From 33869d41ef043b9d510f4ac28c492d921f501ac5 Mon Sep 17 00:00:00 2001 From: Joseph Kavanagh Date: Wed, 1 Jul 2026 14:02:01 +0100 Subject: [PATCH 1/3] feat(docker): add Amazon ECR Public Gallery registry type The gallery is anonymous: a global bearer token is fetched from public.ecr.aws/token/ and cached. --- internal/test/secrets.go | 3 + .../latest_version/filter/docker/defaults.go | 13 +- .../filter/docker/defaults_test.go | 8 +- .../latest_version/filter/docker/help_test.go | 7 +- .../filter/docker/registry_common_test.go | 5 + .../filter/docker/registry_ecr.go | 457 +++++ .../docker/registry_ecr_integration_test.go | 297 ++++ .../filter/docker/registry_ecr_test.go | 1474 +++++++++++++++++ .../filter/docker/registry_ghcr_test.go | 25 +- .../filter/docker/registry_hub_test.go | 25 +- .../filter/docker/registry_quay_test.go | 25 +- .../latest_version/filter/docker/test/main.go | 2 + .../filter/docker/test/main_test.go | 5 + service/latest_version/filter/docker/types.go | 15 + .../service-edit/latest-version-require.tsx | 55 +- .../service/form/builder--latest-version.ts | 31 +- .../service/types/latest-version.ts | 10 + .../types/config/service/latest-version.ts | 43 +- 18 files changed, 2434 insertions(+), 66 deletions(-) create mode 100644 service/latest_version/filter/docker/registry_ecr.go create mode 100644 service/latest_version/filter/docker/registry_ecr_integration_test.go create mode 100644 service/latest_version/filter/docker/registry_ecr_test.go diff --git a/internal/test/secrets.go b/internal/test/secrets.go index 931a1621..22e00ba0 100644 --- a/internal/test/secrets.go +++ b/internal/test/secrets.go @@ -46,6 +46,9 @@ func ShoutrrrGotifyToken() (token string) { var ArgusGitHubRepo = "release-argus/Argus" +// Amazon ECR Public Gallery Repo used for tests (AWS-owned, long-lived). +var ArgusDockerECRRepo = "docker/library/busybox" + // GHCR Repo for Argus. var ArgusDockerGHCRRepo = "release-argus/argus" diff --git a/service/latest_version/filter/docker/defaults.go b/service/latest_version/filter/docker/defaults.go index a4ba9ba2..5a2e435f 100644 --- a/service/latest_version/filter/docker/defaults.go +++ b/service/latest_version/filter/docker/defaults.go @@ -34,6 +34,7 @@ type Defaults struct { // RegistryDefaultsSet holds per-registry default configuration. type RegistryDefaultsSet struct { + ECR *ECRRegistryDefaults `json:"-" yaml:"-"` // Amazon ECR Public Gallery (anonymous: no serialisable config). GHCR *GHCRRegistryDefaults `json:"ghcr,omitzero" yaml:"ghcr,omitzero"` // GitHub Container Registry. Hub *HubRegistryDefaults `json:"hub,omitzero" yaml:"hub,omitzero"` // Docker Hub. Quay *QuayRegistryDefaults `json:"quay,omitzero" yaml:"quay,omitzero"` // Quay. @@ -88,7 +89,8 @@ func (r *RegistryDefaultsSet) IsZero() bool { return true } - return r.GHCR.IsZero() && + return r.ECR.IsZero() && + r.GHCR.IsZero() && r.Hub.IsZero() && r.Quay.IsZero() } @@ -123,6 +125,7 @@ func (d *Defaults) SetDefaults(defaults *Defaults) { d.ContainerDetailDefaults.Defaults = &defaults.ContainerDetailDefaults defaults.initRegistries() + setRegistryDefaults(d.Registry.ECR, defaults.Registry.ECR) setRegistryDefaults(d.Registry.GHCR, defaults.Registry.GHCR) setRegistryDefaults(d.Registry.Hub, defaults.Registry.Hub) setRegistryDefaults(d.Registry.Quay, defaults.Registry.Quay) @@ -170,6 +173,9 @@ func (d *Defaults) CheckValues() error { // initRegistries ensures each registry-specific defaults slot is non-nil. func (d *Defaults) initRegistries() { + if d.Registry.ECR == nil { + d.Registry.ECR = RegistryDefaultsMap["ecr"]().(*ECRRegistryDefaults) + } if d.Registry.GHCR == nil { d.Registry.GHCR = RegistryDefaultsMap["ghcr"]().(*GHCRRegistryDefaults) } @@ -184,6 +190,11 @@ func (d *Defaults) initRegistries() { // getRegistryDefaults returns the defaults for dType from defaults, or nil if unset. func getRegistryDefaults(dType string, defaults *Defaults) RegistryDefaults { switch dType { + case "ecr": + if defaults.Registry.ECR == nil { + return nil + } + return defaults.Registry.ECR case "ghcr": if defaults.Registry.GHCR == nil { return nil diff --git a/service/latest_version/filter/docker/defaults_test.go b/service/latest_version/filter/docker/defaults_test.go index 464ce63a..bc102964 100644 --- a/service/latest_version/filter/docker/defaults_test.go +++ b/service/latest_version/filter/docker/defaults_test.go @@ -264,6 +264,7 @@ func TestDecodeDefaults(t *testing.T) { // THEN: The Defaults were passed over correctly. fieldTests := []test.FieldAssertion{ {Name: "Defaults", Got: got.Defaults, Want: &defaults, Mode: test.CompareSamePointer}, + {Name: "ECR.Auth.Defaults", Got: got.Registry.ECR.GetAuth().Defaults(), Want: defaults.Registry.ECR.GetAuth(), Mode: test.CompareSamePointer}, {Name: "GHCR.Auth.Defaults", Got: got.Registry.GHCR.GetAuth().Defaults(), Want: defaults.Registry.GHCR.GetAuth(), Mode: test.CompareSamePointer}, {Name: "Hub.Auth.Defaults", Got: got.Registry.Hub.GetAuth().Defaults(), Want: defaults.Registry.Hub.GetAuth(), Mode: test.CompareSamePointer}, {Name: "Quay.Auth.Defaults", Got: got.Registry.Quay.GetAuth().Defaults(), Want: defaults.Registry.Quay.GetAuth(), Mode: test.CompareSamePointer}, @@ -851,6 +852,7 @@ func TestDefaults_Defaults(t *testing.T) { return } fieldTests := []test.FieldAssertion{ + {Name: "ECR.Auth.Defaults", Got: defaults.Registry.ECR.GetAuth().Defaults(), Want: tc.defaults.Registry.ECR.GetAuth(), Mode: test.CompareSamePointer}, {Name: "GHCR.Auth.Defaults", Got: defaults.Registry.GHCR.GetAuth().Defaults(), Want: tc.defaults.Registry.GHCR.GetAuth(), Mode: test.CompareSamePointer}, {Name: "Hub.Auth.Defaults", Got: defaults.Registry.Hub.GetAuth().Defaults(), Want: tc.defaults.Registry.Hub.GetAuth(), Mode: test.CompareSamePointer}, {Name: "Quay.Auth.Defaults", Got: defaults.Registry.Quay.GetAuth().Defaults(), Want: tc.defaults.Registry.Quay.GetAuth(), Mode: test.CompareSamePointer}, @@ -999,7 +1001,8 @@ func TestDefaults_InitRegistries(t *testing.T) { var d Defaults // THEN: all registries are nil - if d.Registry.GHCR != nil || + if d.Registry.ECR != nil || + d.Registry.GHCR != nil || d.Registry.Hub != nil || d.Registry.Quay != nil { t.Fatalf( @@ -1012,7 +1015,8 @@ func TestDefaults_InitRegistries(t *testing.T) { d.initRegistries() // THEN: all registries are initialised - if d.Registry.GHCR == nil || + if d.Registry.ECR == nil || + d.Registry.GHCR == nil || d.Registry.Hub == nil || d.Registry.Quay == nil { t.Fatalf( diff --git a/service/latest_version/filter/docker/help_test.go b/service/latest_version/filter/docker/help_test.go index b31db6ef..26b85646 100644 --- a/service/latest_version/filter/docker/help_test.go +++ b/service/latest_version/filter/docker/help_test.go @@ -60,8 +60,9 @@ func plainDefaults(t *testing.T) (*Defaults, *Defaults) { func getTokenData(t *testing.T, auth RegistryAuth) (token, queryToken string, validUntil time.Time) { t.Helper() switch a := auth.(type) { - case *QuayAuth: - token = a.GetToken() + case *ECRAuth: + queryToken = a.queryToken + validUntil = a.validUntil case *GHCRAuth: token = a.GetToken() queryToken = a.queryToken @@ -70,6 +71,8 @@ func getTokenData(t *testing.T, auth RegistryAuth) (token, queryToken string, va token = a.GetToken() queryToken = a.queryToken validUntil = a.validUntil + case *QuayAuth: + token = a.GetToken() } return } diff --git a/service/latest_version/filter/docker/registry_common_test.go b/service/latest_version/filter/docker/registry_common_test.go index 731050bd..d8f3a034 100644 --- a/service/latest_version/filter/docker/registry_common_test.go +++ b/service/latest_version/filter/docker/registry_common_test.go @@ -775,6 +775,11 @@ func TestCommonRegistry_Defaults(t *testing.T) { // GIVEN: registryDefaults. defaults := Defaults{ Registry: RegistryDefaultsSet{ + ECR: &ECRRegistryDefaults{ + CommonRegistryDefaults: CommonRegistryDefaults{ + Auth: &ECRAuthDefaults{}, + }, + }, GHCR: &GHCRRegistryDefaults{ CommonRegistryDefaults: CommonRegistryDefaults{ Auth: &HubAuthDefaults{ diff --git a/service/latest_version/filter/docker/registry_ecr.go b/service/latest_version/filter/docker/registry_ecr.go new file mode 100644 index 00000000..ef3b569b --- /dev/null +++ b/service/latest_version/filter/docker/registry_ecr.go @@ -0,0 +1,457 @@ +// Copyright [2026] [Argus] +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package docker + +import ( + "fmt" + "io" + "net/http" + "sync" + "time" + + "golang.org/x/sync/singleflight" + + "github.com/release-argus/Argus/config/decode" + "github.com/release-argus/Argus/internal/httpx" +) + +// ############# +// # CONSTANTS # +// ############# + +var ( + // ecrTokenAddress is the Amazon ECR Public Gallery anonymous token endpoint. + ecrTokenAddress = "https://public.ecr.aws/token/" + // ecrQueryURL is the Amazon ECR Public Gallery query endpoint for image:tag queries. + ecrQueryURL = "https://public.ecr.aws/v2/%s/manifests/%s" +) + +// ecrTokenResponse is the response body for an Amazon ECR Public Gallery access token request. +type ecrTokenResponse struct { + Token string `json:"token"` +} + +// #################### +// # REGISTRY | TYPES # +// #################### + +// ECRRegistryDefaults holds defaults for queries on Amazon ECR Public Gallery registries. +type ECRRegistryDefaults struct { + CommonRegistryDefaults `json:",inline" yaml:",inline"` +} + +// ECRRegistry holds data for queries on an Amazon ECR Public Gallery registry. +type ECRRegistry struct { + CommonRegistry `json:",inline" yaml:",inline"` +} + +// ####################### +// # REGISTRY | DECODING # +// ####################### + +// UnmarshalJSON implements the json.Unmarshaler interface. +// Use [DecodeDefaults] for a complete ECRRegistryDefaults. +func (r *ECRRegistryDefaults) UnmarshalJSON(data []byte) error { + return r.unmarshal("json", data) +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface. +// Use [DecodeDefaults] for a complete ECRRegistryDefaults. +func (r *ECRRegistryDefaults) UnmarshalYAML(data []byte) error { + return r.unmarshal("yaml", data) +} + +// unmarshal implements the format.Unmarshaler interface. +func (r *ECRRegistryDefaults) unmarshal(format string, data []byte) error { + // Alias to avoid recursion. + type Alias ECRRegistryDefaults + aux := (*Alias)(r) + + if r.Auth == nil { + r.Auth = &ECRAuthDefaults{} + } + // CommonRegistryDefaults. + if len(data) != 0 { + if err := decode.Unmarshal(format, data, aux); err != nil { + return err //nolint:wrapcheck + } + } + + return nil +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +// Use [Decode] for a complete ECRRegistry. +func (r *ECRRegistry) UnmarshalJSON(data []byte) error { + return r.unmarshal("json", data) +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface. +// Use [Decode] for a complete ECRRegistry. +func (r *ECRRegistry) UnmarshalYAML(data []byte) error { + return r.unmarshal("yaml", data) +} + +// unmarshal implements the format.Unmarshaler interface. +func (r *ECRRegistry) unmarshal(format string, data []byte) error { + // Alias to avoid recursion. + type Alias ECRRegistry + aux := (*Alias)(r) + + if r.Auth == nil { + r.Auth = &ECRAuth{} + } + // CommonRegistry. + if len(data) != 0 { + if err := decode.Unmarshal(format, data, aux); err != nil { + return err //nolint:wrapcheck + } + } + + return nil +} + +// DecodeSelf decodes the format-encoded data into the receiver. +func (r *ECRRegistry) DecodeSelf(format string, data []byte) error { + if err := decode.Unmarshal(format, data, r); err != nil { + return err //nolint:wrapcheck + } + return nil +} + +// ApplyOverrides applies format-encoded overrides to the receiver. +func (r *ECRRegistry) ApplyOverrides(format string, data []byte) error { + return r.DecodeSelf(format, data) +} + +// #################### +// # REGISTRY | STATE # +// #################### + +// IsZero implements the yaml.IsZeroer interface. +func (r *ECRRegistryDefaults) IsZero() bool { + if r == nil { + return true + } + + return r.Auth == nil || r.Auth.IsZero() +} + +// IsZero implements the yaml.IsZeroer interface. +func (r *ECRRegistry) IsZero() bool { + if r == nil { + return true + } + return r.CommonRegistry.IsZero() +} + +// Copy returns a deep copy of the receiver. +func (r *ECRRegistry) Copy() Registry { + if r == nil { + return nil + } + + return &ECRRegistry{ + CommonRegistry: *r.CommonRegistry.Clone(), //nolint:staticcheck + } +} + +// ######################## +// # REGISTRY | STRINGIFY # +// ######################## + +// String returns a string representation of the receiver. +func (r *ECRRegistryDefaults) String(prefix string) string { + if r == nil { + return "" + } + return decode.ToYAMLString(r, prefix) +} + +// String returns a string representation of the receiver. +func (r *ECRRegistry) String(prefix string) string { + if r == nil { + return "" + } + return decode.ToYAMLString(r, prefix) +} + +// ####################### +// # REGISTRY | METADATA # +// ####################### + +// GetType returns the registry type identifier. +func (r *ECRRegistryDefaults) GetType() string { + return "ecr" +} + +// GetType returns the registry type identifier. +func (r *ECRRegistry) GetType() string { + return "ecr" +} + +// ######################### +// # REGISTRY | OPERATIONS # +// ######################### + +// newRequest returns a HTTP GET request to query whether the given tag exists for the receiver's image. +func (r *ECRRegistry) newRequest(tag string) (*http.Request, error) { + url := fmt.Sprintf( + ecrQueryURL, r.GetImage(), tag, + ) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err //nolint:wrapcheck + } + + req.Header.Set("Accept", "application/vnd.oci.image.index.v1+json") + return req, nil +} + +// Check queries the Amazon ECR Public Gallery registry for the image:tag. +func (r *ECRRegistry) Check(version string) error { + return check(version, r) +} + +// ################ +// # AUTH | TYPES # +// ################ + +// ECRAuthDefaults holds authentication defaults for the Amazon ECR Public Gallery. +// +// The Public Gallery is anonymous, so there are no configurable credentials; this +// type only caches the anonymous bearer token used for registry queries. +type ECRAuthDefaults struct { + mu sync.RWMutex // Protects query token cache state. + queryToken string // Cached ECR bearer token used for registry queries. + validUntil time.Time // Expiry time for the cached bearer token. + + // defaults form a fallback chain: + // + // instance -> provider defaults -> global defaults + // + // Values are resolved from most specific to least specific. + defaults *ECRAuthDefaults +} + +// ECRAuth holds authentication state for the Amazon ECR Public Gallery. +type ECRAuth struct { + ECRAuthDefaults `json:",inline" yaml:",inline"` + + sf singleflight.Group // Deduplicate refreshes. +} + +// ################### +// # AUTH | DECODING # +// ################### + +// UnmarshalJSON implements the json.Unmarshaler interface. +// Use [DecodeDefaults] for a complete ECRAuthDefaults. +func (d *ECRAuthDefaults) UnmarshalJSON(data []byte) error { + return d.unmarshal("json", data) +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface. +// Use [DecodeDefaults] for a complete ECRAuthDefaults. +func (d *ECRAuthDefaults) UnmarshalYAML(data []byte) error { + return d.unmarshal("yaml", data) +} + +// unmarshal implements the format.Unmarshaler interface. +// +// Amazon ECR Public Gallery has no configurable credential, so there is nothing to +// decode; this only validates that the auth block is a mapping (mirroring the +// other registries) and discards its contents. +func (d *ECRAuthDefaults) unmarshal(format string, data []byte) error { + var discard map[string]any + if err := decode.Unmarshal(format, data, &discard); err != nil { + return err //nolint:wrapcheck + } + + return nil +} + +// ################ +// # AUTH | STATE # +// ################ + +// IsZero implements the yaml.IsZeroer interface. +// +// ECR auth carries no configuration, so it is always zero. +func (d *ECRAuthDefaults) IsZero() bool { + return true +} + +// Clone returns a deep copy of the receiver. +func (a *ECRAuth) Clone() *ECRAuth { + if a == nil { + return nil + } + + return &ECRAuth{ + ECRAuthDefaults: ECRAuthDefaults{ + queryToken: a.queryToken, + validUntil: a.validUntil, + defaults: a.defaults, + }, + } +} + +// Copy returns a deep copy of the receiver as a [RegistryAuth]. +func (a *ECRAuth) Copy() RegistryAuth { + return a.Clone() +} + +// #################### +// # AUTH | STRINGIFY # +// #################### + +// String returns a YAML string representation of the receiver. +func (d *ECRAuthDefaults) String(prefix string) string { + return decode.ToYAMLString(d, prefix) +} + +// ################### +// # AUTH | DEFAULTS # +// ################### + +// Defaults returns the next link in the auth defaults chain. +func (d *ECRAuthDefaults) Defaults() RegistryAuthDefaults { + if d.defaults == nil { + return nil + } + return d.defaults +} + +// SetDefaults assigns defaults to the receiver. +func (d *ECRAuthDefaults) SetDefaults(defaults RegistryAuthDefaults) { + if ecrDefaults, ok := defaults.(*ECRAuthDefaults); ok { + d.defaults = ecrDefaults + } +} + +// ##################### +// # AUTH | VALIDATION # +// ##################### + +// CheckValues validates the fields of the receiver. +func (a *ECRAuth) CheckValues() error { + return nil +} + +// ###################### +// # AUTH | CREDENTIALS # +// ###################### + +// GetTokenSelf returns the configured credential token (Amazon ECR Public Gallery has none). +func (d *ECRAuthDefaults) GetTokenSelf() string { + return "" +} + +// ####################### +// # AUTH | QUERY TOKENS # +// ####################### + +// GetQueryTokenSelf returns the cached query token and its expiry time stored on the receiver. +func (d *ECRAuthDefaults) GetQueryTokenSelf() (string, time.Time) { + d.mu.RLock() + queryToken, validUntil := d.queryToken, d.validUntil + d.mu.RUnlock() + + if isUsable(queryToken, validUntil) { + return queryToken, validUntil + } + return "", time.Time{} +} + +// GetQueryToken returns a cached anonymous query token if available, otherwise refreshes it. +func (a *ECRAuth) GetQueryToken(detail ContainerDetail) (string, error) { + for auth := &a.ECRAuthDefaults; auth != nil; auth = auth.defaults { + // Only use the cached token if still usable. + if queryToken, _ := auth.GetQueryTokenSelf(); queryToken != "" { + return queryToken, nil + } + } + + // Deduplicate refreshes. + v, err, _ := a.sf.Do("refresh-token", func() (any, error) { + return a.refreshQueryToken(detail) + }) + if err != nil { + return "", err //nolint:wrapcheck + } + + return v.(string), nil +} + +// SetQueryToken stores the cached query token and expiry time on the receiver. +func (d *ECRAuthDefaults) SetQueryToken(qT string, until time.Time) { + d.mu.Lock() + defer d.mu.Unlock() + + d.queryToken = qT + d.validUntil = until +} + +// refreshQueryToken retrieves a new anonymous query token from the Amazon ECR Public Gallery. +func (a *ECRAuth) refreshQueryToken(_ ContainerDetail) (string, error) { + // Double-check whether the token is usable. + a.mu.RLock() + if isUsable(a.queryToken, a.validUntil) { + token := a.queryToken + a.mu.RUnlock() + return token, nil + } + a.mu.RUnlock() + + // Do the request. + resp, err := httpx.Client.Get(ecrTokenAddress) + if err != nil { + return "", fmt.Errorf("ecr token refresh fail: %w", err) + } + defer resp.Body.Close() + + // Read the token. + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("ecr token request failed: %s", body) + } + var tokenJSON ecrTokenResponse + if err := decode.Unmarshal("json", body, &tokenJSON); err != nil { + return "", fmt.Errorf("failed to parse ecr token response: %w", err) + } + validUntil := time.Now().UTC().Add(12 * time.Hour).Add(-10 * time.Minute) + + a.SetQueryToken(tokenJSON.Token, validUntil) + return tokenJSON.Token, nil +} + +// ###################### +// # AUTH | INHERITANCE # +// ###################### + +// Inherit copies token data from another [ECRAuth]. +// +// - Amazon ECR Public Gallery tokens are anonymous and global; image/tag does not matter here. +func (a *ECRAuth) Inherit(from RegistryAuth, _, _ ContainerDetail) { + o, ok := from.(*ECRAuth) + if !ok { + return + } + + // Copy token data. + a.queryToken = o.queryToken + a.validUntil = o.validUntil +} diff --git a/service/latest_version/filter/docker/registry_ecr_integration_test.go b/service/latest_version/filter/docker/registry_ecr_integration_test.go new file mode 100644 index 00000000..a0eb1134 --- /dev/null +++ b/service/latest_version/filter/docker/registry_ecr_integration_test.go @@ -0,0 +1,297 @@ +// Copyright [2026] [Argus] +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build integration + +package docker + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/release-argus/Argus/internal/test" + "github.com/release-argus/Argus/util" + "github.com/release-argus/Argus/util/errfmt" +) + +// ######################### +// # REGISTRY | OPERATIONS # +// ######################### + +func TestECRRegistry_Check(t *testing.T) { + // GIVEN: an ECRRegistry, and version to check for. + tests := []struct { + name string + registry ECRRegistry + version string + errRegex string + }{ + { + name: "anonymous, known image+tag", + registry: ECRRegistry{ + CommonRegistry: CommonRegistry{ + ContainerDetail: ContainerDetail{ + Image: test.ArgusDockerECRRepo, + Tag: "{{ version }}", + }, + Auth: &ECRAuth{}, + }, + }, + version: "latest", + errRegex: `^$`, + }, + { + name: "known image, unknown tag", + registry: ECRRegistry{ + CommonRegistry: CommonRegistry{ + ContainerDetail: ContainerDetail{ + Image: test.ArgusDockerECRRepo, + Tag: "{{ version }}-unknown", + }, + Auth: &ECRAuth{}, + }, + }, + version: "latest", + errRegex: `^` + test.ArgusDockerECRRepo + `:latest-unknown - tag not found$`, + }, + { + name: "unknown image", + registry: ECRRegistry{ + CommonRegistry: CommonRegistry{ + ContainerDetail: ContainerDetail{ + Image: test.ArgusDockerECRRepo + "-unknown", + Tag: "{{ version }}", + }, + Auth: &ECRAuth{}, + }, + }, + version: "latest", + errRegex: `^` + test.ArgusDockerECRRepo + `-unknown:latest - tag not found$`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // WHEN: Check is called with this version. + err := tc.registry.Check(tc.version) + + // THEN: any error case is expected. + e := errfmt.FormatError(err) + if !util.RegexCheck(tc.errRegex, e) { + t.Errorf( + "%s\nECRRegistry.Check() error mismatch\ngot: %q\nwant: %s", + tc.name, e, tc.errRegex, + ) + } + }) + } +} + +func TestECRRegistry_Check__errors(t *testing.T) { + // GIVEN: an ECRRegistry, and version to check for. + tests := []struct { + name string + ecrTokenAddress string + ecrQueryURL string + registry ECRRegistry + version string + errRegex string + }{ + { + name: "GetQueryToken error, invalid token URL", + ecrTokenAddress: "https:// example.com", + registry: ECRRegistry{ + CommonRegistry: CommonRegistry{ + ContainerDetail: ContainerDetail{ + Image: test.ArgusDockerECRRepo, + Tag: "{{ version }}", + }, + Auth: &ECRAuth{}, + }, + }, + version: "latest", + errRegex: test.TrimYAML(` + ^ecr token refresh fail: + parse .* + .*invalid control character in URL$`, + ), + }, + { + name: "newRequest error, invalid query URL", + ecrQueryURL: "https:// example.com", + registry: ECRRegistry{ + CommonRegistry: CommonRegistry{ + ContainerDetail: ContainerDetail{ + Image: test.ArgusDockerECRRepo, + Tag: "{{ version }}", + }, + Auth: &ECRAuth{ + ECRAuthDefaults: ECRAuthDefaults{ + queryToken: "test", + }, + }, + }, + }, + version: "latest", + errRegex: test.TrimYAML(` + ^parse .* + .*invalid control character in URL$`, + ), + }, + { + name: "http.client.Do error, invalid URL TLD", + ecrQueryURL: "https://example.invalid/%s/%s", + registry: ECRRegistry{ + CommonRegistry: CommonRegistry{ + ContainerDetail: ContainerDetail{ + Image: test.ArgusDockerECRRepo, + Tag: "{{ version }}", + }, + Auth: &ECRAuth{ + ECRAuthDefaults: ECRAuthDefaults{ + queryToken: "test", + }, + }, + }, + }, + version: "latest", + errRegex: test.TrimYAML(` + ^` + test.ArgusDockerECRRepo + `:latest + Get "https://.* + dial tcp: + lookup .* no such host$`, + ), + }, + } + + _ecrTokenAddress := ecrTokenAddress + _ecrQueryURL := ecrQueryURL + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // t.Parallel() - Cannot run in parallel since we're modifying shared vars. + + if tc.ecrTokenAddress != "" { + ecrTokenAddress = tc.ecrTokenAddress + t.Cleanup(func() { + ecrTokenAddress = _ecrTokenAddress + }) + } + if tc.ecrQueryURL != "" { + ecrQueryURL = tc.ecrQueryURL + t.Cleanup(func() { + ecrQueryURL = _ecrQueryURL + }) + } + + // WHEN: Check is called with this version. + err := tc.registry.Check(tc.version) + + // THEN: any error case is expected. + e := errfmt.FormatError(err) + if !util.RegexCheck(tc.errRegex, e) { + t.Errorf( + "%s\nECRRegistry.Check() error mismatch\ngot: %q\nwant: %s", + tc.name, e, tc.errRegex, + ) + } + }) + } +} + +func TestECRAuth_RefreshQueryToken__integration(t *testing.T) { + // GIVEN: an ECRAuth to fetch a queryToken with. + tests := []struct { + name string + handler http.HandlerFunc // Served via httptest; overrides ecrTokenAddress. + tokenAddress *string + errRegex string + }{ + { + name: "valid", + errRegex: `^$`, + }, + { + name: "non-200 response", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }, + errRegex: `^ecr token request failed`, + }, + { + name: "unparseable response", + handler: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("not json")) + }, + errRegex: `^failed to parse ecr token response`, + }, + { + name: "invalid token URL", + tokenAddress: test.Ptr("https:// example.com"), + errRegex: `^ecr token refresh fail`, + }, + } + _ecrTokenAddress := ecrTokenAddress + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // t.Parallel() - Cannot run in parallel since we're modifying shared vars. + + switch { + case tc.handler != nil: + srv := httptest.NewServer(tc.handler) + t.Cleanup(srv.Close) + ecrTokenAddress = srv.URL + t.Cleanup(func() { + ecrTokenAddress = _ecrTokenAddress + }) + case tc.tokenAddress != nil: + ecrTokenAddress = *tc.tokenAddress + t.Cleanup(func() { + ecrTokenAddress = _ecrTokenAddress + }) + } + detail := ContainerDetail{Image: test.ArgusDockerECRRepo} + + // AND: an ECRAuth to fetch it with. + data := &ECRAuth{} + + // WHEN: refreshQueryToken() is called on it. + queryToken, err := data.refreshQueryToken(detail) + + prefix := fmt.Sprintf( + "%s\nECRAuth.refreshQueryToken(%+v)", + packageName, detail, + ) + + // THEN: any error case is expected. + e := errfmt.FormatError(err) + if !util.RegexCheck(tc.errRegex, e) { + t.Errorf( + "%s error mismatch:\ngot: %q\nwant: %q", + prefix, e, tc.errRegex, + ) + } + + // AND: if we didn't error, then we received a query token. + if err == nil && queryToken == "" { + t.Errorf("%s returned an empty queryToken\nwant: non-empty", prefix) + } + }) + } +} diff --git a/service/latest_version/filter/docker/registry_ecr_test.go b/service/latest_version/filter/docker/registry_ecr_test.go new file mode 100644 index 00000000..94cf4e43 --- /dev/null +++ b/service/latest_version/filter/docker/registry_ecr_test.go @@ -0,0 +1,1474 @@ +// Copyright [2026] [Argus] +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build unit + +package docker + +import ( + "fmt" + "testing" + "time" + + "github.com/release-argus/Argus/config/decode" + "github.com/release-argus/Argus/internal/test" + "github.com/release-argus/Argus/util" + "github.com/release-argus/Argus/util/errfmt" +) + +// ####################### +// # REGISTRY | DECODING # +// ####################### + +func TestECRRegistryDefaults_Unmarshal(t *testing.T) { + // GIVEN: an ECRRegistryDefaults and JSON/YAML to unmarshal into it. + tests := []struct { + name string + format string + data string + registry *ECRRegistryDefaults + errRegex string + want string + }{ + { + name: "JSON/empty", + format: "json", + data: "", + registry: &ECRRegistryDefaults{}, + errRegex: `^$`, + want: "{}\n", + }, + { + name: "JSON/empty object", + format: "json", + data: "{}", + registry: &ECRRegistryDefaults{}, + errRegex: `^$`, + want: "{}\n", + }, + { + name: "YAML/empty", + format: "yaml", + data: "{}", + registry: &ECRRegistryDefaults{}, + errRegex: `^$`, + want: "{}\n", + }, + { + name: "JSON/invalid", + format: "json", + data: "foo", + registry: &ECRRegistryDefaults{}, + errRegex: `invalid character`, + }, + { + name: "YAML/invalid", + format: "yaml", + data: "foo", + registry: &ECRRegistryDefaults{}, + errRegex: `string was used where mapping is expected`, + }, + { + name: "JSON/invalid Auth", + format: "json", + data: `{"auth": []}`, + registry: &ECRRegistryDefaults{ + CommonRegistryDefaults: CommonRegistryDefaults{ + Auth: &ECRAuth{}, + }, + }, + errRegex: test.TrimYAML(` + ^auth: + json: .*unmarshal.*$`, + ), + }, + { + name: "JSON/auth-null", + format: "json", + data: `{"auth": null}`, + registry: &ECRRegistryDefaults{}, + errRegex: `^$`, + want: "{}\n", + }, + { + name: "YAML/auth-empty", + format: "yaml", + data: test.TrimYAML(` + auth: {} + `), + registry: &ECRRegistryDefaults{ + CommonRegistryDefaults: CommonRegistryDefaults{ + Auth: &ECRAuth{}, + }, + }, + errRegex: `^$`, + want: "{}\n", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if _, _, testErr := test.AssertDecode( + t, + func(format string, data []byte) (*ECRRegistryDefaults, error) { + err := decode.Unmarshal(format, data, tc.registry) + return tc.registry, err + }, + tc.format, tc.data, + func(v *ECRRegistryDefaults) string { return v.String("") }, + tc.want, + tc.errRegex, + packageName, + "ECRRegistryDefaults", + ); testErr != nil { + t.Error(testErr) + } + + // AND: Auth should never be nil. + if tc.registry.Auth == nil { + t.Errorf( + "%s\nECRRegistryDefaults.Unmarshal(format=%q, data=%q) - Auth should not be nil", + packageName, tc.format, tc.data, + ) + } + }) + } +} + +func TestECRRegistry_Unmarshal(t *testing.T) { + // GIVEN: an ECRRegistry and JSON/YAML to unmarshal into it. + tests := []struct { + name string + format string + data string + registry *ECRRegistry + errRegex string + want string + }{ + { + name: "JSON/empty", + format: "json", + data: "", + registry: &ECRRegistry{}, + errRegex: `^$`, + want: "{}\n", + }, + { + name: "JSON/empty object", + format: "json", + data: "{}", + registry: &ECRRegistry{}, + errRegex: `^$`, + want: "{}\n", + }, + { + name: "YAML/invalid", + format: "yaml", + data: "foo", + registry: &ECRRegistry{}, + errRegex: `string was used where mapping is expected`, + }, + { + name: "JSON/invalid ContainerDetail", + format: "json", + data: `{"image": []}`, + registry: &ECRRegistry{ + CommonRegistry: CommonRegistry{ + Auth: &ECRAuth{}, + }, + }, + errRegex: `^json: .*unmarshal .*$`, + }, + { + name: "JSON/auth-null", + format: "json", + data: `{"auth": null}`, + registry: &ECRRegistry{}, + errRegex: `^$`, + want: "{}\n", + }, + { + name: "JSON/image+tag (auth omitted, ECR is anonymous)", + format: "json", + data: test.TrimJSON(`{ + "image": "i", + "tag": "t", + "auth": {} + }`), + registry: &ECRRegistry{ + CommonRegistry: CommonRegistry{ + Auth: &ECRAuth{}, + }, + }, + errRegex: `^$`, + want: test.TrimYAML(` + image: i + tag: t + `), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + registry, _, testErr := test.AssertDecode( + t, + func(format string, data []byte) (*ECRRegistry, error) { + err := decode.Unmarshal(format, data, tc.registry) + return tc.registry, err + }, + tc.format, tc.data, + func(v *ECRRegistry) string { return v.String("") }, + tc.want, + tc.errRegex, + packageName, + "ECRRegistry", + ) + if testErr != nil { + t.Fatal(testErr) + } + + // AND: Auth should never be nil. + if registry.Auth == nil { + t.Errorf( + "%s\nECRRegistry.Unmarshal(format=%q, data=%q) - Auth should not be nil", + packageName, tc.format, tc.data, + ) + } + }) + } +} + +func TestECRRegistry_ApplyOverrides(t *testing.T) { + // GIVEN: an ECRRegistry and JSON/YAML to decode into it. + tests := []struct { + name string + format string + data string + registry *ECRRegistry + errRegex string + want string + }{ + { + name: "JSON/empty object", + format: "json", + data: "{}", + registry: &ECRRegistry{}, + errRegex: `^$`, + want: "{}\n", + }, + { + name: "YAML/invalid", + format: "yaml", + data: "foo", + registry: &ECRRegistry{}, + errRegex: `string was used where mapping is expected`, + }, + { + name: "mutate image+tag", + format: "yaml", + data: test.TrimYAML(` + tag: t + `), + registry: &ECRRegistry{ + CommonRegistry: CommonRegistry{ + ContainerDetail: ContainerDetail{ + Image: "i", + }, + Auth: &ECRAuth{}, + }, + }, + errRegex: `^$`, + want: test.TrimYAML(` + image: i + tag: t + `), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // WHEN: it is decoded into itself. + if _, _, testErr := test.AssertApplyOverrides( + t, + tc.registry, + func(format string, data []byte, v *ECRRegistry) (*ECRRegistry, error) { + err := v.ApplyOverrides(format, data) + return v, err + }, + tc.format, tc.data, + func(v *ECRRegistry) string { return v.String("") }, + tc.want, + tc.errRegex, + true, + packageName, + "ECRRegistry.ApplyOverrides", + ); testErr != nil { + t.Error(testErr) + } + }) + } +} + +// #################### +// # REGISTRY | STATE # +// #################### + +func TestECRRegistryDefaults_IsZero(t *testing.T) { + // GIVEN: an ECRRegistryDefaults. + tests := []struct { + name string + registry *ECRRegistryDefaults + want bool + }{ + { + name: "nil", + registry: nil, + want: true, + }, + { + name: "empty", + registry: &ECRRegistryDefaults{}, + want: true, + }, + { + name: "non-empty/queryToken and validUntil", + registry: &ECRRegistryDefaults{ + CommonRegistryDefaults: CommonRegistryDefaults{ + Auth: &ECRAuthDefaults{ + queryToken: "abc", + validUntil: time.Now().UTC().Add(time.Hour), + }, + }, + }, + want: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // WHEN: IsZero is called with it. + got := tc.registry.IsZero() + + // THEN: the expected result is returned. + if got != tc.want { + t.Errorf( + "%s\nECRRegistryDefaults.IsZero() value mismatch\ngot: %t\nwant: %t", + packageName, got, tc.want, + ) + } + }) + } +} + +func TestECRRegistry_IsZero(t *testing.T) { + // GIVEN: an ECRRegistry. + tests := []struct { + name string + data *ECRRegistry + want bool + }{ + { + name: "nil", + data: nil, + want: true, + }, + { + name: "empty", + data: RegistryMap["ecr"]().(*ECRRegistry), + want: true, + }, + { + name: "non-empty/Type", + data: &ECRRegistry{ + CommonRegistry: CommonRegistry{ + Type: "abc", + Auth: RegistryMap["ecr"]().GetAuth(), + }, + }, + want: false, + }, + { + name: "non-empty/ContainerDetail", + data: &ECRRegistry{ + CommonRegistry: CommonRegistry{ + ContainerDetail: ContainerDetail{ + Image: "i", + }, + }, + }, + want: false, + }, + { + name: "non-empty/all", + data: &ECRRegistry{ + CommonRegistry: CommonRegistry{ + ContainerDetail: ContainerDetail{ + Image: "i", + }, + Type: "abc", + Auth: RegistryMap["ecr"]().GetAuth(), + }, + }, + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // WHEN: IsZero() is called on it. + got := tc.data.IsZero() + + // THEN: the expected result is returned. + if got != tc.want { + t.Fatalf( + "%s\nECRRegistry.IsZero() value mismatch\ngot: %t\nwant: %t", + packageName, got, tc.want, + ) + } + }) + } +} + +func TestECRRegistry_Copy(t *testing.T) { + // GIVEN: an ECRRegistry. + tests := []struct { + name string + registry *ECRRegistry + want string + }{ + { + name: "nil", + registry: nil, + want: "null\n", + }, + { + name: "empty", + registry: RegistryMap["ecr"]().(*ECRRegistry), + want: "{}\n", + }, + { + name: "filled", + registry: &ECRRegistry{ + CommonRegistry: CommonRegistry{ + ContainerDetail: ContainerDetail{ + Image: "i1", + Tag: "t1", + }, + Auth: &ECRAuth{ + ECRAuthDefaults: ECRAuthDefaults{ + queryToken: "qT", + validUntil: time.Now().Add(time.Hour), + defaults: &ECRAuthDefaults{}, + }, + }, + }, + }, + want: test.TrimYAML(` + image: i1 + tag: t1 + `), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // WHEN: Copy() is called on it. + gotInterface := tc.registry.Copy() + + prefix := fmt.Sprintf("%s\nECRRegistry.Copy()", packageName) + + // THEN: the returned Registry unmarshals the same. + if got := decode.ToYAMLString(gotInterface, ""); got != tc.want { + t.Fatalf( + "%s stringified mismatch\ngot: %q\nwant: %q", + prefix, got, tc.want, + ) + } + + // AND: the returned Registry is an ECRRegistry. + got, ok := gotInterface.(*ECRRegistry) + if !ok { + if gotInterface == nil { + return + } + t.Fatalf( + "%s returned wrong type: %T", + prefix, gotInterface, + ) + } + hadAuth, hasAuth := tc.registry.GetAuth().(*ECRAuth) + if !hasAuth { + return + } + + // AND: the cache values are copied. + gotAuth, ok := got.GetAuth().(*ECRAuth) + if !ok || + gotAuth.queryToken != hadAuth.queryToken || + gotAuth.validUntil != hadAuth.validUntil || + got.defaults != tc.registry.defaults { + t.Fatalf( + "%s mismatch\ngot: %+v\nwant: %s", + prefix, got, tc.want, + ) + } + + // AND: the returned ECRAuth is at a different address. + if gotAuth == hadAuth { + t.Fatalf( + "%s returned pointer to same Auth for instance %q", + prefix, tc.name, + ) + } + }) + } +} + +// ######################## +// # REGISTRY | STRINGIFY # +// ######################## + +func TestECRRegistryDefaults_String(t *testing.T) { + // GIVEN: an ECRRegistryDefaults. + tests := []struct { + name string + data *ECRRegistryDefaults + want string + }{ + { + name: "nil", + data: nil, + want: "", + }, + { + name: "empty", + data: &ECRRegistryDefaults{}, + want: "{}\n", + }, + { + name: "filled", + data: &ECRRegistryDefaults{ + CommonRegistryDefaults: CommonRegistryDefaults{ + Auth: &ECRAuthDefaults{ + queryToken: "qT", + validUntil: time.Now().Add(time.Hour), + }, + }, + }, + want: "{}\n", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + test.AssertStringWithPrefixes( + t, + packageName, + tc.data.String, + tc.want, + ) + }) + } +} + +func TestECRRegistry_String(t *testing.T) { + // GIVEN: an ECRRegistry. + tests := []struct { + name string + data *ECRRegistry + want string + }{ + { + name: "nil", + data: nil, + want: "", + }, + { + name: "empty", + data: &ECRRegistry{}, + want: "{}\n", + }, + { + name: "filled", + data: &ECRRegistry{ + CommonRegistry: CommonRegistry{ + Type: "test-ecr", + ContainerDetail: ContainerDetail{ + Image: "i1", + Tag: "t1", + }, + Auth: &ECRAuth{ + ECRAuthDefaults: ECRAuthDefaults{ + queryToken: "qT", + validUntil: time.Now().Add(time.Hour), + }, + }, + }, + }, + want: test.TrimYAML(` + type: test-ecr + image: i1 + tag: t1 + `), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + test.AssertStringWithPrefixes( + t, + packageName, + tc.data.String, + tc.want, + ) + }) + } +} + +// ####################### +// # REGISTRY | METADATA # +// ####################### + +func TestECRRegistryDefaults_GetType(t *testing.T) { + // GIVEN: an ECRRegistryDefaults. + var registry ECRRegistryDefaults + + // WHEN: GetType is called on it. + got := registry.GetType() + + // THEN: the type is returned. + if want := "ecr"; got != want { + t.Errorf( + "%s\ngot %q, want %q", + packageName, got, want, + ) + } +} + +func TestECRRegistry_GetType(t *testing.T) { + // GIVEN: an ECRRegistry. + var registry ECRRegistry + + // WHEN: GetType is called on it. + got := registry.GetType() + + // THEN: the type is returned. + if want := "ecr"; got != want { + t.Errorf( + "%s\ngot %q, want %q", + packageName, got, want, + ) + } +} + +// ######################### +// # REGISTRY | OPERATIONS # +// ######################### + +func TestECRRegistry_NewRequest(t *testing.T) { + // GIVEN: an ECRRegistry, and a tag. + tests := []struct { + name string + registry *ECRRegistry + tag string + errRegex string + }{ + { + name: "no image or tag", + registry: &ECRRegistry{ + CommonRegistry: CommonRegistry{ + ContainerDetail: ContainerDetail{ + Image: "", + Tag: "", + }, + }, + }, + errRegex: `^$`, + }, + { + name: "have image+tag", + registry: &ECRRegistry{ + CommonRegistry: CommonRegistry{ + ContainerDetail: ContainerDetail{ + Image: "123", + Tag: "not-used", + }, + }, + }, + tag: "foo", + errRegex: `^$`, + }, + { + name: "tag: invalid", + registry: &ECRRegistry{ + CommonRegistry: CommonRegistry{ + ContainerDetail: ContainerDetail{ + Image: "123", + Tag: "not-used", + }, + }, + }, + tag: " foo", + errRegex: test.TrimYAML(` + ^parse "https://.* + [^\s]+ invalid control character in URL$`, + ), + }, + { + name: "image: invalid", + registry: &ECRRegistry{ + CommonRegistry: CommonRegistry{ + ContainerDetail: ContainerDetail{ + Image: " 123", + Tag: "not-used", + }, + }, + }, + tag: "foo", + errRegex: test.TrimYAML(` + ^parse "https://.* + [^\s]+ invalid control character in URL$`, + ), + }, + } + + // AND: Headers to verify being set. + headers := map[string]string{ + "Accept": "application/vnd.oci.image.index.v1+json", + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // WHEN: newRequest() is called on it. + req, err := tc.registry.newRequest(tc.tag) + + prefix := fmt.Sprintf( + "%s\nECRRegistry.newRequest(%q)", + packageName, tc.tag, + ) + + // THEN: The error is as expected. + e := errfmt.FormatError(err) + if !util.RegexCheck(tc.errRegex, e) { + t.Errorf( + "%s error mismatch\ngot: %q\nwant: %q", + prefix, e, tc.errRegex, + ) + } + if err != nil { + return + } + + // AND: The headers are set. + for key, value := range headers { + if got := req.Header.Get(key); got != value { + t.Errorf( + "%s .Header[%q] mismatch\ngot: %q\nwant: %q", + prefix, key, + got, value, + ) + } + } + }) + } +} + +// ################### +// # AUTH | DECODING # +// ################### + +func TestECRAuthDefaults_Unmarshal(t *testing.T) { + // GIVEN: an ECRAuthDefaults. + tests := []struct { + name string + format, data string + errRegex string + }{ + { + name: "JSON/empty", + format: "json", + data: "{}", + errRegex: `^$`, + }, + { + name: "YAML/empty", + format: "yaml", + data: "{}\n", + errRegex: `^$`, + }, + { + name: "JSON/invalid", + format: "json", + data: "invalid", + errRegex: `invalid character`, + }, + { + name: "JSON/sequence rejected", + format: "json", + data: `[]`, + errRegex: `unmarshal JSON array`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var auth ECRAuthDefaults + err := decode.Unmarshal(tc.format, []byte(tc.data), &auth) + + // THEN: any error case is expected. + e := errfmt.FormatError(err) + if !util.RegexCheck(tc.errRegex, e) { + t.Errorf( + "%s\nECRAuthDefaults.Unmarshal(format=%q, data=%q) error mismatch\ngot: %q\nwant: %q", + packageName, tc.format, tc.data, e, tc.errRegex, + ) + } + }) + } +} + +// ################ +// # AUTH | STATE # +// ################ + +func TestECRAuthDefaults_IsZero(t *testing.T) { + // GIVEN: an ECRAuthDefaults. + tests := []struct { + name string + data *ECRAuthDefaults + want bool + }{ + { + name: "nil", + data: nil, + want: true, + }, + { + name: "empty", + data: &ECRAuthDefaults{}, + want: true, + }, + { + name: "filled", + data: &ECRAuthDefaults{ + queryToken: "qT", + validUntil: time.Now().Add(time.Hour), + }, + want: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // WHEN: IsZero() is called on it. + got := tc.data.IsZero() + + // THEN: the expected result is returned. + if got != tc.want { + t.Fatalf( + "%s\nECRAuthDefaults.IsZero() value mismatch\ngot: %t\nwant: %t", + packageName, got, tc.want, + ) + } + }) + } +} + +func TestECRAuth_Copy(t *testing.T) { + // GIVEN: an ECRAuth. + tests := []struct { + name string + auth *ECRAuth + want string + }{ + { + name: "nil", + auth: nil, + want: "null\n", + }, + { + name: "empty", + auth: &ECRAuth{}, + want: "{}\n", + }, + { + name: "filled", + auth: &ECRAuth{ + ECRAuthDefaults: ECRAuthDefaults{ + queryToken: "qT", + validUntil: time.Now(), + defaults: &ECRAuthDefaults{}, + }, + }, + want: "{}\n", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // WHEN: Copy() is called on it. + authCopy := tc.auth.Copy() + + prefix := fmt.Sprintf("%s\nECRAuth.Copy()", packageName) + + // THEN: the returned RegistryAuth unmarshals the same. + if got := decode.ToYAMLString(authCopy, ""); got != tc.want { + t.Fatalf( + "%s mismatch\ngot: %q\nwant: %q", + prefix, got, tc.want, + ) + } + + // AND: the returned RegistryAuth is an ECRAuth. + got, ok := authCopy.(*ECRAuth) + if !ok { + if authCopy == nil { + return + } + t.Fatalf( + "%s returned wrong type: %T", + prefix, authCopy, + ) + } + if tc.auth == nil { + return + } + + // AND: the cache values are copied. + if got.queryToken != tc.auth.queryToken || + got.validUntil != tc.auth.validUntil || + got.defaults != tc.auth.defaults { + t.Fatalf( + "%s mismatch\ngot: %+v\nwant: %+v", + prefix, got, tc.auth, + ) + } + + // AND: the returned ECRAuth is at a different address. + if got == tc.auth { + t.Fatalf( + "%s returned pointer to the same Auth for instance %q", + prefix, tc.name, + ) + } + }) + } +} + +// #################### +// # AUTH | STRINGIFY # +// #################### + +func TestECRAuthDefaults_String(t *testing.T) { + // GIVEN: an ECRAuthDefaults. + tests := []struct { + name string + data *ECRAuthDefaults + want string + }{ + { + name: "nil", + data: nil, + want: "null\n", + }, + { + name: "empty", + data: &ECRAuthDefaults{}, + want: "{}\n", + }, + { + name: "filled", + data: &ECRAuthDefaults{ + queryToken: "t1", + validUntil: time.Now().Add(time.Hour), + }, + want: "{}\n", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + test.AssertStringWithPrefixes( + t, + packageName, + tc.data.String, + tc.want, + ) + }) + } +} + +// ################### +// # AUTH | DEFAULTS # +// ################### + +func TestECRAuthDefaults_Defaults(t *testing.T) { + // GIVEN: an ECRAuthDefaults. + tests := []struct { + name string + data *ECRAuthDefaults + haveDefaults bool + }{ + { + name: "no defaults", + data: &ECRAuthDefaults{}, + haveDefaults: false, + }, + { + name: "defaults", + data: &ECRAuthDefaults{ + defaults: &ECRAuthDefaults{}, + }, + haveDefaults: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // WHEN: Defaults is called on it. + got := tc.data.Defaults() + + // THEN: Defaults are returned when expected. + if gotDefaults := got != nil; gotDefaults != tc.haveDefaults { + t.Errorf( + "%s\nECRAuthDefaults.Defaults() mismatch\ngot: %t\nwant: %t", + packageName, gotDefaults, tc.haveDefaults, + ) + } + }) + } +} + +func TestECRAuthDefaults_SetDefaults(t *testing.T) { + // GIVEN: a RegistryAuthDefaults. + tests := []struct { + name string + newDefaults RegistryAuthDefaults + doesSet bool + }{ + { + name: "give ECRAuthDefaults", + newDefaults: &ECRAuthDefaults{}, + doesSet: true, + }, + { + name: "doesn't give GHCRAuthDefaults", + newDefaults: &GHCRAuthDefaults{}, + doesSet: false, + }, + { + name: "doesn't give HubAuthDefaults", + newDefaults: &HubAuthDefaults{}, + doesSet: false, + }, + { + name: "doesn't give QuayAuthDefaults", + newDefaults: &QuayAuthDefaults{}, + doesSet: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // AND: an ECRAuthDefaults to take them. + data := &ECRAuthDefaults{} + + // WHEN: SetDefaults() is called to give these defaults. + data.SetDefaults(tc.newDefaults) + + // THEN: they are set when expected. + if got := data.defaults == tc.newDefaults; got != tc.doesSet { + t.Errorf( + "%s\nECRAuthDefaults.SetDefaults() .defaults mismatch\ngot: %t\nwant: %t", + packageName, got, tc.doesSet, + ) + } + }) + } +} + +// ##################### +// # AUTH | VALIDATION # +// ##################### + +func TestECRAuth_CheckValues(t *testing.T) { + // GIVEN: an ECRAuth. + tests := []struct { + name string + input *ECRAuth + errRegex string + }{ + { + name: "nil", + input: (*ECRAuth)(nil), + errRegex: `^$`, + }, + { + name: "empty", + input: &ECRAuth{}, + errRegex: `^$`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + _ = test.AssertCheckValuesWithError( + t, + packageName, + tc.errRegex, + tc.input.CheckValues, + ) + }) + } +} + +// ###################### +// # AUTH | CREDENTIALS # +// ###################### + +func TestECRAuthDefaults_GetTokenSelf(t *testing.T) { + // GIVEN: an ECRAuthDefaults. + data := &ECRAuthDefaults{} + + // WHEN: GetTokenSelf() is called on it. + got := data.GetTokenSelf() + + // THEN: the empty token is returned (Amazon ECR Public Gallery is anonymous). + if got != "" { + t.Errorf( + "%s\nECRAuthDefaults.GetTokenSelf() mismatch\ngot: %q\nwant: %q", + packageName, got, "", + ) + } +} + +// ####################### +// # AUTH | QUERY TOKENS # +// ####################### + +func TestECRAuthDefaults_GetQueryTokenSelf(t *testing.T) { + // GIVEN: an ECRAuthDefaults. + tests := []struct { + name string + data *ECRAuthDefaults + want string + }{ + { + name: "empty", + data: &ECRAuthDefaults{}, + want: "", + }, + { + name: "expired", + data: &ECRAuthDefaults{ + queryToken: "query-token", + validUntil: time.Now().Add(-1 * time.Second), + }, + want: "", + }, + { + name: "valid", + data: &ECRAuthDefaults{ + queryToken: "query-token", + validUntil: time.Now().Add(10 * time.Second), + }, + want: "query-token", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // WHEN: GetQueryTokenSelf() is called on it. + queryToken, _ := tc.data.GetQueryTokenSelf() + + // THEN: the query token is returned as expected. + if queryToken != tc.want { + t.Errorf( + "%s\nECRAuthDefaults.GetQueryTokenSelf() queryToken mismatch\ngot: %q\nwant: %q", + packageName, queryToken, tc.want, + ) + } + }) + } +} + +func TestECRAuthDefaults_GetQueryTokenSelf__parallel(t *testing.T) { + // GIVEN: an ECRAuthDefaults with a valid queryToken. + data := &ECRAuthDefaults{ + queryToken: "query-token", + validUntil: time.Now().Add(time.Hour), + } + + // AND: the mutex is held. + data.mu.Lock() + go func() { + time.Sleep(time.Second) + data.mu.Unlock() + }() + + // WHEN: GetQueryTokenSelf() is queued on it by many goroutines. + for range 10 { + go func() { _, _ = data.GetQueryTokenSelf() }() + } + // Call again to verify we still get the queryToken once the lock is released. + queryToken, _ := data.GetQueryTokenSelf() + + // THEN: the query token is returned as expected. + if queryToken != data.queryToken { + t.Errorf( + "%s\nECRAuthDefaults.GetQueryTokenSelf() queryToken mismatch\ngot: %q\nwant: %q", + packageName, queryToken, data.queryToken, + ) + } +} + +func TestECRAuth_GetQueryToken__cached(t *testing.T) { + // GIVEN: an ECRAuth with a valid cached query token. + tests := []struct { + name string + data *ECRAuth + want string + }{ + { + name: "valid on self", + data: &ECRAuth{ + ECRAuthDefaults: ECRAuthDefaults{ + queryToken: "query-token", + validUntil: time.Now().Add(10 * time.Second), + }, + }, + want: "query-token", + }, + { + name: "valid via defaults chain", + data: &ECRAuth{ + ECRAuthDefaults: ECRAuthDefaults{ + defaults: &ECRAuthDefaults{ + queryToken: "default-query-token", + validUntil: time.Now().Add(10 * time.Second), + }, + }, + }, + want: "default-query-token", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // WHEN: GetQueryToken() is called on it. + queryToken, err := tc.data.GetQueryToken(ContainerDetail{Image: test.ArgusDockerECRRepo}) + + // THEN: no refresh is needed, so no error. + if err != nil { + t.Fatalf( + "%s\nECRAuth.GetQueryToken() unexpected error: %s", + packageName, errfmt.FormatError(err), + ) + } + + // AND: the cached query token is returned. + if queryToken != tc.want { + t.Errorf( + "%s\nECRAuth.GetQueryToken() mismatch\ngot: %q\nwant: %q", + packageName, queryToken, tc.want, + ) + } + }) + } +} + +func TestECRAuthDefaults_SetQueryToken(t *testing.T) { + // GIVEN: an ECRAuth with a defaults chain. + data := &ECRAuth{ + ECRAuthDefaults: ECRAuthDefaults{ + defaults: &ECRAuthDefaults{}, + }, + } + + queryToken := "new-query-token" + validUntil := time.Now().Add(10 * time.Second) + + // WHEN: SetQueryToken() is called on it. + data.SetQueryToken(queryToken, validUntil) + + // THEN: the queryToken is set on self. + if data.queryToken != queryToken || !data.validUntil.Equal(validUntil) { + t.Errorf( + "%s\nECRAuthDefaults.SetQueryToken() self not set\ngot: %q/%v\nwant: %q/%v", + packageName, data.queryToken, data.validUntil, queryToken, validUntil, + ) + } + + // AND: the defaults are not touched (repo-global token cached per-instance). + if data.defaults.queryToken != "" { + t.Errorf( + "%s\nECRAuthDefaults.SetQueryToken() should not propagate to defaults\ngot: %q", + packageName, data.defaults.queryToken, + ) + } +} + +func TestECRAuth_RefreshQueryToken__cached(t *testing.T) { + // GIVEN: an ECRAuth with a cached query token that is valid for a while. + auth := &ECRAuth{ + ECRAuthDefaults: ECRAuthDefaults{ + queryToken: "cached-token", + validUntil: time.Now().Add(time.Hour), + }, + } + + // WHEN: refreshQueryToken is called on it. + queryToken, err := auth.refreshQueryToken(ContainerDetail{Image: test.ArgusDockerECRRepo}) + + prefix := fmt.Sprintf("%s\nECRAuth.refreshQueryToken()", packageName) + + // THEN: the cached token is returned without error. + e := errfmt.FormatError(err) + if !util.RegexCheck(`^$`, e) { + t.Fatalf("%s error mismatch\ngot: %q\nwant: %q", prefix, e, `^$`) + } + if got, want := queryToken, "cached-token"; got != want { + t.Fatalf( + "%s queryToken mismatch\ngot: %q\nwant: %q", + prefix, got, want, + ) + } +} + +// ###################### +// # AUTH | INHERITANCE # +// ###################### + +func TestECRAuth_Inherit(t *testing.T) { + // GIVEN: an ECRAuth, and a RegistryAuth to try and inherit from. + tests := []struct { + name string + auth *ECRAuth + from RegistryAuth + srcDetail, dstDetail ContainerDetail + inherit bool + }{ + { + name: "inherit from nil", + auth: &ECRAuth{}, + from: nil, + }, + { + name: "inherit from ECRAuth (tokens are global, images differ)", + auth: &ECRAuth{}, + from: &ECRAuth{ + ECRAuthDefaults: ECRAuthDefaults{ + queryToken: "qt", + validUntil: time.Now(), + }, + }, + srcDetail: ContainerDetail{Image: "a", Tag: "b"}, + dstDetail: ContainerDetail{Image: "c", Tag: "d"}, + inherit: true, + }, + { + name: "do not inherit from GHCRAuth", + auth: &ECRAuth{}, + from: &GHCRAuth{ + GHCRAuthDefaults: GHCRAuthDefaults{ + Token: "abc", + }, + }, + }, + { + name: "do not inherit from HubAuth", + auth: &ECRAuth{}, + from: &HubAuth{ + HubAuthDefaults: HubAuthDefaults{ + Username: "user", + Token: "abc", + }, + }, + }, + { + name: "do not inherit from QuayAuth", + auth: &ECRAuth{}, + from: &QuayAuth{ + QuayAuthDefaults: QuayAuthDefaults{ + Token: "abc", + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + _, hadQueryToken, hadValidUntil := getTokenData(t, tc.auth) + + // WHEN: Inherit is called on it. + tc.auth.Inherit(tc.from, tc.srcDetail, tc.dstDetail) + + prefix := fmt.Sprintf( + "%s\nECRAuth.Inherit(from=%T)", + packageName, tc.from, + ) + + // THEN: the expected inheritance has occurred. + _, gotQueryToken, gotValidUntil := getTokenData(t, tc.auth) + queryTokenInherited := hadQueryToken != gotQueryToken + validUntilInherited := !hadValidUntil.Equal(gotValidUntil) + if queryTokenInherited != tc.inherit || validUntilInherited != tc.inherit { + t.Errorf( + "%s inheritance mismatch (want inherit=%t)\nqueryToken: had %q, got %q\nvalidUntil: had %q, got %q", + prefix, tc.inherit, + hadQueryToken, gotQueryToken, + hadValidUntil, gotValidUntil, + ) + } + }) + } +} diff --git a/service/latest_version/filter/docker/registry_ghcr_test.go b/service/latest_version/filter/docker/registry_ghcr_test.go index ad49ef44..5f5a372c 100644 --- a/service/latest_version/filter/docker/registry_ghcr_test.go +++ b/service/latest_version/filter/docker/registry_ghcr_test.go @@ -776,6 +776,10 @@ func TestGHCRRegistry_Copy(t *testing.T) { name: "filled", registry: &GHCRRegistry{ CommonRegistry: CommonRegistry{ + ContainerDetail: ContainerDetail{ + Image: "i1", + Tag: "t1", + }, Auth: &GHCRAuth{ GHCRAuthDefaults: GHCRAuthDefaults{ Token: "t1", @@ -785,6 +789,8 @@ func TestGHCRRegistry_Copy(t *testing.T) { }, }, want: test.TrimYAML(` + image: i1 + tag: t1 auth: token: t1 `), @@ -976,7 +982,10 @@ func TestGHCRRegistryDefaults_GetType(t *testing.T) { // THEN: the type is returned. if want := "ghcr"; got != want { - t.Errorf("got %q, want %q", got, want) + t.Errorf( + "%s\ngot %q, want %q", + packageName, got, want, + ) } } @@ -989,7 +998,10 @@ func TestGHCRRegistry_GetType(t *testing.T) { // THEN: the type is returned. if want := "ghcr"; got != want { - t.Errorf("got %q, want %q", got, want) + t.Errorf( + "%s\ngot %q, want %q", + packageName, got, want, + ) } } @@ -1073,7 +1085,7 @@ func TestGHCRRegistry_NewRequest(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - // WHEN: getQueryURL() is called on it. + // WHEN: newRequest() is called on it. req, err := tc.registry.newRequest(tc.tag) prefix := fmt.Sprintf( @@ -1450,7 +1462,7 @@ func TestGHCRAuthDefaults_Defaults(t *testing.T) { } func TestGHCRAuthDefaults_SetDefaults(t *testing.T) { - // GIVEN: a RegistryAuth. + // GIVEN: a RegistryAuthDefaults. tests := []struct { name string newDefaults RegistryAuthDefaults @@ -1461,6 +1473,11 @@ func TestGHCRAuthDefaults_SetDefaults(t *testing.T) { newDefaults: &GHCRAuthDefaults{}, doesSet: true, }, + { + name: "doesn't give ECRAuthDefaults", + newDefaults: &ECRAuthDefaults{}, + doesSet: false, + }, { name: "doesn't give HubAuthDefaults", newDefaults: &HubAuthDefaults{}, diff --git a/service/latest_version/filter/docker/registry_hub_test.go b/service/latest_version/filter/docker/registry_hub_test.go index 583e3a24..2d7f74b9 100644 --- a/service/latest_version/filter/docker/registry_hub_test.go +++ b/service/latest_version/filter/docker/registry_hub_test.go @@ -793,6 +793,10 @@ func TestHubRegistry_Copy(t *testing.T) { name: "filled", registry: &HubRegistry{ CommonRegistry: CommonRegistry{ + ContainerDetail: ContainerDetail{ + Image: "i1", + Tag: "t1", + }, Auth: &HubAuth{ HubAuthDefaults: HubAuthDefaults{ Username: "u1", @@ -803,6 +807,8 @@ func TestHubRegistry_Copy(t *testing.T) { }, }, want: test.TrimYAML(` + image: i1 + tag: t1 auth: username: u1 token: t1 @@ -1001,7 +1007,10 @@ func TestHubRegistryDefaults_GetType(t *testing.T) { // THEN: the type is returned. if want := "hub"; got != want { - t.Errorf("got %q, want %q", got, want) + t.Errorf( + "%s\ngot %q, want %q", + packageName, got, want, + ) } } @@ -1034,7 +1043,10 @@ func TestHubRegistry_GetType(t *testing.T) { // THEN: the type is returned. if want := "hub"; got != want { - t.Errorf("got %q, want %q", got, want) + t.Errorf( + "%s\ngot %q, want %q", + packageName, got, want, + ) } }) } @@ -1230,7 +1242,7 @@ func TestHubRegistry_NewRequest(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - // WHEN: getQueryURL() is called on it. + // WHEN: newRequest() is called on it. _, err := tc.registry.newRequest(tc.tag) prefix := fmt.Sprintf( @@ -1498,7 +1510,7 @@ func TestHubAuthDefaults_Defaults(t *testing.T) { } func TestHubAuthDefaults_SetDefaults(t *testing.T) { - // GIVEN: a RegistryAuth. + // GIVEN: a RegistryAuthDefaults. tests := []struct { name string newDefaults RegistryAuthDefaults @@ -1509,6 +1521,11 @@ func TestHubAuthDefaults_SetDefaults(t *testing.T) { newDefaults: &HubAuthDefaults{}, doesSet: true, }, + { + name: "doesn't give ECRAuthDefaults", + newDefaults: &ECRAuthDefaults{}, + doesSet: false, + }, { name: "doesn't give GHCRAuthDefaults", newDefaults: &GHCRAuthDefaults{}, diff --git a/service/latest_version/filter/docker/registry_quay_test.go b/service/latest_version/filter/docker/registry_quay_test.go index 527a636f..bbdf3f16 100644 --- a/service/latest_version/filter/docker/registry_quay_test.go +++ b/service/latest_version/filter/docker/registry_quay_test.go @@ -735,6 +735,10 @@ func TestQuayRegistry_Copy(t *testing.T) { name: "filled", registry: &QuayRegistry{ CommonRegistry: CommonRegistry{ + ContainerDetail: ContainerDetail{ + Image: "i1", + Tag: "t1", + }, Auth: &QuayAuth{ QuayAuthDefaults: QuayAuthDefaults{ Token: "t1", @@ -744,6 +748,8 @@ func TestQuayRegistry_Copy(t *testing.T) { }, }, want: test.TrimYAML(` + image: i1 + tag: t1 auth: token: t1 `), @@ -932,7 +938,10 @@ func TestQuayRegistryDefaults_GetType(t *testing.T) { // THEN: the type is returned. if want := "quay"; got != want { - t.Errorf("got %q, want %q", got, want) + t.Errorf( + "%s\ngot %q, want %q", + packageName, got, want, + ) } } func TestQuayRegistry_GetType(t *testing.T) { @@ -944,7 +953,10 @@ func TestQuayRegistry_GetType(t *testing.T) { // THEN: the type is returned. if want := "quay"; got != want { - t.Errorf("got %q, want %q", got, want) + t.Errorf( + "%s\ngot %q, want %q", + packageName, got, want, + ) } } @@ -1023,11 +1035,11 @@ func TestQuayRegistry_NewRequest(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - // WHEN: getQueryURL() is called on it. + // WHEN: newRequest() is called on it. _, err := tc.registry.newRequest(tc.tag) prefix := fmt.Sprintf( - "%s\nHubRegistry newRequest(%s)", + "%s\nQuayRegistry newRequest(%s)", packageName, tc.tag, ) @@ -1273,6 +1285,11 @@ func TestQuayAuthDefaults_SetDefaults(t *testing.T) { newDefaults: &QuayAuthDefaults{}, doesSet: true, }, + { + name: "doesn't give ECRAuthDefaults", + newDefaults: &ECRAuthDefaults{}, + doesSet: false, + }, { name: "doesn't give GHCRAuthDefaults", newDefaults: &GHCRAuth{ diff --git a/service/latest_version/filter/docker/test/main.go b/service/latest_version/filter/docker/test/main.go index 78c23a7c..53e82186 100644 --- a/service/latest_version/filter/docker/test/main.go +++ b/service/latest_version/filter/docker/test/main.go @@ -31,6 +31,8 @@ func GetDefaultOfDockerType(t *testing.T, dType string, defaults *docker.Default t.Helper() switch dType { + case "ecr": + return defaults.Registry.ECR, nil case "ghcr": return defaults.Registry.GHCR, nil case "hub": diff --git a/service/latest_version/filter/docker/test/main_test.go b/service/latest_version/filter/docker/test/main_test.go index 89421cc4..a761721f 100644 --- a/service/latest_version/filter/docker/test/main_test.go +++ b/service/latest_version/filter/docker/test/main_test.go @@ -32,6 +32,11 @@ func TestGetDefaultOfDockerType(t *testing.T) { expect docker.RegistryDefaults err bool }{ + { + name: "ecr", + dType: "ecr", + expect: defaults.Registry.ECR, + }, { name: "ghcr", dType: "ghcr", diff --git a/service/latest_version/filter/docker/types.go b/service/latest_version/filter/docker/types.go index 4ca9a6ab..83c3e25c 100644 --- a/service/latest_version/filter/docker/types.go +++ b/service/latest_version/filter/docker/types.go @@ -20,6 +20,7 @@ import ( // PossibleTypes for the docker Lookup. var PossibleTypes = []string{ + "ecr", "ghcr", "hub", "quay", @@ -27,6 +28,13 @@ var PossibleTypes = []string{ // RegistryMap maps a registry type to a Registry constructor. var RegistryMap = map[string]func() Registry{ + "ecr": func() Registry { + return &ECRRegistry{ + CommonRegistry: CommonRegistry{ + Auth: &ECRAuth{}, + }, + } + }, "ghcr": func() Registry { return &GHCRRegistry{ CommonRegistry: CommonRegistry{ @@ -53,6 +61,13 @@ var RegistryMapInheritable = polymorphic.ToInheritableMap(RegistryMap) // RegistryDefaultsMap maps a registry type to a RegistryDefaults constructor. var RegistryDefaultsMap = map[string]func() RegistryDefaults{ + "ecr": func() RegistryDefaults { + return &ECRRegistryDefaults{ + CommonRegistryDefaults: CommonRegistryDefaults{ + Auth: &ECRAuthDefaults{}, + }, + } + }, "ghcr": func() RegistryDefaults { return &GHCRRegistryDefaults{ CommonRegistryDefaults: CommonRegistryDefaults{ diff --git a/web/ui/react-app/src/components/modals/service-edit/latest-version-require.tsx b/web/ui/react-app/src/components/modals/service-edit/latest-version-require.tsx index b97df79d..ba53f631 100644 --- a/web/ui/react-app/src/components/modals/service-edit/latest-version-require.tsx +++ b/web/ui/react-app/src/components/modals/service-edit/latest-version-require.tsx @@ -16,8 +16,7 @@ import { useSchemaContext } from '@/contexts/service-edit-zod-type'; import type { NonNull } from '@/types/util'; import { type DockerFilterType, - type DockerFilterUsername, - type DockerFilterUsernameDefaults, + type DockerFilterUsernameToken, type DockerType, LATEST_VERSION_LOOKUP__REQUIRE_DOCKER_TYPE, LATEST_VERSION_LOOKUP_TYPE, @@ -25,15 +24,15 @@ import { latestVersionRequireDockerTypeOptions, type RequireDockerFilterDefaults, } from '@/utils/api/types/config/service/latest-version'; -import type { - DockerTypeDockerHub, - LatestVersionRequire, -} from '@/utils/api/types/config-edit/service/types/latest-version'; +import type { LatestVersionRequire } from '@/utils/api/types/config-edit/service/types/latest-version'; import { type NullString, nullString, } from '@/utils/api/types/config-edit/shared/null-string'; +// Widest auth shape across the registry union (ECR has none). +type WithDockerAuth = { auth?: DockerFilterUsernameToken['auth'] | null }; + /** * The `latest_version.require` form fields. */ @@ -82,10 +81,19 @@ const EditServiceLatestVersionRequire = () => { dockerRegistry === nullString ? (defaultDockerRegistry as NonNull) : dockerRegistry; + // Only Docker Hub has a username field. const showUsernameField = selectedDockerRegistry === LATEST_VERSION_LOOKUP__REQUIRE_DOCKER_TYPE.DOCKER_HUB.value; + // Amazon ECR Public Gallery is anonymous with no auth fields. + const showTokenField = + selectedDockerRegistry !== + LATEST_VERSION_LOOKUP__REQUIRE_DOCKER_TYPE.AMAZON_ECR.value; const dockerDefaults = defaults?.docker?.registry?.[selectedDockerRegistry]; + // `auth` varies by registry (token / token+username / none for ECR), so read + // it through the widest shape — every field is optional. + const dockerAuth = (values.docker as WithDockerAuth)?.auth; + const dockerDefaultsAuth = (dockerDefaults as WithDockerAuth | undefined)?.auth; // Target release assets or webpages. const latestVersionType = useWatch({ @@ -168,33 +176,30 @@ const EditServiceLatestVersionRequire = () => { {showUsernameField && ( + )} + {showTokenField && ( + )} - diff --git a/web/ui/react-app/src/utils/api/types/config-edit/service/form/builder--latest-version.ts b/web/ui/react-app/src/utils/api/types/config-edit/service/form/builder--latest-version.ts index 8192fd23..16c2f547 100644 --- a/web/ui/react-app/src/utils/api/types/config-edit/service/form/builder--latest-version.ts +++ b/web/ui/react-app/src/utils/api/types/config-edit/service/form/builder--latest-version.ts @@ -4,7 +4,7 @@ import type { NonNull } from '@/types/util'; import { type DockerFilter, type DockerFilterType, - type DockerFilterUsername, + type DockerFilterUsernameToken, LATEST_VERSION_LOOKUP__REQUIRE_DOCKER_TYPE, LATEST_VERSION_LOOKUP_TYPE, type LatestVersionLookup, @@ -23,6 +23,7 @@ import { dockerFilterSchemaDefaults, isLatestVersionType, latestVersionLookupRequireDockerTypeSchema, + latestVersionLookupRequireDockerTypeSchemaAmazonECR, latestVersionLookupRequireDockerTypeSchemaDockerHub, type latestVersionLookupSchema, latestVersionLookupSchemaDefault, @@ -126,13 +127,13 @@ export const buildDockerFilterSchemaWithFallbacks = ( const path = 'latest_version.require.docker'; const defaultType = defaults?.type || hardDefaults?.type || undefined; - const dockerHubValue = - LATEST_VERSION_LOOKUP__REQUIRE_DOCKER_TYPE.DOCKER_HUB.value; + const ecrValue = LATEST_VERSION_LOOKUP__REQUIRE_DOCKER_TYPE.AMAZON_ECR.value; const ghcrValue = LATEST_VERSION_LOOKUP__REQUIRE_DOCKER_TYPE.GHCR.value; + const hubValue = LATEST_VERSION_LOOKUP__REQUIRE_DOCKER_TYPE.DOCKER_HUB.value; const quayValue = LATEST_VERSION_LOOKUP__REQUIRE_DOCKER_TYPE.QUAY.value; const combinedDefaults = { - registry: [dockerHubValue, ghcrValue, quayValue].reduce( + registry: [ecrValue, ghcrValue, hubValue, quayValue].reduce( (acc, type) => { acc[type] = applyDefaultsRecursive>( (defaults?.registry?.[type] as Partial) ?? null, @@ -148,7 +149,7 @@ export const buildDockerFilterSchemaWithFallbacks = ( }; // Docker registries that support username with tokens. - const usernameTypes = new Set([dockerHubValue]); + const usernameTypes = new Set([hubValue]); // Docker schema. const schema = z.preprocess( @@ -165,11 +166,11 @@ export const buildDockerFilterSchemaWithFallbacks = ( return data; }, z.discriminatedUnion('type', [ - latestVersionLookupRequireDockerTypeSchemaDockerHub.extend({ + latestVersionLookupRequireDockerTypeSchemaAmazonECR.extend({ type: - defaultType === dockerHubValue - ? z.literal([dockerHubValue, nullString]) - : z.literal(dockerHubValue), + defaultType === ecrValue + ? z.literal([ecrValue, nullString]) + : z.literal(ecrValue), }), latestVersionLookupRequireDockerTypeSchema.extend({ type: @@ -177,6 +178,12 @@ export const buildDockerFilterSchemaWithFallbacks = ( ? z.literal([ghcrValue, nullString]) : z.literal(ghcrValue), }), + latestVersionLookupRequireDockerTypeSchemaDockerHub.extend({ + type: + defaultType === hubValue + ? z.literal([hubValue, nullString]) + : z.literal(hubValue), + }), latestVersionLookupRequireDockerTypeSchema.extend({ type: defaultType === quayValue @@ -214,12 +221,12 @@ export const buildDockerFilterSchemaWithFallbacks = ( typeof latestVersionLookupRequireDockerTypeSchemaDockerHub >; const argTyped = arg as DockerUsernameTyped; + const defaultsTyped = schemaDefaults as Partial; const hasUsername = !!( - argTyped.auth?.username || - (schemaDefaults as Partial).auth?.username + argTyped.auth?.username || defaultsTyped.auth?.username )?.trim(); const hasToken = !!( - arg.auth?.token || schemaDefaults?.auth?.token + argTyped.auth?.token || defaultsTyped?.auth?.token )?.trim(); // We must have a username and token, or neither. diff --git a/web/ui/react-app/src/utils/api/types/config-edit/service/types/latest-version.ts b/web/ui/react-app/src/utils/api/types/config-edit/service/types/latest-version.ts index bbda9cde..845f857a 100644 --- a/web/ui/react-app/src/utils/api/types/config-edit/service/types/latest-version.ts +++ b/web/ui/react-app/src/utils/api/types/config-edit/service/types/latest-version.ts @@ -117,6 +117,7 @@ export const urlCommandsSchemaOutgoing = z /* require.docker */ const dockerFilterSchemaBase = [ + LATEST_VERSION_LOOKUP__REQUIRE_DOCKER_TYPE.AMAZON_ECR.value, LATEST_VERSION_LOOKUP__REQUIRE_DOCKER_TYPE.DOCKER_HUB.value, LATEST_VERSION_LOOKUP__REQUIRE_DOCKER_TYPE.GHCR.value, LATEST_VERSION_LOOKUP__REQUIRE_DOCKER_TYPE.QUAY.value, @@ -161,8 +162,16 @@ export const latestVersionLookupRequireDockerTypeSchemaDockerHub = ), }); +export const latestVersionLookupRequireDockerTypeSchemaAmazonECR = + latestVersionLookupRequireDockerTypeSchemaBase.extend({ + type: z.literal( + LATEST_VERSION_LOOKUP__REQUIRE_DOCKER_TYPE.AMAZON_ECR.value, + ), + }); + export const dockerFilterSchema = z.discriminatedUnion('type', [ latestVersionLookupRequireDockerTypeSchema, + latestVersionLookupRequireDockerTypeSchemaAmazonECR, latestVersionLookupRequireDockerTypeSchemaDockerHub, ]); export type DockerTypeDockerHub = z.infer< @@ -188,6 +197,7 @@ export const dockerFilterSchemaDefaults = latestVersionLookupRequireDockerDefaultsSchemaBase.extend({ registry: z .object({ + ecr: latestVersionLookupRequireDockerDefaultsSchemaBase.partial(), ghcr: latestVersionLookupRequireDockerRegistryDefaultsSchema.partial(), hub: latestVersionLookupRequireDockerRegistryDefaultsSchemaDockerHub.partial(), quay: latestVersionLookupRequireDockerRegistryDefaultsSchema.partial(), diff --git a/web/ui/react-app/src/utils/api/types/config/service/latest-version.ts b/web/ui/react-app/src/utils/api/types/config/service/latest-version.ts index 1980417f..7f18ea9c 100644 --- a/web/ui/react-app/src/utils/api/types/config/service/latest-version.ts +++ b/web/ui/react-app/src/utils/api/types/config/service/latest-version.ts @@ -78,6 +78,7 @@ type FormURLCommandSplit = URLCommandSplit & { /* Require */ export const LATEST_VERSION_LOOKUP__REQUIRE_DOCKER_TYPE = { + AMAZON_ECR: { label: 'Amazon ECR Public Gallery', value: 'ecr' }, DOCKER_HUB: { label: 'Docker Hub', value: 'hub' }, GHCR: { label: 'GHCR', value: 'ghcr' }, QUAY: { label: 'Quay', value: 'quay' }, @@ -89,33 +90,48 @@ export const latestVersionRequireDockerTypeOptions = Object.values( LATEST_VERSION_LOOKUP__REQUIRE_DOCKER_TYPE, ); -type DockerFilterBase = { +// Fields shared by every docker registry filter. +type DockerFilterFields = { + image: string; + tag: string; +}; +// Amazon ECR Public Gallery — anonymous, no auth. +export type DockerFilterBase = DockerFilterFields & { + type: + | typeof LATEST_VERSION_LOOKUP__REQUIRE_DOCKER_TYPE.AMAZON_ECR.value + | null; +}; +// GHCR / Quay — token auth. +export type DockerFilterToken = DockerFilterFields & { type: | typeof LATEST_VERSION_LOOKUP__REQUIRE_DOCKER_TYPE.GHCR.value | typeof LATEST_VERSION_LOOKUP__REQUIRE_DOCKER_TYPE.QUAY.value | null; - image: string; - tag: string; auth: { - token: string; + token?: string; }; }; -export type DockerFilterUsername = DockerFilterBase & { +// Docker Hub — username + token auth. +export type DockerFilterUsernameToken = DockerFilterFields & { + type: + | typeof LATEST_VERSION_LOOKUP__REQUIRE_DOCKER_TYPE.DOCKER_HUB.value + | null; auth: { - type: - | typeof LATEST_VERSION_LOOKUP__REQUIRE_DOCKER_TYPE.DOCKER_HUB.value - | null; - username: string; + username?: string; + token?: string; }; }; -export type DockerFilterUsernameDefaults = Partial; export type DockerType = + | typeof LATEST_VERSION_LOOKUP__REQUIRE_DOCKER_TYPE.AMAZON_ECR.value + | typeof LATEST_VERSION_LOOKUP__REQUIRE_DOCKER_TYPE.DOCKER_HUB.value | typeof LATEST_VERSION_LOOKUP__REQUIRE_DOCKER_TYPE.GHCR.value | typeof LATEST_VERSION_LOOKUP__REQUIRE_DOCKER_TYPE.QUAY.value - | typeof LATEST_VERSION_LOOKUP__REQUIRE_DOCKER_TYPE.DOCKER_HUB.value | NullString; -export type DockerFilter = DockerFilterBase | DockerFilterUsername; +export type DockerFilter = + | DockerFilterBase + | DockerFilterToken + | DockerFilterUsernameToken; export type DockerRegistryDefaults = { auth?: { token?: string }; @@ -123,12 +139,15 @@ export type DockerRegistryDefaults = { export type DockerRegistryUsernameDefaults = { auth?: { token?: string; username?: string }; }; +// Amazon ECR Public Gallery is anonymous — no auth defaults. +export type DockerRegistryNoAuthDefaults = { auth?: never }; export type RequireDockerFilterDefaults = { type?: DockerFilterType; tag?: string; registry?: { + ecr?: DockerRegistryNoAuthDefaults; ghcr?: DockerRegistryDefaults; hub?: DockerRegistryUsernameDefaults; quay?: DockerRegistryDefaults; From 013a08764f73cdaadd0b574dfe625bd9c032096c Mon Sep 17 00:00:00 2001 From: Joseph Kavanagh Date: Wed, 1 Jul 2026 14:25:53 +0100 Subject: [PATCH 2/3] test: missing ecr coverage --- .../filter/docker/decode_test.go | 146 ++++++++++++++++++ .../filter/docker/defaults_test.go | 12 ++ .../filter/docker/interfaces.go | 4 +- .../filter/require_integration_test.go | 22 +++ service/latest_version/filter/test/main.go | 2 + .../latest_version/filter/test/main_test.go | 8 + 6 files changed, 192 insertions(+), 2 deletions(-) diff --git a/service/latest_version/filter/docker/decode_test.go b/service/latest_version/filter/docker/decode_test.go index 238bc4dd..1f70c119 100644 --- a/service/latest_version/filter/docker/decode_test.go +++ b/service/latest_version/filter/docker/decode_test.go @@ -257,6 +257,102 @@ func TestApplyOverrides(t *testing.T) { json: .*unmarshal.* number .*`, ), }, + { + name: "ecr -> ghcr", + format: "json", + data: test.TrimJSON(`{ + "type": "ghcr", + "image": "test/app-ghcr", + "tag": "{{ version }}" + }`), + previous: &ECRRegistry{ + CommonRegistry: CommonRegistry{ + Type: "ecr", + ContainerDetail: ContainerDetail{ + Image: "something", + Tag: "else", + }, + }, + }, + errRegex: `^$`, + want: test.TrimYAML(` + type: ghcr + image: test/app-ghcr + tag: '{{ version }}' + `), + }, + { + name: "ecr -> hub", + format: "json", + data: test.TrimJSON(`{ + "type": "hub", + "image": "test/app-hub", + "tag": "{{ version }}" + }`), + previous: &ECRRegistry{ + CommonRegistry: CommonRegistry{ + Type: "ecr", + ContainerDetail: ContainerDetail{ + Image: "something", + Tag: "else", + }, + }, + }, + errRegex: `^$`, + want: test.TrimYAML(` + type: hub + image: test/app-hub + tag: '{{ version }}' + `), + }, + { + name: "ecr -> quay", + format: "json", + data: test.TrimJSON(`{ + "type": "quay", + "image": "test/app-quay", + "tag": "{{ version }}" + }`), + previous: &ECRRegistry{ + CommonRegistry: CommonRegistry{ + Type: "ecr", + ContainerDetail: ContainerDetail{ + Image: "something", + Tag: "else", + }, + }, + }, + errRegex: `^$`, + want: test.TrimYAML(` + type: quay + image: test/app-quay + tag: '{{ version }}' + `), + }, + { + name: "ghcr -> ecr", + format: "json", + data: test.TrimJSON(`{ + "type": "ecr", + "image": "test/app-ecr", + "tag": "{{ version }}" + }`), + previous: &GHCRRegistry{ + CommonRegistry: CommonRegistry{ + Type: "ghcr", + ContainerDetail: ContainerDetail{ + Image: "something", + Tag: "else", + }, + }, + }, + errRegex: `^$`, + want: test.TrimYAML(` + type: ecr + image: test/app-ecr + tag: '{{ version }}' + `), + }, { name: "ghcr -> hub", format: "json", @@ -305,6 +401,30 @@ func TestApplyOverrides(t *testing.T) { tag: '{{ version }}' `), }, + { + name: "hub -> ecr", + format: "json", + data: test.TrimJSON(`{ + "type": "ecr", + "image": "test/app-ecr", + "tag": "{{ version }}" + }`), + previous: &HubRegistry{ + CommonRegistry: CommonRegistry{ + Type: "hub", + ContainerDetail: ContainerDetail{ + Image: "something", + Tag: "else", + }, + }, + }, + errRegex: `^$`, + want: test.TrimYAML(` + type: ecr + image: test/app-ecr + tag: '{{ version }}' + `), + }, { name: "hub -> ghcr", format: "json", @@ -347,6 +467,30 @@ func TestApplyOverrides(t *testing.T) { image: test/app-quay `), }, + { + name: "quay -> ecr", + format: "json", + data: test.TrimJSON(`{ + "type": "ecr", + "image": "test/app-ecr", + "tag": "{{ version }}" + }`), + previous: &QuayRegistry{ + CommonRegistry: CommonRegistry{ + Type: "quay", + ContainerDetail: ContainerDetail{ + Image: "something", + Tag: "else", + }, + }, + }, + errRegex: `^$`, + want: test.TrimYAML(` + type: ecr + image: test/app-ecr + tag: '{{ version }}' + `), + }, { name: "quay -> ghcr", format: "json", @@ -443,6 +587,8 @@ func TestApplyOverrides(t *testing.T) { if tc.previous != nil { switch v := tc.previous.(type) { + case *ECRRegistry: + v.Auth = RegistryMapInheritable["ecr"]().(Registry).GetAuth() case *GHCRRegistry: v.Auth = RegistryMapInheritable["ghcr"]().(Registry).GetAuth() case *HubRegistry: diff --git a/service/latest_version/filter/docker/defaults_test.go b/service/latest_version/filter/docker/defaults_test.go index bc102964..aa8440ee 100644 --- a/service/latest_version/filter/docker/defaults_test.go +++ b/service/latest_version/filter/docker/defaults_test.go @@ -1032,6 +1032,10 @@ func TestGetRegistryDefaults(t *testing.T) { name string dType string }{ + { + name: "known: ecr", + dType: "ecr", + }, { name: "known: ghcr", dType: "ghcr", @@ -1059,6 +1063,8 @@ func TestGetRegistryDefaults(t *testing.T) { var want RegistryDefaults switch tc.dType { + case "ecr": + want = defaults.Registry.ECR case "ghcr": want = defaults.Registry.GHCR case "hub": @@ -1092,6 +1098,10 @@ func TestGetRegistryDefaults_NilDefaults(t *testing.T) { name string dType string }{ + { + name: "known: ecr", + dType: "ecr", + }, { name: "known: ghcr", dType: "ghcr", @@ -1118,6 +1128,8 @@ func TestGetRegistryDefaults_NilDefaults(t *testing.T) { _, defaults := plainDefaults(t) switch tc.dType { + case "ecr": + defaults.Registry.ECR = nil case "ghcr": defaults.Registry.GHCR = nil case "hub": diff --git a/service/latest_version/filter/docker/interfaces.go b/service/latest_version/filter/docker/interfaces.go index 94f0fbd2..73c22693 100644 --- a/service/latest_version/filter/docker/interfaces.go +++ b/service/latest_version/filter/docker/interfaces.go @@ -67,7 +67,7 @@ type RegistryAuth interface { type RegistryDefaults interface { yaml.IsZeroer - // GetType returns the fixed registry kind (e.g. "hub", "ghcr", "quay"). + // GetType returns the fixed registry kind (e.g. "ecr", "ghcr", "hub", "quay"). GetType() string GetAuth() RegistryAuthDefaults @@ -103,7 +103,7 @@ type Registry interface { Inherit(from Registry) // GetTypeSelf returns the configured type field; GetType (from polymorphic.Inheritable) - // returns the fixed registry kind (e.g. "hub", "ghcr", "quay"). + // returns the fixed registry kind (e.g. "ecr", "ghcr", "hub", "quay"). GetTypeSelf() string GetImage() string GetImageSelf() string diff --git a/service/latest_version/filter/require_integration_test.go b/service/latest_version/filter/require_integration_test.go index 26e40777..561462e0 100644 --- a/service/latest_version/filter/require_integration_test.go +++ b/service/latest_version/filter/require_integration_test.go @@ -38,6 +38,28 @@ func TestRequire_DockerTagCheck(t *testing.T) { version string errRegex string }{ + { + name: "ECR/tag found", + yaml: test.TrimYAML(` + docker: + type: ecr + image: ` + test.ArgusDockerECRRepo + ` + tag: "{{ version }}" + `), + version: "latest", + errRegex: `^$`, + }, + { + name: "ECR/tag not found", + yaml: test.TrimYAML(` + docker: + type: ecr + image: ` + test.ArgusDockerECRRepo + ` + tag: "{{ version }}-unknown" + `), + version: "latest", + errRegex: `tag not found`, + }, { name: "GHCR/tag found", yaml: test.TrimYAML(` diff --git a/service/latest_version/filter/test/main.go b/service/latest_version/filter/test/main.go index a71ccb7d..5cce18fe 100644 --- a/service/latest_version/filter/test/main.go +++ b/service/latest_version/filter/test/main.go @@ -36,6 +36,8 @@ func Require(t *testing.T, dockerType string) *filter.Require { var image string switch dockerType { + case "ecr": + image = test.ArgusDockerECRRepo case "ghcr": image = test.ArgusDockerGHCRRepo case "hub": diff --git a/service/latest_version/filter/test/main_test.go b/service/latest_version/filter/test/main_test.go index a9c88d87..ed40d990 100644 --- a/service/latest_version/filter/test/main_test.go +++ b/service/latest_version/filter/test/main_test.go @@ -28,6 +28,7 @@ func TestRequire(t *testing.T) { tests := []struct { dockerType string }{ + {dockerType: "ecr"}, {dockerType: "ghcr"}, {dockerType: "hub"}, {dockerType: "quay"}, @@ -47,6 +48,13 @@ func TestRequire(t *testing.T) { // THEN: the expected Docker type is returned. switch tc.dockerType { + case "ecr": + if _, ok := got.Docker.(*docker.ECRRegistry); !ok { + t.Errorf( + "%s type mismatch\ngot: %t\nwant: %q", + prefix, got.Docker, "ECRRegistry", + ) + } case "ghcr": if _, ok := got.Docker.(*docker.GHCRRegistry); !ok { t.Errorf( From 8cb8778528e6dd3780a3e2981570fa9b6ec5eaf1 Mon Sep 17 00:00:00 2001 From: Joseph Kavanagh Date: Thu, 2 Jul 2026 00:40:01 +0100 Subject: [PATCH 3/3] refactor(docker): re-add ecr tag to RegistryDefaultsSet --- service/latest_version/filter/docker/defaults.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/latest_version/filter/docker/defaults.go b/service/latest_version/filter/docker/defaults.go index 5a2e435f..1e91021c 100644 --- a/service/latest_version/filter/docker/defaults.go +++ b/service/latest_version/filter/docker/defaults.go @@ -34,7 +34,7 @@ type Defaults struct { // RegistryDefaultsSet holds per-registry default configuration. type RegistryDefaultsSet struct { - ECR *ECRRegistryDefaults `json:"-" yaml:"-"` // Amazon ECR Public Gallery (anonymous: no serialisable config). + ECR *ECRRegistryDefaults `json:"ecr,omitzero" yaml:"ecr,omitzero"` // Amazon ECR Public Gallery (anonymous: no serialisable config). GHCR *GHCRRegistryDefaults `json:"ghcr,omitzero" yaml:"ghcr,omitzero"` // GitHub Container Registry. Hub *HubRegistryDefaults `json:"hub,omitzero" yaml:"hub,omitzero"` // Docker Hub. Quay *QuayRegistryDefaults `json:"quay,omitzero" yaml:"quay,omitzero"` // Quay.