diff --git a/api/krm.go b/api/krm.go index 63162f1..39992b4 100644 --- a/api/krm.go +++ b/api/krm.go @@ -78,7 +78,11 @@ func (i *KRMInput) GetRemoteClient(items []*kyaml.RNode) (*auth.Client, error) { } } - return registryauth.ConfigureClient(i.RemoteModule.Registry, secret) + reference, err := i.RemoteModule.GetReference() + if err != nil { + return nil, fmt.Errorf("failed to get reference: %w", err) + } + return registryauth.ConfigureClient(reference.Registry, secret) } // ItemMatchReference checks if the given item matches the provided selector. diff --git a/api/remote_module.go b/api/remote_module.go index 387295c..a7543d7 100644 --- a/api/remote_module.go +++ b/api/remote_module.go @@ -1,13 +1,43 @@ package api -import "sigs.k8s.io/kustomize/api/types" +import ( + "fmt" + + "oras.land/oras-go/v2/registry" + "sigs.k8s.io/kustomize/api/types" +) // RemoteModule defines the structure to describe a remote CUE module to fetch from an OCI registry. type RemoteModule struct { - Registry string `yaml:"registry" json:"registry"` - Repo string `yaml:"repo" json:"repo"` - Tag string `yaml:"tag" json:"tag"` + // Ref is the full OCI reference in the format: registry/repo:tag + // Example: ghcr.io/workday/my-module:v1.0.0 + // If Ref is specified, it takes precedence over Registry, Repo, and Tag. + Ref string `yaml:"ref,omitempty" json:"ref,omitempty"` + + // Registry is the OCI registry hosting the module. + // + // Deprecated: Use Ref instead. Registry will be removed in a future version. + Registry string `yaml:"registry,omitempty" json:"registry,omitempty"` + // Repo is the repository path within the OCI registry. + // + // Deprecated: Use Ref instead. Repo will be removed in a future version. + Repo string `yaml:"repo,omitempty" json:"repo,omitempty"` + // Tag is the version tag of the module. + // + // Deprecated: Use Ref instead. Tag will be removed in a future version. + Tag string `yaml:"tag,omitempty" json:"tag,omitempty"` Auth *types.Selector `yaml:"auth,omitempty" json:"auth,omitempty"` PlainHTTP bool `yaml:"plainHTTP,omitempty" json:"plainHTTP,omitempty"` } + +func (r *RemoteModule) GetReference() (registry.Reference, error) { + if r.Ref != "" { + return registry.ParseReference(r.Ref) + } + referenceStr := fmt.Sprintf("%s/%s", r.Registry, r.Repo) + if r.Tag != "" { + referenceStr = fmt.Sprintf("%s:%s", referenceStr, r.Tag) + } + return registry.ParseReference(referenceStr) +} diff --git a/api/remote_module_test.go b/api/remote_module_test.go new file mode 100644 index 0000000..cbf61dc --- /dev/null +++ b/api/remote_module_test.go @@ -0,0 +1,150 @@ +package api + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRemoteModule_GetReference(t *testing.T) { + tests := []struct { + name string + module RemoteModule + wantRegistry string + wantRepo string + wantRef string + wantErr bool + }{ + { + name: "uses ref when present", + module: RemoteModule{ + Ref: "ghcr.io/workday/module:v1.0.0", + Registry: "old-registry.io", + Repo: "old-repo", + Tag: "old-tag", + }, + wantRegistry: "ghcr.io", + wantRepo: "workday/module", + wantRef: "v1.0.0", + wantErr: false, + }, + { + name: "constructs from separate fields when ref is empty", + module: RemoteModule{ + Registry: "docker.io", + Repo: "library/nginx", + Tag: "latest", + }, + wantRegistry: "docker.io", + wantRepo: "library/nginx", + wantRef: "latest", + wantErr: false, + }, + { + name: "handles nested repo paths", + module: RemoteModule{ + Ref: "registry.io/org/team/project:v2.0.0", + }, + wantRegistry: "registry.io", + wantRepo: "org/team/project", + wantRef: "v2.0.0", + wantErr: false, + }, + { + name: "handles registry with port", + module: RemoteModule{ + Ref: "registry:5000/sample-module:latest", + }, + wantRegistry: "registry:5000", + wantRepo: "sample-module", + wantRef: "latest", + wantErr: false, + }, + { + name: "handles localhost registry", + module: RemoteModule{ + Ref: "localhost:5000/my-module:v1.0.0", + }, + wantRegistry: "localhost:5000", + wantRepo: "my-module", + wantRef: "v1.0.0", + wantErr: false, + }, + { + name: "constructs without tag defaults to empty", + module: RemoteModule{ + Registry: "ghcr.io", + Repo: "workday/module", + }, + wantRegistry: "ghcr.io", + wantRepo: "workday/module", + wantRef: "", + wantErr: false, + }, + { + name: "handles sha256 tags", + module: RemoteModule{ + Ref: "docker.io/nginx:sha256-abc123", + }, + wantRegistry: "docker.io", + wantRepo: "nginx", + wantRef: "sha256-abc123", + wantErr: false, + }, + { + name: "returns error for invalid ref", + module: RemoteModule{ + Ref: "invalid", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ref, err := tt.module.GetReference() + + if tt.wantErr { + require.Error(t, err) + } else { + assert.Equal(t, tt.wantRegistry, ref.Registry) + assert.Equal(t, tt.wantRepo, ref.Repository) + assert.Equal(t, tt.wantRef, ref.Reference) + } + }) + } +} + +func TestRemoteModule_BackwardsCompatibility(t *testing.T) { + t.Run("deprecated fields still work", func(t *testing.T) { + module := RemoteModule{ + Registry: "ghcr.io", + Repo: "workday/module", + Tag: "v1.0.0", + } + + ref, err := module.GetReference() + require.NoError(t, err, "GetReference() unexpected error = %v", err) + + assert.Equal(t, "ghcr.io", ref.Registry) + assert.Equal(t, "workday/module", ref.Repository) + assert.Equal(t, "v1.0.0", ref.Reference) + }) + + t.Run("ref takes precedence over deprecated fields", func(t *testing.T) { + module := RemoteModule{ + Ref: "new-registry.io/new-repo:v2.0.0", + Registry: "old-registry.io", + Repo: "old-repo", + Tag: "v1.0.0", + } + + ref, err := module.GetReference() + require.NoError(t, err) + + assert.Equal(t, "new-registry.io", ref.Registry) + assert.Equal(t, "new-repo", ref.Repository) + assert.Equal(t, "v2.0.0", ref.Reference) + }) +} diff --git a/pkg/cuestomize/model/oci_provider.go b/pkg/cuestomize/model/oci_provider.go index 80346b2..5a58c15 100644 --- a/pkg/cuestomize/model/oci_provider.go +++ b/pkg/cuestomize/model/oci_provider.go @@ -9,6 +9,7 @@ import ( "github.com/Workday/cuestomize/api" "github.com/Workday/cuestomize/pkg/oci/fetcher" "github.com/go-logr/logr" + "oras.land/oras-go/v2/registry" "oras.land/oras-go/v2/registry/remote/auth" kyaml "sigs.k8s.io/kustomize/kyaml/yaml" ) @@ -18,20 +19,32 @@ type OCIOption func(*ociModelProviderOptions) // ociModelProviderOptions holds configuration options for OCIModelProvider. type ociModelProviderOptions struct { - Registry string - Repo string - Tag string + Reference registry.Reference PlainHTTP bool Client *auth.Client WorkingDir string } -// WithRemote configures the OCI remote to fetch the CUE model from. -func WithRemote(registry, repo, tag string) OCIOption { +// WithRemoteParts configures the OCI remote to fetch the CUE model from an OCI registry. +// +// Panics if the provided registry, repo, and tag do not form a valid OCI reference. +func WithRemoteParts(reg, repo, tag string) OCIOption { + reference := fmt.Sprintf("%s/%s", reg, repo) + if tag != "" { + reference = fmt.Sprintf("%s:%s", reference, tag) + } + ref, err := registry.ParseReference(reference) + if err != nil { + panic(fmt.Sprintf("invalid reference: %v", err)) + } return func(opts *ociModelProviderOptions) { - opts.Registry = registry - opts.Repo = repo - opts.Tag = tag + opts.Reference = ref + } +} + +func WithRemote(ref registry.Reference) OCIOption { + return func(opts *ociModelProviderOptions) { + opts.Reference = ref } } @@ -58,9 +71,7 @@ func WithClient(client *auth.Client) OCIOption { // OCIModelProvider is a model provider that fetches the CUE model from an OCI registry. type OCIModelProvider struct { - registry string - repo string - tag string + reference registry.Reference plainHTTP bool workingDir string client *auth.Client @@ -75,8 +86,14 @@ func NewOCIModelProviderFromConfigAndItems(config *api.KRMInput, items []*kyaml. if err != nil { return nil, fmt.Errorf("failed to configure remote client: %w", err) } + + reference, err := config.RemoteModule.GetReference() + if err != nil { + return nil, fmt.Errorf("failed to get reference: %w", err) + } + return New( - WithRemote(config.RemoteModule.Registry, config.RemoteModule.Repo, config.RemoteModule.Tag), + WithRemote(reference), WithPlainHTTP(config.RemoteModule.PlainHTTP), WithClient(client), ) @@ -102,9 +119,7 @@ func New(opts ...OCIOption) (*OCIModelProvider, error) { } return &OCIModelProvider{ - registry: options.Registry, - repo: options.Repo, - tag: options.Tag, + reference: options.Reference, plainHTTP: options.PlainHTTP, workingDir: options.WorkingDir, client: options.Client, @@ -119,7 +134,7 @@ func (p *OCIModelProvider) Path() string { // Get fetches the CUE model from the OCI registry and stores it in the working directory. func (p *OCIModelProvider) Get(ctx context.Context) error { log := logr.FromContextOrDiscard(ctx).V(4).WithValues( - "registry", p.registry, "repo", p.repo, "tag", p.tag, "workingDir", p.workingDir, + "registry", p.reference.Registry, "repo", p.reference.Repository, "tag", p.reference.Reference, "workingDir", p.workingDir, ) log.Info("fetching from OCI registry", "plainHTTP", p.plainHTTP) @@ -128,9 +143,7 @@ func (p *OCIModelProvider) Get(ctx context.Context) error { ctx, p.client, p.workingDir, - p.registry, - p.repo, - p.tag, + p.reference, p.plainHTTP, ) if err != nil { diff --git a/pkg/oci/fetcher/fetcher.go b/pkg/oci/fetcher/fetcher.go index 87b0b42..0e25e0c 100644 --- a/pkg/oci/fetcher/fetcher.go +++ b/pkg/oci/fetcher/fetcher.go @@ -8,11 +8,12 @@ import ( "github.com/go-logr/logr" "oras.land/oras-go/v2" "oras.land/oras-go/v2/content/file" + "oras.land/oras-go/v2/registry" "oras.land/oras-go/v2/registry/remote" ) // FetchFromOCIRegistry fetches an artifact from an OCI registry and stores it in the specified working directory. -func FetchFromOCIRegistry(ctx context.Context, client remote.Client, workingDir, reg, repo, tag string, plainHTTP bool) error { +func FetchFromOCIRegistry(ctx context.Context, client remote.Client, workingDir string, ref registry.Reference, plainHTTP bool) error { log := logr.FromContextOrDiscard(ctx).V(4) fs, err := file.New(workingDir) @@ -20,7 +21,7 @@ func FetchFromOCIRegistry(ctx context.Context, client remote.Client, workingDir, return fmt.Errorf("failed to create file store: %w", err) } - repository, err := remote.NewRepository(reg + "/" + repo) + repository, err := remote.NewRepository(ref.String()) if err != nil { return err } @@ -29,14 +30,15 @@ func FetchFromOCIRegistry(ctx context.Context, client remote.Client, workingDir, } repository.PlainHTTP = plainHTTP - desc, err := oras.Copy(ctx, repository, tag, fs, tag, oras.DefaultCopyOptions) + desc, err := oras.Copy(ctx, repository, ref.Reference, fs, ref.Reference, oras.DefaultCopyOptions) if err != nil { return err } log.Info("fetched artifact from OCI registry", - "reg", reg, - "repo", repo, + "reg", ref.Registry, + "repo", ref.Repository, + "tag", ref.Reference, "workingDir", workingDir, "digest", desc.Digest.String(), "mediaType", desc.MediaType, diff --git a/pkg/oci/fetcher/fetcher_integration_test.go b/pkg/oci/fetcher/fetcher_integration_test.go index 7ad65cb..3b010be 100644 --- a/pkg/oci/fetcher/fetcher_integration_test.go +++ b/pkg/oci/fetcher/fetcher_integration_test.go @@ -1,12 +1,14 @@ package fetcher import ( + "fmt" "os" "path/filepath" "testing" "github.com/Workday/cuestomize/internal/pkg/testhelpers" "github.com/stretchr/testify/require" + "oras.land/oras-go/v2/registry" "oras.land/oras-go/v2/registry/remote" "oras.land/oras-go/v2/registry/remote/auth" ) @@ -76,8 +78,11 @@ func Test_FetchFromRegistry(t *testing.T) { // push testdata/sample-module to the registry _ = testhelpers.PushDirectoryToOCIRegistryT(t, tc.registryHost+"/"+tc.repo+":"+tc.tag, tc.testdataDir, tc.artifactType, tc.tag, tc.client, tc.plainHTTP) + ref, err := registry.ParseReference(fmt.Sprintf("%s/%s:%s", tc.registryHost, tc.repo, tc.tag)) + require.NoError(t, err, "failed to parse OCI reference") + // Fetch the module from the registry - err := FetchFromOCIRegistry(ctx, tc.client, tempDir, tc.registryHost, tc.repo, tc.tag, tc.plainHTTP) + err = FetchFromOCIRegistry(ctx, tc.client, tempDir, ref, tc.plainHTTP) if !tc.shouldError { require.NoError(t, err, "failed to fetch module from OCI registry") // verify that tempDir contains the expected files diff --git a/semver b/semver index a2268e2..9fc80f9 100644 --- a/semver +++ b/semver @@ -1 +1 @@ -0.3.1 \ No newline at end of file +0.3.2 \ No newline at end of file