From 81b67cb1bd4f8b1903af097bf4d069dfd94f0f87 Mon Sep 17 00:00:00 2001 From: Jason Hall Date: Mon, 6 May 2024 20:02:26 -0400 Subject: [PATCH] new functions: `parse` and `get` (#131) Provider-defined functions are new in Terraform 1.8+ (and opentofu 1.7+) This is expected to be a replacement for the `oci_string` and `oci_ref` datasources, which perform the same logic, but have their results persisted in state, which adds to slowness. Usage ``` output "parsed" { value = provider::oci::parse("cgr.dev/chainguard/static@sha256:abc...").digest # sha256:abcdef... } locals { parsed = provider::oci::parse("cgr.dev/chainguard/static@sha256:abc...").digest # sha256:abcdef... gotten = provider::oci::get("cgr.dev/chainguard/static").digest # sha256:... } ``` Docs https://developer.hashicorp.com/terraform/plugin/framework/functions/concepts https://developer.hashicorp.com/terraform/plugin/framework/functions/returns/object https://developer.hashicorp.com/terraform/plugin/framework/functions/testing --------- Signed-off-by: Jason Hall --- .github/workflows/test.yml | 9 +- docs/functions/get.md | 26 +++ docs/functions/parse.md | 26 +++ internal/provider/get_function.go | 141 +++++++++++++++ internal/provider/get_function_test.go | 199 ++++++++++++++++++++++ internal/provider/parse_function.go | 88 ++++++++++ internal/provider/parse_function_test.go | 87 ++++++++++ internal/provider/provider.go | 10 +- internal/provider/ref_data_source.go | 6 +- internal/provider/ref_data_source_test.go | 3 - internal/provider/string_data_source.go | 2 + internal/provider/types.go | 109 +++++------- 12 files changed, 627 insertions(+), 79 deletions(-) create mode 100644 docs/functions/get.md create mode 100644 docs/functions/parse.md create mode 100644 internal/provider/get_function.go create mode 100644 internal/provider/get_function_test.go create mode 100644 internal/provider/parse_function.go create mode 100644 internal/provider/parse_function_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2624d59..18fcc06 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -60,11 +60,10 @@ jobs: matrix: # list whatever Terraform versions here you would like to support terraform: - - '1.0.*' - - '1.1.*' - - '1.2.*' - - '1.3.*' - - '1.4.*' + - '1.5.*' + - '1.6.*' + - '1.7.*' + - '1.8.*' steps: - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 - uses: actions/setup-go@cdcb36043654635271a94b9a6d1392de5bb323a7 # v5.0.1 diff --git a/docs/functions/get.md b/docs/functions/get.md new file mode 100644 index 0000000..dcd14a4 --- /dev/null +++ b/docs/functions/get.md @@ -0,0 +1,26 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "get function - terraform-provider-oci" +subcategory: "" +description: |- + Parses a pinned OCI string into its constituent parts. +--- + +# function: get + + + + + +## Signature + + +```text +get(input string) object +``` + +## Arguments + + +1. `input` (String) The OCI reference string to get. + diff --git a/docs/functions/parse.md b/docs/functions/parse.md new file mode 100644 index 0000000..5a21918 --- /dev/null +++ b/docs/functions/parse.md @@ -0,0 +1,26 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "parse function - terraform-provider-oci" +subcategory: "" +description: |- + Parses a pinned OCI string into its constituent parts. +--- + +# function: parse + + + + + +## Signature + + +```text +parse(input string) object +``` + +## Arguments + + +1. `input` (String) The OCI reference string to parse. + diff --git a/internal/provider/get_function.go b/internal/provider/get_function.go new file mode 100644 index 0000000..86d008c --- /dev/null +++ b/internal/provider/get_function.go @@ -0,0 +1,141 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/function" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +// Ensure provider defined types fully satisfy framework interfaces. +var _ function.Function = &GetFunction{} + +func NewGetFunction() function.Function { + return &GetFunction{} +} + +// GetFunction defines the function implementation. +type GetFunction struct{} + +// Metadata should return the name of the function, such as parse_xyz. +func (s *GetFunction) Metadata(_ context.Context, _ function.MetadataRequest, resp *function.MetadataResponse) { + resp.Name = "get" +} + +// Definition should return the definition for the function. +func (s *GetFunction) Definition(_ context.Context, _ function.DefinitionRequest, resp *function.DefinitionResponse) { + resp.Definition = function.Definition{ + Summary: "Parses a pinned OCI string into its constituent parts.", + Parameters: []function.Parameter{ + function.StringParameter{ + Name: "input", + Description: "The OCI reference string to get.", + }, + }, + Return: function.ObjectReturn{ + AttributeTypes: map[string]attr.Type{ + "full_ref": basetypes.StringType{}, + "digest": basetypes.StringType{}, + "tag": basetypes.StringType{}, + "manifest": basetypes.ObjectType{AttrTypes: manifestAttribute.AttributeTypes}, + "images": basetypes.MapType{ElemType: imageType}, + "config": basetypes.ObjectType{AttrTypes: configAttribute.AttributeTypes}, + }, + }, + } +} + +// Run should return the result of the function logic. It is called when +// Terraform reaches a function call in the configuration. Argument data +// values should be read from the [RunRequest] and the result value set in +// the [RunResponse]. +func (s *GetFunction) Run(ctx context.Context, req function.RunRequest, resp *function.RunResponse) { + var input string + if ferr := req.Arguments.GetArgument(ctx, 0, &input); ferr != nil { + resp.Error = ferr + return + } + + // Parse the input string into its constituent parts. + ref, err := name.ParseReference(input) + if err != nil { + resp.Error = function.NewFuncError(fmt.Sprintf("Failed to parse OCI reference: %v", err)) + return + } + + result := struct { + FullRef string `tfsdk:"full_ref"` + Digest string `tfsdk:"digest"` + Tag string `tfsdk:"tag"` + Manifest *Manifest `tfsdk:"manifest"` + Images map[string]Image `tfsdk:"images"` + Config *Config `tfsdk:"config"` + }{} + + if t, ok := ref.(name.Tag); ok { + result.Tag = t.TagStr() + } + + desc, err := remote.Get(ref, + remote.WithAuthFromKeychain(authn.DefaultKeychain), + remote.WithUserAgent("terraform-provider-oci"), + remote.WithContext(ctx)) + if err != nil { + resp.Error = function.NewFuncError(fmt.Sprintf("Failed to get image: %v", err)) + return + } + + result.Digest = desc.Digest.String() + result.FullRef = ref.Context().Digest(desc.Digest.String()).String() + + mf := &Manifest{} + if err := mf.FromDescriptor(desc); err != nil { + resp.Error = function.NewFuncError(fmt.Sprintf("Failed to parse manifest: %v", err)) + return + } + result.Manifest = mf + + if desc.MediaType.IsIndex() { + idx, err := desc.ImageIndex() + if err != nil { + resp.Error = function.NewFuncError(fmt.Sprintf("Failed to parse index: %v", err)) + return + } + imf, err := idx.IndexManifest() + if err != nil { + resp.Error = function.NewFuncError(fmt.Sprintf("Failed to parse index manifest: %v", err)) + return + } + result.Images = make(map[string]Image, len(imf.Manifests)) + for _, m := range imf.Manifests { + if m.Platform == nil { + continue + } + result.Images[m.Platform.String()] = Image{ + Digest: m.Digest.String(), + ImageRef: ref.Context().Digest(m.Digest.String()).String(), + } + } + } else if desc.MediaType.IsImage() { + img, err := desc.Image() + if err != nil { + resp.Error = function.NewFuncError(fmt.Sprintf("Failed to parse image: %v", err)) + return + } + cf, err := img.ConfigFile() + if err != nil { + resp.Error = function.NewFuncError(fmt.Sprintf("Failed to parse config: %v", err)) + return + } + cfg := &Config{} + cfg.FromConfigFile(cf) + result.Config = cfg + } + + resp.Error = function.ConcatFuncErrors(resp.Error, resp.Result.Set(ctx, &result)) +} diff --git a/internal/provider/get_function_test.go b/internal/provider/get_function_test.go new file mode 100644 index 0000000..5a17fba --- /dev/null +++ b/internal/provider/get_function_test.go @@ -0,0 +1,199 @@ +package provider + +import ( + "fmt" + "math/big" + "testing" + "time" + + ocitesting "github.com/chainguard-dev/terraform-provider-oci/testing" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/random" + "github.com/google/go-containerregistry/pkg/v1/remote" + ggcrtypes "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfversion" +) + +func TestGetFunction(t *testing.T) { + repo, cleanup := ocitesting.SetupRepository(t, "test") + defer cleanup() + + // Push an image to the local registry. + ref := repo.Tag("latest") + t.Logf("Using ref: %s", ref) + img, err := random.Image(1024, 3) + if err != nil { + t.Fatalf("failed to create image: %v", err) + } + img = mutate.MediaType(img, ggcrtypes.OCIManifestSchema1) + img = mutate.Annotations(img, map[string]string{ //nolint:forcetypeassert + "foo": "bar", + }).(v1.Image) + img, err = mutate.Config(img, v1.Config{ + Env: []string{"FOO=BAR"}, + User: "nobody", + Entrypoint: []string{"/bin/sh"}, + Cmd: []string{"-c", "echo hello world"}, + WorkingDir: "/tmp", + }) + if err != nil { + t.Fatalf("failed to mutate image: %v", err) + } + now := time.Now() + img, err = mutate.CreatedAt(img, v1.Time{Time: now}) + if err != nil { + t.Fatalf("failed to mutate image: %v", err) + } + if err := remote.Write(ref, img); err != nil { + t.Fatalf("failed to write image: %v", err) + } + + d, err := img.Digest() + if err != nil { + t.Fatalf("failed to get image digest: %v", err) + } + + isDescriptor := func(mt ggcrtypes.MediaType) knownvalue.Check { + return knownvalue.ObjectExact(map[string]knownvalue.Check{ + "digest": knownvalue.StringRegexp(digestRE), + "media_type": knownvalue.StringExact(string(mt)), + "size": knownvalue.NotNull(), + "platform": knownvalue.Null(), + }) + } + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{tfversion.SkipBelow(tfversion.Version1_8_0)}, + Steps: []resource.TestStep{{ + Config: fmt.Sprintf(`output "gotten" { value = provider::oci::get(%q) }`, ref), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownOutputValue("gotten", knownvalue.ObjectExact(map[string]knownvalue.Check{ + "full_ref": knownvalue.StringExact(fmt.Sprintf("%s@%s", ref.Context().Name(), d.String())), + "digest": knownvalue.StringExact(d.String()), + "tag": knownvalue.StringExact("latest"), + "manifest": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "schema_version": knownvalue.NumberExact(big.NewFloat(2)), + "media_type": knownvalue.StringExact(string(ggcrtypes.OCIManifestSchema1)), + "config": isDescriptor(ggcrtypes.DockerConfigJSON), + "layers": knownvalue.ListExact([]knownvalue.Check{ + isDescriptor(ggcrtypes.DockerLayer), + isDescriptor(ggcrtypes.DockerLayer), + isDescriptor(ggcrtypes.DockerLayer), + }), + "annotations": knownvalue.MapExact(map[string]knownvalue.Check{"foo": knownvalue.StringExact("bar")}), + "manifests": knownvalue.Null(), + "subject": knownvalue.Null(), + }), + "config": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "env": knownvalue.ListExact([]knownvalue.Check{knownvalue.StringExact("FOO=BAR")}), + "user": knownvalue.StringExact("nobody"), + "entrypoint": knownvalue.ListExact([]knownvalue.Check{knownvalue.StringExact("/bin/sh")}), + "cmd": knownvalue.ListExact([]knownvalue.Check{knownvalue.StringExact("-c"), knownvalue.StringExact("echo hello world")}), + "working_dir": knownvalue.StringExact("/tmp"), + "created_at": knownvalue.StringExact(now.Format(time.RFC3339)), + }), + "images": knownvalue.Null(), + })), + }, + }}, + }) + + // Push an index to the local registry. + var idx v1.ImageIndex = empty.Index + for _, plat := range []v1.Platform{ + {OS: "linux", Architecture: "amd64"}, + {OS: "windows", Architecture: "arm64", Variant: "v3", OSVersion: "1-rc365"}, + } { + plat := plat + img, err := random.Image(1024, 3) + if err != nil { + t.Fatalf("failed to create image: %v", err) + } + img = mutate.MediaType(img, ggcrtypes.OCIManifestSchema1) + idx = mutate.AppendManifests(idx, mutate.IndexAddendum{ + Add: img, + Descriptor: v1.Descriptor{Platform: &plat}, + }) + } + idx = mutate.IndexMediaType(idx, ggcrtypes.OCIImageIndex) + idx = mutate.Annotations(idx, map[string]string{ //nolint:forcetypeassert + "foo": "bar", + }).(v1.ImageIndex) + + ref = repo.Tag("index") + t.Logf("Using ref: %s", ref) + if err := remote.WriteIndex(ref, idx); err != nil { + t.Fatalf("failed to write index: %v", err) + } + + d, err = idx.Digest() + if err != nil { + t.Fatalf("failed to get index digest: %v", err) + } + + // An index specified by tag has a .tag attribute, and all the other index manifest attributes. + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{tfversion.SkipBelow(tfversion.Version1_8_0)}, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{{ + Config: fmt.Sprintf(`output "gotten" { value = provider::oci::get(%q) }`, ref), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownOutputValue("gotten", knownvalue.ObjectExact(map[string]knownvalue.Check{ + "full_ref": knownvalue.StringExact(fmt.Sprintf("%s@%s", ref.Context().Name(), d.String())), + "digest": knownvalue.StringExact(d.String()), + "tag": knownvalue.StringExact("index"), + "manifest": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "schema_version": knownvalue.NumberExact(big.NewFloat(2)), + "media_type": knownvalue.StringExact(string(ggcrtypes.OCIImageIndex)), + "manifests": knownvalue.ListExact([]knownvalue.Check{ + knownvalue.ObjectExact(map[string]knownvalue.Check{ + "digest": knownvalue.StringRegexp(digestRE), + "platform": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "os": knownvalue.StringExact("linux"), + "architecture": knownvalue.StringExact("amd64"), + "variant": knownvalue.StringExact(""), + "os_version": knownvalue.StringExact(""), + }), + "media_type": knownvalue.StringExact(string(ggcrtypes.OCIManifestSchema1)), + "size": knownvalue.NotNull(), + }), + knownvalue.ObjectExact(map[string]knownvalue.Check{ + "digest": knownvalue.StringRegexp(digestRE), + "platform": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "os": knownvalue.StringExact("windows"), + "architecture": knownvalue.StringExact("arm64"), + "variant": knownvalue.StringExact("v3"), + "os_version": knownvalue.StringExact("1-rc365"), + }), + "media_type": knownvalue.StringExact(string(ggcrtypes.OCIManifestSchema1)), + "size": knownvalue.NotNull(), + }), + }), + "annotations": knownvalue.MapExact(map[string]knownvalue.Check{"foo": knownvalue.StringExact("bar")}), + "layers": knownvalue.Null(), + "subject": knownvalue.Null(), + "config": knownvalue.Null(), + }), + "config": knownvalue.Null(), + "images": knownvalue.MapExact(map[string]knownvalue.Check{ + "linux/amd64": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "digest": knownvalue.StringRegexp(digestRE), + "image_ref": knownvalue.NotNull(), + }), + "windows/arm64/v3:1-rc365": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "digest": knownvalue.StringRegexp(digestRE), + "image_ref": knownvalue.NotNull(), + }), + }), + })), + }, + }}, + }) +} diff --git a/internal/provider/parse_function.go b/internal/provider/parse_function.go new file mode 100644 index 0000000..c104e1a --- /dev/null +++ b/internal/provider/parse_function.go @@ -0,0 +1,88 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/function" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +// Ensure provider defined types fully satisfy framework interfaces. +var _ function.Function = &ParseFunction{} + +func NewParseFunction() function.Function { + return &ParseFunction{} +} + +// ParseFunction defines the function implementation. +type ParseFunction struct{} + +// Metadata should return the name of the function, such as parse_xyz. +func (s *ParseFunction) Metadata(_ context.Context, _ function.MetadataRequest, resp *function.MetadataResponse) { + resp.Name = "parse" +} + +// Definition should return the definition for the function. +func (s *ParseFunction) Definition(_ context.Context, _ function.DefinitionRequest, resp *function.DefinitionResponse) { + resp.Definition = function.Definition{ + Summary: "Parses a pinned OCI string into its constituent parts.", + Parameters: []function.Parameter{ + function.StringParameter{ + Name: "input", + Description: "The OCI reference string to parse.", + }, + }, + Return: function.ObjectReturn{ + AttributeTypes: map[string]attr.Type{ + "registry": basetypes.StringType{}, + "repo": basetypes.StringType{}, + "registry_repo": basetypes.StringType{}, + "digest": basetypes.StringType{}, + "pseudo_tag": basetypes.StringType{}, + }, + }, + } +} + +// Run should return the result of the function logic. It is called when +// Terraform reaches a function call in the configuration. Argument data +// values should be read from the [RunRequest] and the result value set in +// the [RunResponse]. +func (s *ParseFunction) Run(ctx context.Context, req function.RunRequest, resp *function.RunResponse) { + var input string + if ferr := req.Arguments.GetArgument(ctx, 0, &input); ferr != nil { + resp.Error = ferr + return + } + + // Parse the input string into its constituent parts. + ref, err := name.ParseReference(input) + if err != nil { + resp.Error = function.NewFuncError(fmt.Sprintf("Failed to parse OCI reference: %v", err)) + return + } + + if _, ok := ref.(name.Tag); ok { + resp.Error = function.NewFuncError(fmt.Sprintf("Reference %s contains only a tag, but a digest is required", input)) + return + } + + result := struct { + Registry string `tfsdk:"registry"` + Repo string `tfsdk:"repo"` + RegistryRepo string `tfsdk:"registry_repo"` + Digest string `tfsdk:"digest"` + PseudoTag string `tfsdk:"pseudo_tag"` + }{ + Registry: ref.Context().RegistryStr(), + Repo: ref.Context().RepositoryStr(), + RegistryRepo: ref.Context().RegistryStr() + "/" + ref.Context().RepositoryStr(), + Digest: ref.Identifier(), + PseudoTag: fmt.Sprintf("unused@%s", ref.Identifier()), + } + + resp.Error = function.ConcatFuncErrors(resp.Error, resp.Result.Set(ctx, &result)) +} diff --git a/internal/provider/parse_function_test.go b/internal/provider/parse_function_test.go new file mode 100644 index 0000000..d1ae3bf --- /dev/null +++ b/internal/provider/parse_function_test.go @@ -0,0 +1,87 @@ +package provider + +import ( + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfversion" +) + +func TestParseFunction(t *testing.T) { + // A naked ref string errors due to missing digest + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{tfversion.SkipBelow(tfversion.Version1_8_0)}, + Steps: []resource.TestStep{{ + Config: `output "parsed" { value = provider::oci::parse("") }`, + ExpectError: regexp.MustCompile(""), // any error is ok + }}, + }) + + // A fully qualified tag ref string errors due to missing digest + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{tfversion.SkipBelow(tfversion.Version1_8_0)}, + Steps: []resource.TestStep{{ + Config: `output "parsed" { value = provider::oci::parse("cgr.dev/foo/sample:latest") }`, + ExpectError: regexp.MustCompile(""), // any error is ok + }}, + }) + + // A fully qualified ref + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{tfversion.SkipBelow(tfversion.Version1_8_0)}, + Steps: []resource.TestStep{{ + Config: `output "parsed" { value = provider::oci::parse("cgr.dev/foo/sample@sha256:1234567890123456789012345678901234567890123456789012345678901234") }`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownOutputValue("parsed", knownvalue.ObjectExact(map[string]knownvalue.Check{ + "registry": knownvalue.StringExact("cgr.dev"), + "repo": knownvalue.StringExact("foo/sample"), + "registry_repo": knownvalue.StringExact("cgr.dev/foo/sample"), + "digest": knownvalue.StringExact("sha256:1234567890123456789012345678901234567890123456789012345678901234"), + "pseudo_tag": knownvalue.StringExact("unused@sha256:1234567890123456789012345678901234567890123456789012345678901234"), + })), + }, + }}, + }) + + // A shorthand digest ref string has everything (including a pseudo tag) + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{tfversion.SkipBelow(tfversion.Version1_8_0)}, + Steps: []resource.TestStep{{ + Config: `output "parsed" { value = provider::oci::parse("sample@sha256:1234567890123456789012345678901234567890123456789012345678901234") }`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownOutputValue("parsed", knownvalue.ObjectExact(map[string]knownvalue.Check{ + "registry": knownvalue.StringExact("index.docker.io"), + "repo": knownvalue.StringExact("library/sample"), + "registry_repo": knownvalue.StringExact("index.docker.io/library/sample"), + "digest": knownvalue.StringExact("sha256:1234567890123456789012345678901234567890123456789012345678901234"), + "pseudo_tag": knownvalue.StringExact("unused@sha256:1234567890123456789012345678901234567890123456789012345678901234"), + })), + }, + }}, + }) + + // A shorthand tagged and digest ref string has everything (including a replaced pseudo tag) + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{tfversion.SkipBelow(tfversion.Version1_8_0)}, + Steps: []resource.TestStep{{ + Config: `output "parsed" { value = provider::oci::parse("sample:cursed@sha256:1234567890123456789012345678901234567890123456789012345678901234") }`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownOutputValue("parsed", knownvalue.ObjectExact(map[string]knownvalue.Check{ + "registry": knownvalue.StringExact("index.docker.io"), + "repo": knownvalue.StringExact("library/sample"), + "registry_repo": knownvalue.StringExact("index.docker.io/library/sample"), + "digest": knownvalue.StringExact("sha256:1234567890123456789012345678901234567890123456789012345678901234"), + "pseudo_tag": knownvalue.StringExact("unused@sha256:1234567890123456789012345678901234567890123456789012345678901234"), + })), + }, + }}, + }) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index a7aa0ee..d0a7f05 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -7,12 +7,13 @@ import ( "github.com/google/go-containerregistry/pkg/v1/google" "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/function" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/provider/schema" "github.com/hashicorp/terraform-plugin-framework/resource" ) -var _ provider.Provider = &OCIProvider{} +var _ provider.ProviderWithFunctions = &OCIProvider{} // OCIProvider defines the provider implementation. type OCIProvider struct { @@ -110,6 +111,13 @@ func (p *OCIProvider) DataSources(ctx context.Context) []func() datasource.DataS } } +func (p *OCIProvider) Functions(ctx context.Context) []func() function.Function { + return []func() function.Function{ + NewParseFunction, + NewGetFunction, + } +} + func New(version string) func() provider.Provider { return func() provider.Provider { return &OCIProvider{ diff --git a/internal/provider/ref_data_source.go b/internal/provider/ref_data_source.go index 1b2ab78..16c24f7 100644 --- a/internal/provider/ref_data_source.go +++ b/internal/provider/ref_data_source.go @@ -46,7 +46,7 @@ func (d *RefDataSource) Metadata(ctx context.Context, req datasource.MetadataReq func (d *RefDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { resp.Schema = schema.Schema{ MarkdownDescription: "Image ref data source", - + DeprecationMessage: "This data source is deprecated and will be removed in a future release. Use the `get` function instead.", Attributes: map[string]schema.Attribute{ "ref": schema.StringAttribute{ MarkdownDescription: "Image ref to lookup", @@ -141,8 +141,8 @@ func (d *RefDataSource) Read(ctx context.Context, req datasource.ReadRequest, re continue } data.Images[m.Platform.String()] = Image{ - Digest: types.StringValue(m.Digest.String()), - ImageRef: types.StringValue(ref.Context().Digest(m.Digest.String()).String()), + Digest: m.Digest.String(), + ImageRef: ref.Context().Digest(m.Digest.String()).String(), } } } else if desc.MediaType.IsImage() { diff --git a/internal/provider/ref_data_source_test.go b/internal/provider/ref_data_source_test.go index bbfb4db..2ff821d 100644 --- a/internal/provider/ref_data_source_test.go +++ b/internal/provider/ref_data_source_test.go @@ -57,9 +57,6 @@ func TestAccRefDataSource(t *testing.T) { t.Fatalf("failed to get image digest: %v", err) } - cf, _ := img.ConfigFile() - t.Logf("Image config: %+v", cf) // TODO: remove - // An image specified by tag has a .tag attribute, and all the other image manifest attributes. resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, diff --git a/internal/provider/string_data_source.go b/internal/provider/string_data_source.go index 57f17fe..e0fca6f 100644 --- a/internal/provider/string_data_source.go +++ b/internal/provider/string_data_source.go @@ -41,6 +41,7 @@ func (*StringDataSource) Metadata(ctx context.Context, req datasource.MetadataRe func (d *StringDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { resp.Schema = schema.Schema{ MarkdownDescription: `Data source for parsing a pinned oci string into its constituent parts. A pinned oci reference is one that includes a digest, and is in the format: '${registry}/${repo}@${digest}'. For example: 'cgr.dev/my-project/my-image@sha256:...'.`, + DeprecationMessage: "This data source is deprecated and will be removed in a future release. Use the `parse` function instead.", Attributes: map[string]schema.Attribute{ "input": schema.StringAttribute{ MarkdownDescription: `The oci reference string to parse. This supports any valid oci reference string, including those with a tag, digest, or both. For example: 'cgr.dev/my-project/my-image:latest' or 'cgr.dev/my-project/my-image@sha256:...'. Note that when tags are provided, they will be replaced in favor of the digest.`, @@ -85,6 +86,7 @@ func (d *StringDataSource) Read(ctx context.Context, req datasource.ReadRequest, ref, err := name.ParseReference(data.Input.ValueString()) if err != nil { resp.Diagnostics.AddError("Invalid reference string", fmt.Sprintf("Unable to parse ref %s, got error: %v", data.Input.ValueString(), err)) + return } data.Id = types.StringValue(ref.Name()) diff --git a/internal/provider/types.go b/internal/provider/types.go index 367ff47..b796cc6 100644 --- a/internal/provider/types.go +++ b/internal/provider/types.go @@ -2,28 +2,25 @@ package provider import ( "fmt" - "math/big" "time" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/remote" - "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) type Image struct { - Digest types.String `tfsdk:"digest"` - ImageRef types.String `tfsdk:"image_ref"` + Digest string `tfsdk:"digest"` + ImageRef string `tfsdk:"image_ref"` } type Manifest struct { - SchemaVersion types.Number `tfsdk:"schema_version"` - MediaType types.String `tfsdk:"media_type"` - Config *Descriptor `tfsdk:"config"` - Layers []Descriptor `tfsdk:"layers"` - Annotations map[string]types.String `tfsdk:"annotations"` - Manifests []Descriptor `tfsdk:"manifests"` - Subject *Descriptor `tfsdk:"subject"` + SchemaVersion int64 `tfsdk:"schema_version"` + MediaType string `tfsdk:"media_type"` + Config *Descriptor `tfsdk:"config"` + Layers []Descriptor `tfsdk:"layers"` + Annotations map[string]string `tfsdk:"annotations"` + Manifests []Descriptor `tfsdk:"manifests"` + Subject *Descriptor `tfsdk:"subject"` } func (m *Manifest) FromDescriptor(desc *remote.Descriptor) error { @@ -37,11 +34,11 @@ func (m *Manifest) FromDescriptor(desc *remote.Descriptor) error { if err != nil { return err } - m.SchemaVersion = basetypes.NewNumberValue(big.NewFloat(float64(imf.SchemaVersion))) - m.MediaType = basetypes.NewStringValue(string(imf.MediaType)) + m.SchemaVersion = imf.SchemaVersion + m.MediaType = string(imf.MediaType) m.Config = ToDescriptor(&imf.Config) m.Layers = ToDescriptors(imf.Layers) - m.Annotations = ToStringMap(imf.Annotations) + m.Annotations = imf.Annotations m.Subject = ToDescriptor(imf.Subject) m.Manifests = nil return nil @@ -55,10 +52,10 @@ func (m *Manifest) FromDescriptor(desc *remote.Descriptor) error { if err != nil { return err } - m.SchemaVersion = basetypes.NewNumberValue(big.NewFloat(float64(imf.SchemaVersion))) - m.MediaType = basetypes.NewStringValue(string(imf.MediaType)) + m.SchemaVersion = imf.SchemaVersion + m.MediaType = string(imf.MediaType) m.Manifests = ToDescriptors(imf.Manifests) - m.Annotations = ToStringMap(imf.Annotations) + m.Annotations = imf.Annotations m.Subject = ToDescriptor(imf.Subject) m.Config = nil m.Layers = nil @@ -68,25 +65,14 @@ func (m *Manifest) FromDescriptor(desc *remote.Descriptor) error { return fmt.Errorf("unsupported media type: %s", desc.MediaType) } -func ToStringMap(m map[string]string) map[string]basetypes.StringValue { - if m == nil { - return map[string]basetypes.StringValue{} - } - out := make(map[string]basetypes.StringValue, len(m)) - for k, v := range m { - out[k] = basetypes.NewStringValue(v) - } - return out -} - func ToDescriptor(d *v1.Descriptor) *Descriptor { if d == nil { return nil } return &Descriptor{ - MediaType: basetypes.NewStringValue(string(d.MediaType)), - Size: basetypes.NewNumberValue(big.NewFloat(float64(d.Size))), - Digest: basetypes.NewStringValue(d.Digest.String()), + MediaType: string(d.MediaType), + Size: d.Size, + Digest: d.Digest.String(), Platform: ToPlatform(d.Platform), } } @@ -96,10 +82,10 @@ func ToPlatform(p *v1.Platform) *Platform { return nil } return &Platform{ - Architecture: basetypes.NewStringValue(p.Architecture), - OS: basetypes.NewStringValue(p.OS), - Variant: basetypes.NewStringValue(p.Variant), - OSVersion: basetypes.NewStringValue(p.OSVersion), + Architecture: p.Architecture, + OS: p.OS, + Variant: p.Variant, + OSVersion: p.OSVersion, } } @@ -112,26 +98,26 @@ func ToDescriptors(d []v1.Descriptor) []Descriptor { } type Descriptor struct { - MediaType types.String `tfsdk:"media_type"` - Size types.Number `tfsdk:"size"` - Digest types.String `tfsdk:"digest"` - Platform *Platform `tfsdk:"platform"` + MediaType string `tfsdk:"media_type"` + Size int64 `tfsdk:"size"` + Digest string `tfsdk:"digest"` + Platform *Platform `tfsdk:"platform"` } type Platform struct { - Architecture types.String `tfsdk:"architecture"` - OS types.String `tfsdk:"os"` - Variant types.String `tfsdk:"variant"` - OSVersion types.String `tfsdk:"os_version"` + Architecture string `tfsdk:"architecture"` + OS string `tfsdk:"os"` + Variant string `tfsdk:"variant"` + OSVersion string `tfsdk:"os_version"` } type Config struct { - Env []types.String `tfsdk:"env"` - User types.String `tfsdk:"user"` - WorkingDir types.String `tfsdk:"working_dir"` - Entrypoint []types.String `tfsdk:"entrypoint"` - Cmd []types.String `tfsdk:"cmd"` - CreatedAt types.String `tfsdk:"created_at"` + Env []string `tfsdk:"env"` + User string `tfsdk:"user"` + WorkingDir string `tfsdk:"working_dir"` + Entrypoint []string `tfsdk:"entrypoint"` + Cmd []string `tfsdk:"cmd"` + CreatedAt string `tfsdk:"created_at"` } func (c *Config) FromConfigFile(cf *v1.ConfigFile) { @@ -142,21 +128,10 @@ func (c *Config) FromConfigFile(cf *v1.ConfigFile) { return } - c.Env = ToStrings(cf.Config.Env) - c.User = basetypes.NewStringValue(cf.Config.User) - c.WorkingDir = basetypes.NewStringValue(cf.Config.WorkingDir) - c.Entrypoint = ToStrings(cf.Config.Entrypoint) - c.Cmd = ToStrings(cf.Config.Cmd) - c.CreatedAt = basetypes.NewStringValue(cf.Created.Time.Format(time.RFC3339)) -} - -func ToStrings(ss []string) []basetypes.StringValue { - if len(ss) == 0 { - return nil - } - out := make([]basetypes.StringValue, len(ss)) - for i, s := range ss { - out[i] = basetypes.NewStringValue(s) - } - return out + c.Env = cf.Config.Env + c.User = cf.Config.User + c.WorkingDir = cf.Config.WorkingDir + c.Entrypoint = cf.Config.Entrypoint + c.Cmd = cf.Config.Cmd + c.CreatedAt = cf.Created.Time.Format(time.RFC3339) }