diff --git a/cmd/client/commands/build_collection.go b/cmd/client/commands/build_collection.go index 0e244b64..51e228eb 100644 --- a/cmd/client/commands/build_collection.go +++ b/cmd/client/commands/build_collection.go @@ -86,6 +86,10 @@ func (o *BuildCollectionOptions) Validate() error { } func (o *BuildCollectionOptions) Run(ctx context.Context) error { + if err := o.Remote.LoadRegistryConfig(); err != nil { + return err + } + space, err := workspace.NewLocalWorkspace(o.RootDir) if err != nil { return err @@ -104,6 +108,7 @@ func (o *BuildCollectionOptions) Run(ctx context.Context) error { orasclient.SkipTLSVerify(o.Insecure), orasclient.WithAuthConfigs(o.Configs), orasclient.WithPlainHTTP(o.PlainHTTP), + orasclient.WithRegistryConfig(o.RegistryConfig), ) if err != nil { return fmt.Errorf("error configuring client: %v", err) diff --git a/cmd/client/commands/options/common.go b/cmd/client/commands/options/common.go index f6e40f85..5c7408fa 100644 --- a/cmd/client/commands/options/common.go +++ b/cmd/client/commands/options/common.go @@ -32,7 +32,7 @@ func (o *Common) BindFlags(fs *pflag.FlagSet) { "Log level (debug, info, warn, error, fatal)") } -// Init initializes default values for Common options. +// Init initializes default values for Common options at runtime. func (o *Common) Init() error { logger, err := log.NewLogger(o.IOStreams.Out, o.LogLevel) if err != nil { diff --git a/cmd/client/commands/options/remote.go b/cmd/client/commands/options/remote.go index fefc49fe..3d90aba8 100644 --- a/cmd/client/commands/options/remote.go +++ b/cmd/client/commands/options/remote.go @@ -1,11 +1,20 @@ package options -import "github.com/spf13/pflag" +import ( + "errors" + + "github.com/mitchellh/mapstructure" + "github.com/spf13/pflag" + "github.com/spf13/viper" + + "github.com/uor-framework/uor-client-go/registryclient" +) // Remote describes remote configuration options that can be set. type Remote struct { - Insecure bool - PlainHTTP bool + Insecure bool + PlainHTTP bool + RegistryConfig registryclient.RegistryConfig } // BindFlags binds options from a flag set to Remote options. @@ -14,6 +23,28 @@ func (o *Remote) BindFlags(fs *pflag.FlagSet) { fs.BoolVarP(&o.PlainHTTP, "plain-http", "", o.PlainHTTP, "use plain http and not https when contacting registries") } +// LoadRegistryConfig loads the registry config from disk. +func (o *Remote) LoadRegistryConfig() error { + viper.SetConfigName("registry-config") + viper.SetConfigType("yaml") + viper.AddConfigPath(".") + viper.AddConfigPath("$HOME/.uor") + viper.AddConfigPath(".") + err := viper.ReadInConfig() + if err != nil { + var configNotFound viper.ConfigFileNotFoundError + if errors.As(err, &configNotFound) { + return nil + } + return err + } + option := viper.DecoderConfigOption(func(config *mapstructure.DecoderConfig) { + config.TagName = "json" + }) + + return viper.Unmarshal(&o.RegistryConfig, option) +} + // RemoteAuth describes remote authentication configuration options that can be set. type RemoteAuth struct { Configs []string diff --git a/cmd/client/commands/pull.go b/cmd/client/commands/pull.go index 4e69715e..82895fc1 100644 --- a/cmd/client/commands/pull.go +++ b/cmd/client/commands/pull.go @@ -105,6 +105,9 @@ func (o *PullOptions) Validate() error { } func (o *PullOptions) Run(ctx context.Context) error { + if err := o.Remote.LoadRegistryConfig(); err != nil { + return err + } if !o.NoVerify { o.Logger.Infof("Checking signature of %s", o.Source) @@ -139,6 +142,7 @@ func (o *PullOptions) Run(ctx context.Context) error { orasclient.WithPlainHTTP(o.PlainHTTP), orasclient.WithCache(cache), orasclient.WithPullableAttributes(matcher), + orasclient.WithRegistryConfig(o.RegistryConfig), ) if err != nil { return fmt.Errorf("error configuring client: %v", err) diff --git a/cmd/client/commands/push.go b/cmd/client/commands/push.go index 42f03bb0..74f9a0e2 100644 --- a/cmd/client/commands/push.go +++ b/cmd/client/commands/push.go @@ -76,6 +76,10 @@ func (o *PushOptions) Run(ctx context.Context) error { return err } + // Not adding a registry configuration when pushing since only + // one reference us being handled at a time. + // QUESTION(jpower432): Could this create a problem? I think it would + // be more unexpected to publish collection to declared mirrors. client, err := orasclient.NewClient( orasclient.SkipTLSVerify(o.Insecure), orasclient.WithAuthConfigs(o.Configs), diff --git a/cmd/client/commands/serve.go b/cmd/client/commands/serve.go index b10615d3..1da45051 100644 --- a/cmd/client/commands/serve.go +++ b/cmd/client/commands/serve.go @@ -71,6 +71,10 @@ func (o *ServeOptions) Validate() error { } func (o *ServeOptions) Run(ctx context.Context) error { + if err := o.Remote.LoadRegistryConfig(); err != nil { + return err + } + ctx, cancel := context.WithCancel(ctx) defer cancel() @@ -84,9 +88,10 @@ func (o *ServeOptions) Run(ctx context.Context) error { manager := defaultmanager.New(cache, o.Logger) opts := collectionmanager.ServiceOptions{ - Insecure: o.Insecure, - PlainHTTP: o.PlainHTTP, - PullCache: cache, + Insecure: o.Insecure, + PlainHTTP: o.PlainHTTP, + PullCache: cache, + RegistryConfig: o.RegistryConfig, } service := collectionmanager.FromManager(manager, opts) diff --git a/go.mod b/go.mod index f09903eb..89e5498b 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,11 @@ require ( sigs.k8s.io/yaml v1.3.0 ) -require github.com/sigstore/cosign v1.12.1 +require ( + github.com/mitchellh/mapstructure v1.5.0 + github.com/sigstore/cosign v1.12.1 + github.com/spf13/viper v1.13.0 +) require ( bitbucket.org/creachadair/shell v0.0.7 // indirect @@ -174,7 +178,6 @@ require ( github.com/miekg/pkcs11 v1.1.1 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-wordwrap v1.0.0 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/spdystream v0.2.0 // indirect github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect @@ -213,7 +216,6 @@ require ( github.com/soheilhy/cmux v0.1.5 // indirect github.com/spf13/cast v1.5.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect - github.com/spf13/viper v1.13.0 // indirect github.com/spiffe/go-spiffe/v2 v2.1.1 // indirect github.com/subosito/gotenv v1.4.1 // indirect github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d // indirect diff --git a/registryclient/errors.go b/registryclient/errors.go new file mode 100644 index 00000000..b28b75b5 --- /dev/null +++ b/registryclient/errors.go @@ -0,0 +1,12 @@ +package registryclient + +import "fmt" + +// ErrNoAvailableMirrors denotes that all registry mirrors are not accessible. +type ErrNoAvailableMirrors struct { + Registry string +} + +func (e *ErrNoAvailableMirrors) Error() string { + return fmt.Sprintf("registry %q: no avaialble mirrors", e.Registry) +} diff --git a/registryclient/orasclient/options.go b/registryclient/orasclient/options.go index 7ac4f04e..2bd1ef6f 100644 --- a/registryclient/orasclient/options.go +++ b/registryclient/orasclient/options.go @@ -24,14 +24,15 @@ type ClientOption func(o *ClientConfig) error // ClientConfig contains configuration data for the registry client. type ClientConfig struct { - outputDir string - configs []string - credFn func(context.Context, string) (auth.Credential, error) - plainHTTP bool - insecure bool - cache content.Store - copyOpts oras.CopyOptions - attributes model.Matcher + outputDir string + configs []string + credFn func(context.Context, string) (auth.Credential, error) + plainHTTP bool + insecure bool + cache content.Store + copyOpts oras.CopyOptions + attributes model.Matcher + registryConfig registryclient.RegistryConfig } func (c *ClientConfig) apply(options []ClientOption) error { @@ -91,6 +92,7 @@ func NewClient(options ...ClientOption) (registryclient.Client, error) { client.destroy = destroy client.cache = config.cache client.attributes = config.attributes + client.registryConf = config.registryConfig // We are not allowing this to be configurable since // oras file stores turn artifacts into descriptors in @@ -118,6 +120,16 @@ func WithAuthConfigs(configs []string) ClientOption { } } +// WithRegistryConfig defines the configuration for specific registry +// endpoints. If specified, the configuration for a found registry +// will override WithSkipTLSVerify and WithPlainHTTP. +func WithRegistryConfig(registryConf registryclient.RegistryConfig) ClientOption { + return func(config *ClientConfig) error { + config.registryConfig = registryConf + return nil + } +} + // SkipTLSVerify disables TLS certificate checking. func SkipTLSVerify(insecure bool) ClientOption { return func(config *ClientConfig) error { diff --git a/registryclient/orasclient/oras.go b/registryclient/orasclient/oras.go index 7ab5c9a2..6c0190af 100644 --- a/registryclient/orasclient/oras.go +++ b/registryclient/orasclient/oras.go @@ -3,9 +3,11 @@ package orasclient import ( "bytes" "context" + "crypto/tls" "errors" "fmt" "io" + "net/http" "path/filepath" "sort" "sync" @@ -32,6 +34,7 @@ import ( type orasClient struct { plainHTTP bool authClient *auth.Client + registryConf registryclient.RegistryConfig copyOpts oras.CopyOptions artifactStore *file.Store cache content.Store @@ -132,11 +135,11 @@ func (c *orasClient) LoadCollection(ctx context.Context, reference string) (coll } // Pull performs a copy of OCI artifacts to a local location from a remote location. -func (c *orasClient) Pull(ctx context.Context, ref string, store content.Store) (ocispec.Descriptor, []ocispec.Descriptor, error) { +func (c *orasClient) Pull(ctx context.Context, reference string, store content.Store) (ocispec.Descriptor, []ocispec.Descriptor, error) { var allDescs []ocispec.Descriptor var from oras.Target - repo, err := c.setupRepo(ref) + repo, ref, err := c.setupRepo(ctx, reference) if err != nil { return ocispec.Descriptor{}, allDescs, fmt.Errorf("could not create registry target: %w", err) } @@ -231,27 +234,27 @@ func (c *orasClient) Pull(ctx context.Context, ref string, store content.Store) } // Push performs a copy of OCI artifacts to a remote location. -func (c *orasClient) Push(ctx context.Context, store content.Store, ref string) (ocispec.Descriptor, error) { - repo, err := c.setupRepo(ref) +func (c *orasClient) Push(ctx context.Context, store content.Store, reference string) (ocispec.Descriptor, error) { + repo, updatedRef, err := c.setupRepo(ctx, reference) if err != nil { return ocispec.Descriptor{}, fmt.Errorf("could not create registry target: %w", err) } - return oras.Copy(ctx, store, ref, repo, ref, c.copyOpts) + return oras.Copy(ctx, store, updatedRef, repo, updatedRef, c.copyOpts) } // GetManifest returns the manifest the reference resolves to. func (c *orasClient) GetManifest(ctx context.Context, reference string) (ocispec.Descriptor, io.ReadCloser, error) { - repo, err := c.setupRepo(reference) + repo, updatedRef, err := c.setupRepo(ctx, reference) if err != nil { return ocispec.Descriptor{}, nil, fmt.Errorf("could not create registry target: %w", err) } - return repo.FetchReference(ctx, reference) + return repo.FetchReference(ctx, updatedRef) } // GetContent retrieves the content for a specified descriptor at a specified reference. func (c *orasClient) GetContent(ctx context.Context, reference string, desc ocispec.Descriptor) ([]byte, error) { - repo, err := c.setupRepo(reference) + repo, _, err := c.setupRepo(ctx, reference) if err != nil { return nil, fmt.Errorf("could not create registry target: %w", err) } @@ -283,14 +286,90 @@ func (c *orasClient) checkFileStore() error { } // setupRepo configures the client to access the remote repository. -func (c *orasClient) setupRepo(ref string) (*remote.Repository, error) { - repo, err := remote.NewRepository(ref) +func (c *orasClient) setupRepo(ctx context.Context, reference string) (*remote.Repository, string, error) { + reg, err := registryclient.FindRegistry(c.registryConf, reference) if err != nil { - return nil, fmt.Errorf("could not create registry target: %w", err) + return nil, reference, err + } + + repo, err := remote.NewRepository(reference) + if err != nil { + return nil, reference, fmt.Errorf("could not create registry target: %w", err) + } + + // If the incoming reference does not match any registry prefixes, + // use the client configuration. If there is a match and the registry + // has mirrors configured, try each one before attempting to contact the + // input reference (the default). + switch { + case reg == nil: + repo.PlainHTTP = c.plainHTTP + repo.Client = c.authClient + return repo, reference, nil + case len(reg.Mirrors) != 0: + repo, ref, err := c.pickMirror(ctx, *reg, reference) + if err == nil { + return repo, ref, nil + } + + var merr *registryclient.ErrNoAvailableMirrors + if err != nil && !errors.As(err, &merr) { + return nil, reference, err + } + + fallthrough + default: + repo.PlainHTTP = reg.PlainHTTP + // FIXME(jpower432): This solution could easily + // lead to bugs because the authClient many field that are + // pointers or reference types. Come up with something different here. + copyAuthClient := *c.authClient + copyAuthClient.Client = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: reg.SkipTLS, + }, + }, + } + repo.Client = ©AuthClient + return repo, reference, nil } - repo.PlainHTTP = c.plainHTTP - repo.Client = c.authClient - return repo, nil +} + +// pickMirror is used if the reference is linked to a registry in the registry configuration that has mirrors. It returns +// a configured remote.Repository and rewritten reference per the mirror configuration. +func (c *orasClient) pickMirror(ctx context.Context, reg registryclient.Registry, ref string) (*remote.Repository, string, error) { + pullSources, err := reg.PullSourceFromReference(ref) + if err != nil { + return nil, ref, err + } + for _, ps := range pullSources { + mirror := ps.Endpoint + repo, err := remote.NewRepository(ps.Reference) + if err != nil { + return nil, ref, fmt.Errorf("could not create registry target: %w", err) + } + repo.PlainHTTP = mirror.PlainHTTP + // FIXME(jpower432): This solution could easily + // lead to bugs because the authClient many field that are + // pointers or reference types. Come up with something different here. + copyAuthClient := *c.authClient + copyAuthClient.Client = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: reg.SkipTLS, + }, + }, + } + repo.Client = ©AuthClient + + // FIXME(jpower432): Resolving work if this is used to publish to a mirror. + if _, err := repo.Resolve(ctx, ps.Reference); err == nil { + return repo, ps.Reference, nil + } + } + + return nil, ref, ®istryclient.ErrNoAvailableMirrors{Registry: reg.Location} } // loadFiles stores files in a file store and creates descriptors representing each file in the store. diff --git a/registryclient/orasclient/oras_test.go b/registryclient/orasclient/oras_test.go index 0c664218..033267a4 100644 --- a/registryclient/orasclient/oras_test.go +++ b/registryclient/orasclient/oras_test.go @@ -17,6 +17,7 @@ import ( "github.com/uor-framework/uor-client-go/attributes" "github.com/uor-framework/uor-client-go/attributes/matchers" "github.com/uor-framework/uor-client-go/ocimanifest" + "github.com/uor-framework/uor-client-go/registryclient" ) func TestAddFiles(t *testing.T) { @@ -111,7 +112,7 @@ func TestPushPull(t *testing.T) { ctx := context.TODO() - t.Run("Success/PushOneImage", func(t *testing.T) { + t.Run("Success/PushOneCollection", func(t *testing.T) { cache := memory.New() expDigest := "sha256:98f36e12e9dbacfbb10b9d1f32a46641eb42de588e54cfd7e8627d950ae8140a" c, err := NewClient(WithPlainHTTP(true), WithCache(cache)) @@ -133,7 +134,7 @@ func TestPushPull(t *testing.T) { require.NoError(t, c.Destroy()) }) - t.Run("Success/PullOneImage", func(t *testing.T) { + t.Run("Success/PullOneCollection", func(t *testing.T) { expDigest := "sha256:98f36e12e9dbacfbb10b9d1f32a46641eb42de588e54cfd7e8627d950ae8140a" c, err := NewClient(WithPlainHTTP(true)) require.NoError(t, err) @@ -144,7 +145,7 @@ func TestPushPull(t *testing.T) { require.NoError(t, c.Destroy()) }) - t.Run("Success/FilteredImage", func(t *testing.T) { + t.Run("Success/FilteredCollection", func(t *testing.T) { expDigest := "" matcher := matchers.PartialAttributeMatcher{"test": attributes.NewString("test", "fail")} c, err := NewClient(WithPlainHTTP(true), WithPullableAttributes(matcher)) @@ -168,7 +169,7 @@ func TestPushPull(t *testing.T) { require.NoError(t, c.Destroy()) }) - t.Run("Success/PushMultipleImages", func(t *testing.T) { + t.Run("Success/PushMultipleCollections", func(t *testing.T) { c, err := NewClient(WithPlainHTTP(true)) require.NoError(t, err) descs, err := c.AddFiles(ctx, "", testdata) @@ -189,7 +190,7 @@ func TestPushPull(t *testing.T) { require.NoError(t, c.Destroy()) }) - t.Run("Success/PullMultipleImages", func(t *testing.T) { + t.Run("Success/PullMultipleCollections", func(t *testing.T) { tmp := t.TempDir() destination := file.New(tmp) c, err := NewClient(WithPlainHTTP(true)) @@ -203,6 +204,83 @@ func TestPushPull(t *testing.T) { require.NoError(t, c.Destroy()) }) + t.Run("Success/PushWithRegistryConfig", func(t *testing.T) { + cache := memory.New() + expDigest := "sha256:98f36e12e9dbacfbb10b9d1f32a46641eb42de588e54cfd7e8627d950ae8140a" + config := registryclient.RegistryConfig{ + Registries: []registryclient.Registry{ + { + Prefix: u.Host, + Endpoint: registryclient.Endpoint{ + PlainHTTP: true, + Location: u.Host, + }, + }, + }, + } + c, err := NewClient(WithRegistryConfig(config), WithCache(cache)) + require.NoError(t, err) + descs, err := c.AddFiles(ctx, "", testdata) + require.NoError(t, err) + configDesc, err := c.AddContent(ctx, ocimanifest.UORConfigMediaType, []byte("{}"), nil) + require.NoError(t, err) + + mdesc, err := c.AddManifest(ctx, ref, configDesc, nil, descs...) + require.NoError(t, err) + source, err := c.Store() + require.NoError(t, err) + + desc, err := c.Push(context.TODO(), source, ref) + require.NoError(t, err) + require.Equal(t, mdesc.Digest.String(), desc.Digest.String()) + require.Equal(t, expDigest, desc.Digest.String()) + require.NoError(t, c.Destroy()) + }) + + t.Run("Success/PullWithRegistryConfig", func(t *testing.T) { + expDigest := "sha256:98f36e12e9dbacfbb10b9d1f32a46641eb42de588e54cfd7e8627d950ae8140a" + config := registryclient.RegistryConfig{ + Registries: []registryclient.Registry{ + { + Prefix: u.Host, + Endpoint: registryclient.Endpoint{ + PlainHTTP: true, + Location: u.Host, + }, + }, + }, + } + c, err := NewClient(WithRegistryConfig(config)) + require.NoError(t, err) + root, descs, err := c.Pull(context.TODO(), ref, memory.New()) + require.NoError(t, err) + require.Equal(t, expDigest, root.Digest.String()) + require.Len(t, descs, 4) + require.NoError(t, c.Destroy()) + }) + + t.Run("Success/PullWithRegistryConfigNoMatch", func(t *testing.T) { + expDigest := "sha256:98f36e12e9dbacfbb10b9d1f32a46641eb42de588e54cfd7e8627d950ae8140a" + config := registryclient.RegistryConfig{ + Registries: []registryclient.Registry{ + { + Prefix: "anotherhost", + Endpoint: registryclient.Endpoint{ + PlainHTTP: false, + Location: "another-host", + }, + }, + }, + } + c, err := NewClient(WithRegistryConfig(config), WithPlainHTTP(true)) + require.NoError(t, err) + root, descs, err := c.Pull(context.TODO(), ref, memory.New()) + require.NoError(t, err) + require.Equal(t, expDigest, root.Digest.String()) + require.Len(t, descs, 4) + require.NoError(t, c.Destroy()) + }) + t.Run("Failure/ImageDoesNotExist", func(t *testing.T) { c, err := NewClient(WithPlainHTTP(true)) require.NoError(t, err) diff --git a/registryclient/registries.go b/registryclient/registries.go new file mode 100644 index 00000000..04c6b966 --- /dev/null +++ b/registryclient/registries.go @@ -0,0 +1,115 @@ +package registryclient + +import ( + "fmt" + "regexp" + "strings" + + "oras.land/oras-go/v2/errdef" +) + +// This configuration is slightly modified and paired down version of the registries.conf. +// Source https://github.com/containers/image/blob/main/pkg/sysregistriesv2/system_registries_v2.go. +// More information on why this does not just use the `containers/system_registries_v2` library. +// While this library has a lot of overlapping functionality, it has more functionality than we +// need, and it makes sense to use the `containers` registry client which we are not. Search registries +// will eventually be a used in this library, but will be resolved and related to collection attributes +// and not short names. + +// Endpoint describes a remote location of a registry. +type Endpoint struct { + // The endpoint's remote location. + Location string `json:"location"` + // If true, certs verification will be skipped. + SkipTLS bool `json:"skipTLS"` + // If true, the client will use HTTP to + // connect to the registry. + PlainHTTP bool `json:"plainHTTP"` +} + +// RewriteReference returns a reference for the endpoint given the original +// reference and registry prefix. +func (e Endpoint) RewriteReference(reference string) (string, error) { + if e.Location == "" { + return reference, nil + } + + parts := strings.SplitN(reference, "/", 2) + if len(parts) == 1 { + return " ", fmt.Errorf("%w: missing repository", errdef.ErrInvalidReference) + } + path := parts[1] + return fmt.Sprintf("%s/%s", e.Location, path), nil +} + +// PullSource is a reference that is associated with a +// specific endpoint. This is used to generate references +// for registry mirrors and correlate them the mirror endpoint +type PullSource struct { + Reference string + Endpoint +} + +// Registry represents a registry. +type Registry struct { + // Prefix is used for endpoint matching. + Prefix string `json:"prefix"` + // A registry is an Endpoint too + Endpoint `json:"endpoint"` + // The registry mirrors + Mirrors []Endpoint `json:"mirrors,omitempty"` +} + +// PullSourceFromReference returns all pull source for the registry mirrors from +// a given reference. +func (r *Registry) PullSourceFromReference(ref string) ([]PullSource, error) { + var sources []PullSource + for _, mirror := range r.Mirrors { + rewritten, err := mirror.RewriteReference(ref) + if err != nil { + return nil, err + } + sources = append(sources, PullSource{Endpoint: mirror, Reference: rewritten}) + } + return sources, nil +} + +// RegistryConfig is a configuration to configure multiple +// registry endpoints. +type RegistryConfig struct { + Registries []Registry `json:"registries"` + //AttributeSearchDomain []string +} + +// FindRegistry returns the registry from the registry config that +// matches the reference. +func FindRegistry(registryConfig RegistryConfig, ref string) (*Registry, error) { + reg := Registry{} + prefixLen := 0 + + for _, r := range registryConfig.Registries { + prefixExp, err := regexp.Compile(validPrefix(r.Prefix)) + if err != nil { + return nil, err + } + if prefixExp.MatchString(ref) { + if len(r.Prefix) > prefixLen { + reg = r + prefixLen = len(r.Prefix) + } + } + } + if prefixLen != 0 { + return ®, nil + } + return nil, nil +} + +// validPrefix will check the registry prefix value +// and return a valid regex. +func validPrefix(regPrefix string) string { + if strings.HasPrefix(regPrefix, "*") { + return strings.Replace(regPrefix, "*", ".*", -1) + } + return regPrefix +} diff --git a/registryclient/registries_test.go b/registryclient/registries_test.go new file mode 100644 index 00000000..67c90013 --- /dev/null +++ b/registryclient/registries_test.go @@ -0,0 +1,227 @@ +package registryclient + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFindRegistry(t *testing.T) { + type spec struct { + name string + cfg RegistryConfig + inRef string + expError string + expReg Registry + } + cases := []spec{ + { + name: "Success/OneMatch", + cfg: RegistryConfig{ + Registries: []Registry{ + { + Prefix: "*.example.com", + Endpoint: Endpoint{ + SkipTLS: true, + }, + }, + { + Prefix: "*.not.com", + Endpoint: Endpoint{ + SkipTLS: false, + }, + }, + }, + }, + inRef: "reg.example.com", + expReg: Registry{ + Prefix: "*.example.com", + Endpoint: Endpoint{ + SkipTLS: true, + }, + }, + }, + { + name: "Success/MultipleMatches", + cfg: RegistryConfig{ + Registries: []Registry{ + { + Prefix: "*.example.com", + Endpoint: Endpoint{ + SkipTLS: true, + }, + }, + { + Prefix: "*", + Endpoint: Endpoint{ + SkipTLS: false, + }, + }, + }, + }, + inRef: "reg.example.com", + expReg: Registry{ + Prefix: "*.example.com", + Endpoint: Endpoint{ + SkipTLS: true, + }, + }, + }, + { + name: "Success/SubDomainWildcard", + cfg: RegistryConfig{ + Registries: []Registry{ + { + Prefix: "reg.example.*", + Endpoint: Endpoint{ + SkipTLS: true, + }, + }, + { + Prefix: "*", + Endpoint: Endpoint{ + SkipTLS: false, + }, + }, + }, + }, + inRef: "reg.example.com", + expReg: Registry{ + Prefix: "reg.example.*", + Endpoint: Endpoint{ + SkipTLS: true, + }, + }, + }, + { + name: "Success/NotMatch", + cfg: RegistryConfig{ + Registries: []Registry{ + { + Prefix: "*.not.com", + Endpoint: Endpoint{ + SkipTLS: true, + }, + }, + }, + }, + inRef: "reg.example.com", + expReg: Registry{}, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + reg, err := FindRegistry(c.cfg, c.inRef) + if c.expError != "" { + require.EqualError(t, err, c.expError) + } else { + require.NoError(t, err) + if c.expReg.Prefix == "" { + require.Equal(t, (*Registry)(nil), reg) + } else { + require.Equal(t, c.expReg, *reg) + } + } + }) + } +} + +func TestEndpoint_RewriteReference(t *testing.T) { + type spec struct { + name string + expError string + endpoint Endpoint + inRef string + expRef string + } + + cases := []spec{ + { + name: "Success/MatchingPrefix", + endpoint: Endpoint{ + Location: "alt.example.com", + }, + inRef: "reg.example.com/test:latest", + expRef: "alt.example.com/test:latest", + }, + { + name: "Success/EmptyLocation", + endpoint: Endpoint{ + Location: "", + }, + inRef: "reg.example.com/test:latest", + expRef: "reg.example.com/test:latest", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + ref, err := c.endpoint.RewriteReference(c.inRef) + if c.expError != "" { + require.EqualError(t, err, c.expError) + } else { + require.NoError(t, err) + require.Equal(t, c.expRef, ref) + } + }) + } +} + +func TestRegistry_PullSourceFromReference(t *testing.T) { + type spec struct { + name string + expError string + registry Registry + inRef string + expSources []PullSource + } + cases := []spec{ + { + name: "Success/NoMirrors", + registry: Registry{ + Prefix: "reg.example.com", + Endpoint: Endpoint{ + SkipTLS: false, + }, + }, + inRef: "reg.example.com/test:latest", + }, + { + name: "Success/OneMirror", + registry: Registry{ + Prefix: "reg.example.com", + Endpoint: Endpoint{ + SkipTLS: false, + }, + Mirrors: []Endpoint{ + { + SkipTLS: true, + Location: "alt.registry.com", + }, + }, + }, + inRef: "reg.example.com/test:latest", + expSources: []PullSource{ + { + Reference: "alt.registry.com/test:latest", + Endpoint: Endpoint{ + SkipTLS: true, + PlainHTTP: false, + Location: "alt.registry.com", + }, + }, + }, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + sources, err := c.registry.PullSourceFromReference(c.inRef) + if c.expError != "" { + require.EqualError(t, err, c.expError) + } else { + require.NoError(t, err) + require.Equal(t, c.expSources, sources) + } + }) + } +} diff --git a/services/collectionmanager/service.go b/services/collectionmanager/service.go index 8ceae6eb..dcdd3e76 100644 --- a/services/collectionmanager/service.go +++ b/services/collectionmanager/service.go @@ -14,6 +14,7 @@ import ( "github.com/uor-framework/uor-client-go/config" "github.com/uor-framework/uor-client-go/content" "github.com/uor-framework/uor-client-go/manager" + "github.com/uor-framework/uor-client-go/registryclient" "github.com/uor-framework/uor-client-go/registryclient/orasclient" "github.com/uor-framework/uor-client-go/util/workspace" ) @@ -29,9 +30,10 @@ type service struct { // ServiceOptions configure the collection router service with default remote // and collection caching options. type ServiceOptions struct { - Insecure bool - PlainHTTP bool - PullCache content.Store + Insecure bool + PlainHTTP bool + PullCache content.Store + RegistryConfig registryclient.RegistryConfig } // FromManager returns a CollectionManager API server from a Manager type. @@ -49,7 +51,9 @@ func (s *service) PublishContent(ctx context.Context, message *managerapi.Publis orasclient.WithCache(s.options.PullCache), orasclient.WithPlainHTTP(s.options.PlainHTTP), orasclient.WithCredentialFunc(authConf.Credential), - orasclient.SkipTLSVerify(s.options.Insecure)) + orasclient.SkipTLSVerify(s.options.Insecure), + orasclient.WithRegistryConfig(s.options.RegistryConfig), + ) if err != nil { return &managerapi.Publish_Response{}, status.Error(codes.Internal, err.Error()) } @@ -120,6 +124,7 @@ func (s *service) RetrieveContent(ctx context.Context, message *managerapi.Retri orasclient.WithPlainHTTP(s.options.PlainHTTP), orasclient.SkipTLSVerify(s.options.Insecure), orasclient.WithPullableAttributes(matcher), + orasclient.WithRegistryConfig(s.options.RegistryConfig), ) if err != nil { return &managerapi.Retrieve_Response{}, status.Error(codes.Internal, err.Error())