diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 8d3051c86..782bd7e61 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -275,7 +275,7 @@ Paths the list already matches stay excluded whatever is later added there, so k ecsServicesRegexFlag = "[optional] The comma-separated list of ECS service name regex patterns to snapshot. Can't be used together with --exclude-services or --exclude-services-regex." ecsExcludeServicesFlag = "[optional] The comma-separated list of ECS service names to exclude. Can't be used together with --services or --services-regex." ecsExcludeServicesRegexFlag = "[optional] The comma-separated list of ECS service name regex patterns to exclude. Can't be used together with --services or --services-regex." - cloudRunResolveNamesFlag = "[optional] When set, resolve digest-pinned artifact names back to their deploy-time tags (commit SHA / version) via an Artifact Registry reverse-lookup. Requires roles/artifactregistry.reader. Default: artifacts keep whatever name the Cloud Run API returned (digest-pinned for Services, deploy-time form for Jobs)." + cloudRunResolveNamesFlag = "[optional] When set, resolve digest-pinned artifact names back to their deploy-time tags (commit SHA / version) via an Artifact Registry reverse-lookup. Requires the artifactregistry.dockerimages.get permission. Default: artifacts keep whatever name the Cloud Run API returned (digest-pinned for Services, deploy-time form for Jobs)." cloudRunIncludeFlag = "[optional] The comma-separated list of Cloud Run service or job names to snapshot. Can't be used together with --exclude or --exclude-regex." cloudRunIncludeRegexFlag = "[optional] The comma-separated list of Cloud Run service or job name regex patterns to snapshot. Can't be used together with --exclude or --exclude-regex." cloudRunExcludeFlag = "[optional] The comma-separated list of Cloud Run service or job names to exclude. Can't be used together with --include or --include-regex." diff --git a/cmd/kosli/snapshotCloudRun.go b/cmd/kosli/snapshotCloudRun.go index f591ace7b..72f2b70b5 100644 --- a/cmd/kosli/snapshotCloudRun.go +++ b/cmd/kosli/snapshotCloudRun.go @@ -34,14 +34,18 @@ Idle Jobs (no currently-running Execution) are included. GCP authentication uses Application Default Credentials. On a developer machine, run ^gcloud auth application-default login^; in GCE/GKE/Cloud Run -the metadata server / Workload Identity is used automatically. The caller -needs ^roles/run.viewer^ on the target project, plus -^roles/artifactregistry.reader^ on the Artifact Registry repository (or the -project) for digest and tag resolution on tag-pinned images. Missing the AR -role is non-fatal — tag-pinned artifacts then surface with empty digests. - -Digest and tag resolution is scoped to Artifact Registry (^*-docker.pkg.dev^) -and the legacy Container Registry (^*.gcr.io^). Images from other registries +the metadata server / Workload Identity is used automatically. + +The caller needs ^roles/run.viewer^ on the target project. To resolve the +digest of a tag-pinned image it also needs ^artifactregistry.tags.get^ on the +Artifact Registry repository (or the project), and ^--resolve-names^ needs +^artifactregistry.dockerimages.get^. Both are metadata reads: neither grants +permission to pull an image. ^roles/artifactregistry.reader^ also works. +Missing these permissions is non-fatal: tag-pinned artifacts then surface +with empty digests. + +Digest resolution covers Artifact Registry (^*-docker.pkg.dev^) and ^gcr.io^ +hosts, which Artifact Registry now serves. Images from other registries (Docker Hub, Quay, ECR, etc.) are reported as-is. Skip all filtering flags to report every service and every job in the given @@ -52,7 +56,8 @@ job names and are case-sensitive. Pass ^--resolve-names^ to rewrite digest-pinned Service artifact names back to their deploy-time tags (commit SHA / version) via an Artifact Registry -reverse-lookup. Only supported for Artifact Registry hosts.` +reverse-lookup. Supported for Artifact Registry (^*-docker.pkg.dev^) and +^gcr.io^ hosts.` const snapshotCloudRunExample = ` # report every Cloud Run service and job in a project + region: diff --git a/internal/cloudrun/cloudrun.go b/internal/cloudrun/cloudrun.go index 4bd6431e4..2a23c8523 100644 --- a/internal/cloudrun/cloudrun.go +++ b/internal/cloudrun/cloudrun.go @@ -58,8 +58,8 @@ type apiClient interface { // Client fetches Cloud Run data from GCP. The optional resolver, when set, // is consulted for any tag-pinned image whose digest cannot be parsed from -// the image string alone — it queries the OCI Distribution endpoint of the -// hosting registry to look up the current sha256. The optional tagResolver, +// the image string alone — it asks the hosting registry for the current +// sha256. The optional tagResolver, // when set (via the --resolve-names flag at construction time), is consulted // for any digest-pinned image to recover a tag pointing at the same digest, // so the artifact's display name surfaces the deploy-time tag (commit SHA diff --git a/internal/cloudrun/registry.go b/internal/cloudrun/registry.go index 6cbf2ca0c..22f97b1bf 100644 --- a/internal/cloudrun/registry.go +++ b/internal/cloudrun/registry.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "sort" "strings" @@ -15,11 +16,10 @@ import ( ) // digestResolver resolves a tag-pinned image reference (e.g. -// "europe-west1-docker.pkg.dev/proj/repo/img:v1") to its sha256 digest by -// querying the OCI Distribution endpoint of the hosting registry. Errors -// are non-fatal at the call site: a failed resolution leaves the original -// tag-pinned reference in place with an empty digest, mirroring the -// existing fallback for tag-pinned ECS / Service container images. +// "europe-west1-docker.pkg.dev/proj/repo/img:v1") to its sha256 digest. +// Errors are non-fatal at the call site: a failed resolution leaves the +// original tag-pinned reference in place with an empty digest, mirroring +// the existing fallback for tag-pinned ECS / Service container images. type digestResolver interface { // Resolve returns the bare hex digest for the given tag-pinned image // reference, or an error. Callers keep the original image string as @@ -28,6 +28,8 @@ type digestResolver interface { Resolve(image string) (hexDigest string, err error) } +const artifactRegistryAPI = "https://artifactregistry.googleapis.com" + // errNonGCPRegistry is returned by gcpRegistryResolver when the image's // host is not a known GCP registry. We don't attempt to authenticate // against unknown hosts because the GCP OAuth token only works against @@ -35,8 +37,12 @@ type digestResolver interface { var errNonGCPRegistry = errors.New("not a GCP registry host — skipping resolution") // gcpRegistryResolver implements digestResolver for Artifact Registry -// (*-docker.pkg.dev) and the legacy Container Registry (gcr.io family), -// using a GCP OAuth access token from Application Default Credentials. +// (*-docker.pkg.dev) and the gcr.io hosts it serves, using a GCP OAuth +// access token from Application Default Credentials. It asks the Artifact +// Registry API first, a metadata read needing artifactregistry.tags.get, +// and reads the manifest from the registry's OCI Distribution endpoint +// only when that fails: the manifest read needs +// artifactregistry.repositories.downloadArtifacts, which also pulls layers. // The HTTP client is built once in cloudrun.New() and shared across all // Resolve calls in a single snapshot — keeping the connection pool warm // so TCP/TLS handshakes amortize across the (potentially hundreds of) @@ -59,7 +65,13 @@ func (r *gcpRegistryResolver) Resolve(image string) (string, error) { if err != nil { return "", fmt.Errorf("getting GCP access token: %w", err) } - hex, err := digest.RemoteDockerImageSha256(r.client, path, tag, "https://"+host+"/v2", tok.AccessToken) + hex, apiErr := r.digestFromTag(host, path, tag, tok.AccessToken) + if apiErr == nil { + return hex, nil + } + r.log.Debug("Artifact Registry API lookup failed for %q, reading the manifest instead: %v", image, apiErr) + + hex, err = digest.RemoteDockerImageSha256(r.client, path, tag, "https://"+host+"/v2", tok.AccessToken) if err != nil { return "", err } @@ -69,6 +81,41 @@ func (r *gcpRegistryResolver) Resolve(image string) (string, error) { return hex, nil } +// digestFromTag reads the digest a tag points at from the Artifact +// Registry API's tags.get. +func (r *gcpRegistryResolver) digestFromTag(host, path, tag, accessToken string) (string, error) { + img, err := artifactRegistryRepoFor(host, path) + if err != nil { + return "", err + } + // The package is a single path segment, so a "/" in the image path + // has to be "%2F". + u := fmt.Sprintf( + "%s/v1/projects/%s/locations/%s/repositories/%s/packages/%s/tags/%s", + artifactRegistryAPI, img.project, img.location, img.repo, url.PathEscape(img.image), url.PathEscape(tag), + ) + res, err := r.client.Do(&requests.RequestParams{ + Method: http.MethodGet, + URL: u, + Token: accessToken, + }) + if err != nil { + return "", err + } + var resp struct { + Version string `json:"version"` + } + if err := json.Unmarshal([]byte(res.Body), &resp); err != nil { + return "", fmt.Errorf("parsing Artifact Registry tag response: %w", err) + } + // Version is ".../packages//versions/sha256:". + hex := strings.TrimPrefix(resp.Version[strings.LastIndex(resp.Version, "/")+1:], "sha256:") + if err := digest.ValidateDigest(hex); err != nil { + return "", fmt.Errorf("tag version %q carries no sha256 digest", resp.Version) + } + return hex, nil +} + // splitTagPinnedImage parses a tag-pinned image reference of the form // // host/path/segments...:tag @@ -115,6 +162,50 @@ func isGCPRegistryHost(host string) bool { return false } +// arImage names an image by the Artifact Registry resources that hold it. +type arImage struct { + project string + location string + repo string + image string +} + +// gcrLocations maps each Container Registry host to the location of the +// Artifact Registry repository that serves it. The repository is named +// after the host. +var gcrLocations = map[string]string{ + "gcr.io": "us", + "us.gcr.io": "us", + "eu.gcr.io": "europe", + "asia.gcr.io": "asia", +} + +// artifactRegistryRepoFor maps a registry host and image path to the +// Artifact Registry resources behind them. Hosts the Artifact Registry +// API does not serve are rejected. +func artifactRegistryRepoFor(host, path string) (arImage, error) { + var img arImage + if location, ok := gcrLocations[host]; ok { + parts := strings.SplitN(path, "/", 2) + if len(parts) < 2 { + return arImage{}, fmt.Errorf("image path %q has too few segments for %s", path, host) + } + img = arImage{project: parts[0], location: location, repo: host, image: parts[1]} + } else if location := strings.TrimSuffix(host, "-docker.pkg.dev"); location != host && location != "" { + parts := strings.SplitN(path, "/", 3) + if len(parts) < 3 { + return arImage{}, fmt.Errorf("image path %q has too few segments for Artifact Registry", path) + } + img = arImage{project: parts[0], location: location, repo: parts[1], image: parts[2]} + } else { + return arImage{}, fmt.Errorf("host %q is not served by the Artifact Registry API", host) + } + if img.project == "" || img.repo == "" || img.image == "" { + return arImage{}, fmt.Errorf("image path %q has an empty segment", path) + } + return img, nil +} + // tagResolver resolves a digest-pinned image reference (e.g. // "europe-west1-docker.pkg.dev/proj/repo/img@sha256:") to a tag // pointing at that digest, by querying Artifact Registry's @@ -130,11 +221,10 @@ type tagResolver interface { } // gcpArtifactRegistryTagResolver implements tagResolver against -// Artifact Registry (*-docker.pkg.dev). The legacy Container Registry -// hosts (gcr.io family) do not expose an equivalent reverse-lookup -// API, so they are rejected with errNonGCPRegistry. The HTTP client -// is shared with the forward (gcpRegistryResolver) path so a single -// connection pool serves every per-artifact API call in the snapshot. +// Artifact Registry (*-docker.pkg.dev) and the gcr.io hosts it serves. +// The HTTP client is shared with the forward (gcpRegistryResolver) path +// so a single connection pool serves every per-artifact API call in the +// snapshot. type gcpArtifactRegistryTagResolver struct { tokens oauth2.TokenSource client *requests.Client @@ -154,10 +244,11 @@ func (r *gcpArtifactRegistryTagResolver) LatestTag(image string) (string, error) // dockerImages.get returns a single DockerImage by exact resource name. // The resource name is "@sha256:" within the repo; "@" and // ":" are allowed in URL path segments per RFC 3986 and Google's - // gateway accepts them unescaped. + // gateway accepts them unescaped. A "/" in the image path has to be + // "%2F": the resource name is a single path segment. u := fmt.Sprintf( - "https://artifactregistry.googleapis.com/v1/projects/%s/locations/%s/repositories/%s/dockerImages/%s@%s", - parts.project, parts.location, parts.repo, parts.image, parts.digest, + "%s/v1/projects/%s/locations/%s/repositories/%s/dockerImages/%s@%s", + artifactRegistryAPI, parts.project, parts.location, parts.repo, url.PathEscape(parts.image), parts.digest, ) res, err := r.client.Do(&requests.RequestParams{ @@ -211,34 +302,25 @@ type digestPinnedRefParts struct { digest string // "sha256:" } -// splitDigestPinnedAR parses a digest-pinned Artifact Registry image -// reference into its constituent parts. Rejects non-AR hosts (including -// gcr.io, since the legacy GCR API does not expose tag reverse-lookup), -// non-digest-pinned references, and references with too few path -// segments. +// splitDigestPinnedAR parses a digest-pinned image reference into the +// Artifact Registry resources that hold it. Rejects hosts the Artifact +// Registry API does not serve, non-digest-pinned references, and +// references with too few path segments. func splitDigestPinnedAR(image string) (digestPinnedRefParts, error) { idx := strings.Index(image, sha256Marker) if idx < 0 { return digestPinnedRefParts{}, fmt.Errorf("image %q is not digest-pinned", image) } - ref := image[:idx] - digestPart := image[idx+1:] // strip "@" → "sha256:" - - parts := strings.SplitN(ref, "/", 4) - if len(parts) < 4 { - return digestPinnedRefParts{}, fmt.Errorf("image %q has too few path segments for AR", image) - } - host, project, repo, imagePath := parts[0], parts[1], parts[2], parts[3] - - location := strings.TrimSuffix(host, "-docker.pkg.dev") - if location == host { - return digestPinnedRefParts{}, fmt.Errorf("host %q is not Artifact Registry (tag resolution unsupported)", host) + host, path, _ := strings.Cut(image[:idx], "/") + img, err := artifactRegistryRepoFor(host, path) + if err != nil { + return digestPinnedRefParts{}, err } return digestPinnedRefParts{ - project: project, - location: location, - repo: repo, - image: imagePath, - digest: digestPart, + project: img.project, + location: img.location, + repo: img.repo, + image: img.image, + digest: image[idx+1:], // strip "@" → "sha256:" }, nil } diff --git a/internal/cloudrun/registry_http_test.go b/internal/cloudrun/registry_http_test.go new file mode 100644 index 000000000..5a9311eb3 --- /dev/null +++ b/internal/cloudrun/registry_http_test.go @@ -0,0 +1,213 @@ +package cloudrun + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/kosli-dev/cli/internal/requests" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" +) + +// fakeRegistry answers HTTP requests by host + escaped path, recording each +// one, so resolver tests can assert the exact URL sent to Google. +type fakeRegistry struct { + responses map[string]fakeResponse + requested []string +} + +type fakeResponse struct { + status int + body string + header http.Header +} + +func (f *fakeRegistry) RoundTrip(req *http.Request) (*http.Response, error) { + key := req.URL.Host + req.URL.EscapedPath() + f.requested = append(f.requested, key) + res, ok := f.responses[key] + if !ok { + res = fakeResponse{status: http.StatusNotFound, body: `{"error":{"code":404}}`} + } + header := res.header + if header == nil { + header = http.Header{} + } + return &http.Response{ + StatusCode: res.status, + Header: header, + Body: io.NopCloser(strings.NewReader(res.body)), + Request: req, + }, nil +} + +func newFakeRegistryClient(t *testing.T, registry *fakeRegistry) *requests.Client { + return &requests.Client{ + Logger: newDiscardLogger(t), + HttpClient: &http.Client{Transport: registry}, + } +} + +func staticToken() oauth2.TokenSource { + return oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "test-token"}) +} + +const testHex = "0b2fd168ee792a7b0c9d6f48a21f83a2ed6a4eaff9ce6cf49abec4a9e12ad6d7" + +func TestLatestTag_EscapesNestedImagePath(t *testing.T) { + registry := &fakeRegistry{responses: map[string]fakeResponse{ + "artifactregistry.googleapis.com/v1/projects/proj/locations/europe-west1/repositories/repo/dockerImages/parent%2Fchild%2Fimg@sha256:" + testHex: { + status: http.StatusOK, body: `{"tags":["v1"]}`, + }, + }} + r := &gcpArtifactRegistryTagResolver{tokens: staticToken(), client: newFakeRegistryClient(t, registry), log: newDiscardLogger(t)} + + tag, err := r.LatestTag("europe-west1-docker.pkg.dev/proj/repo/parent/child/img@sha256:" + testHex) + + require.NoError(t, err) + require.Equal(t, "v1", tag) +} + +func newTestDigestResolver(t *testing.T, registry *fakeRegistry) *gcpRegistryResolver { + return &gcpRegistryResolver{tokens: staticToken(), client: newFakeRegistryClient(t, registry), log: newDiscardLogger(t)} +} + +func tagResponse(tagName string) fakeResponse { + versions := strings.Replace(tagName[:strings.LastIndex(tagName, "/tags/")], "artifactregistry.googleapis.com/v1/", "", 1) + return fakeResponse{ + status: http.StatusOK, + body: `{"version":"` + versions + `/versions/sha256:` + testHex + `"}`, + } +} + +func manifestResponse() fakeResponse { + return fakeResponse{ + status: http.StatusOK, + body: `{}`, + header: http.Header{"Docker-Content-Digest": []string{"sha256:" + testHex}}, + } +} + +func TestResolve_ReadsDigestFromArtifactRegistryTag(t *testing.T) { + cases := []struct { + name string + image string + tag string + }{ + {"pkg.dev", "europe-west1-docker.pkg.dev/proj/repo/img:v1", + "artifactregistry.googleapis.com/v1/projects/proj/locations/europe-west1/repositories/repo/packages/img/tags/v1"}, + {"pkg.dev nested image", "europe-west1-docker.pkg.dev/proj/repo/parent/child/img:v1", + "artifactregistry.googleapis.com/v1/projects/proj/locations/europe-west1/repositories/repo/packages/parent%2Fchild%2Fimg/tags/v1"}, + {"gcr.io", "eu.gcr.io/proj/team/img:v1", + "artifactregistry.googleapis.com/v1/projects/proj/locations/europe/repositories/eu.gcr.io/packages/team%2Fimg/tags/v1"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + registry := &fakeRegistry{responses: map[string]fakeResponse{c.tag: tagResponse(c.tag)}} + + hex, err := newTestDigestResolver(t, registry).Resolve(c.image) + + require.NoError(t, err) + require.Equal(t, testHex, hex) + require.Equal(t, []string{c.tag}, registry.requested) + }) + } +} + +func TestResolve_FallsBackToManifestRead(t *testing.T) { + const image = "europe-west1-docker.pkg.dev/proj/repo/img:v1" + const tag = "artifactregistry.googleapis.com/v1/projects/proj/locations/europe-west1/repositories/repo/packages/img/tags/v1" + const manifest = "europe-west1-docker.pkg.dev/v2/proj/repo/img/manifests/v1" + + cases := []struct { + name string + tagResponse fakeResponse + }{ + {"permission denied", fakeResponse{status: http.StatusForbidden, body: `{"error":{"code":403}}`}}, + {"not a sha256 version", fakeResponse{status: http.StatusOK, body: `{"version":"projects/proj/locations/europe-west1/repositories/repo/packages/img/versions/1.0"}`}}, + {"unparseable body", fakeResponse{status: http.StatusOK, body: `not json`}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + registry := &fakeRegistry{responses: map[string]fakeResponse{ + tag: c.tagResponse, + manifest: manifestResponse(), + }} + + hex, err := newTestDigestResolver(t, registry).Resolve(image) + + require.NoError(t, err) + require.Equal(t, testHex, hex) + require.Equal(t, []string{tag, manifest}, registry.requested) + }) + } +} + +func TestResolve_HostOutsideArtifactRegistryAPIReadsManifest(t *testing.T) { + const manifest = "docker.pkg.dev/v2/proj/repo/img/manifests/v1" + registry := &fakeRegistry{responses: map[string]fakeResponse{manifest: manifestResponse()}} + + hex, err := newTestDigestResolver(t, registry).Resolve("docker.pkg.dev/proj/repo/img:v1") + + require.NoError(t, err) + require.Equal(t, testHex, hex) + require.Equal(t, []string{manifest}, registry.requested) +} + +func TestResolve_BothLookupsFail(t *testing.T) { + registry := &fakeRegistry{} + + _, err := newTestDigestResolver(t, registry).Resolve("europe-west1-docker.pkg.dev/proj/repo/img:v1") + + require.Error(t, err) + require.Len(t, registry.requested, 2) +} + +func TestResolve_NonGCPRegistrySendsNoRequest(t *testing.T) { + registry := &fakeRegistry{} + + _, err := newTestDigestResolver(t, registry).Resolve("docker.io/library/nginx:1.27") + + require.ErrorIs(t, err, errNonGCPRegistry) + require.Empty(t, registry.requested) +} + +func TestLatestTag_GCRHost(t *testing.T) { + registry := &fakeRegistry{responses: map[string]fakeResponse{ + "artifactregistry.googleapis.com/v1/projects/proj/locations/europe/repositories/eu.gcr.io/dockerImages/team%2Fimg@sha256:" + testHex: { + status: http.StatusOK, body: `{"tags":["v1"]}`, + }, + }} + r := &gcpArtifactRegistryTagResolver{tokens: staticToken(), client: newFakeRegistryClient(t, registry), log: newDiscardLogger(t)} + + tag, err := r.LatestTag("eu.gcr.io/proj/team/img@sha256:" + testHex) + + require.NoError(t, err) + require.Equal(t, "v1", tag) +} + +func TestLatestTag_PlainImagePathIsSentUnescaped(t *testing.T) { + registry := &fakeRegistry{responses: map[string]fakeResponse{ + "artifactregistry.googleapis.com/v1/projects/proj/locations/europe-west1/repositories/repo/dockerImages/hello-world_v2.app@sha256:" + testHex: { + status: http.StatusOK, body: `{"tags":["v1"]}`, + }, + }} + r := &gcpArtifactRegistryTagResolver{tokens: staticToken(), client: newFakeRegistryClient(t, registry), log: newDiscardLogger(t)} + + tag, err := r.LatestTag("europe-west1-docker.pkg.dev/proj/repo/hello-world_v2.app@sha256:" + testHex) + + require.NoError(t, err) + require.Equal(t, "v1", tag) +} + +func TestLatestTag_NonGCPRegistrySendsNoRequest(t *testing.T) { + registry := &fakeRegistry{} + r := &gcpArtifactRegistryTagResolver{tokens: staticToken(), client: newFakeRegistryClient(t, registry), log: newDiscardLogger(t)} + + _, err := r.LatestTag("docker.io/library/nginx@sha256:" + testHex) + + require.Error(t, err) + require.Empty(t, registry.requested) +} diff --git a/internal/cloudrun/registry_test.go b/internal/cloudrun/registry_test.go index dd70fe16a..87a45d873 100644 --- a/internal/cloudrun/registry_test.go +++ b/internal/cloudrun/registry_test.go @@ -193,19 +193,46 @@ func TestSplitDigestPinnedAR_TagPinnedRejected(t *testing.T) { require.Contains(t, err.Error(), "not digest-pinned") } +func TestSplitDigestPinnedAR_GCRHosts(t *testing.T) { + cases := []struct { + host string + location string + }{ + {"gcr.io", "us"}, + {"us.gcr.io", "us"}, + {"eu.gcr.io", "europe"}, + {"asia.gcr.io", "asia"}, + } + for _, c := range cases { + t.Run(c.host, func(t *testing.T) { + parts, err := splitDigestPinnedAR(c.host + "/proj/team/img@sha256:abc") + require.NoError(t, err) + require.Equal(t, digestPinnedRefParts{ + project: "proj", location: c.location, repo: c.host, image: "team/img", digest: "sha256:abc", + }, parts) + }) + } +} + func TestSplitDigestPinnedAR_NonARHostRejected(t *testing.T) { - // 4 path segments so the parser passes the segment-count check and - // reaches the host check. Refs from GCR or other registries do not - // share AR's region-prefixed pkg.dev host pattern. - _, err := splitDigestPinnedAR("eu.gcr.io/proj/repo/img@sha256:abc") + _, err := splitDigestPinnedAR("docker.io/proj/repo/img@sha256:abc") require.Error(t, err) - require.Contains(t, err.Error(), "not Artifact Registry") + require.Contains(t, err.Error(), "not served by the Artifact Registry API") } func TestSplitDigestPinnedAR_TooFewSegmentsRejected(t *testing.T) { - _, err := splitDigestPinnedAR("europe-west1-docker.pkg.dev/proj@sha256:abc") - require.Error(t, err) - require.Contains(t, err.Error(), "too few path segments") + for _, image := range []string{ + "europe-west1-docker.pkg.dev/proj@sha256:abc", + "europe-west1-docker.pkg.dev/proj/repo@sha256:abc", + "gcr.io/proj@sha256:abc", + "gcr.io@sha256:abc", + } { + t.Run(image, func(t *testing.T) { + _, err := splitDigestPinnedAR(image) + require.Error(t, err) + require.Contains(t, err.Error(), "too few segments") + }) + } } // --- pickLatestTag --- @@ -319,3 +346,59 @@ func TestResolveTagPinnedDigests_MixedMap(t *testing.T) { tagPinned: "newhex", }, digests, "both keys must be preserved; only the tag-pinned value is filled in") } + +// --- artifactRegistryRepoFor --- + +func TestArtifactRegistryRepoFor(t *testing.T) { + cases := []struct { + name string + host string + path string + want arImage + }{ + {"pkg.dev", "europe-west1-docker.pkg.dev", "proj/repo/img", + arImage{project: "proj", location: "europe-west1", repo: "repo", image: "img"}}, + {"pkg.dev multi-region", "us-docker.pkg.dev", "proj/repo/img", + arImage{project: "proj", location: "us", repo: "repo", image: "img"}}, + {"pkg.dev nested image", "europe-west1-docker.pkg.dev", "proj/repo/parent/child/img", + arImage{project: "proj", location: "europe-west1", repo: "repo", image: "parent/child/img"}}, + {"gcr.io", "gcr.io", "proj/img", + arImage{project: "proj", location: "us", repo: "gcr.io", image: "img"}}, + {"us.gcr.io", "us.gcr.io", "proj/img", + arImage{project: "proj", location: "us", repo: "us.gcr.io", image: "img"}}, + {"eu.gcr.io", "eu.gcr.io", "proj/img", + arImage{project: "proj", location: "europe", repo: "eu.gcr.io", image: "img"}}, + {"asia.gcr.io", "asia.gcr.io", "proj/img", + arImage{project: "proj", location: "asia", repo: "asia.gcr.io", image: "img"}}, + {"gcr.io nested image", "gcr.io", "proj/team/img", + arImage{project: "proj", location: "us", repo: "gcr.io", image: "team/img"}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := artifactRegistryRepoFor(c.host, c.path) + require.NoError(t, err) + require.Equal(t, c.want, got) + }) + } +} + +func TestArtifactRegistryRepoFor_Rejected(t *testing.T) { + cases := []struct { + name string + host string + path string + }{ + {"unknown host", "docker.io", "library/nginx"}, + {"unknown gcr.io mirror", "mirror.gcr.io", "library/nginx"}, + {"pkg.dev without location", "docker.pkg.dev", "proj/repo/img"}, + {"pkg.dev without image", "europe-west1-docker.pkg.dev", "proj/repo"}, + {"gcr.io without image", "gcr.io", "proj"}, + {"empty segment", "gcr.io", "proj/"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := artifactRegistryRepoFor(c.host, c.path) + require.Error(t, err) + }) + } +}