From 7934a1924209e41e790964e9d1af53b671878533 Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Fri, 13 Nov 2026 22:32:06 +0000 Subject: [PATCH 1/4] fix: escape nested image paths in cloud-run --resolve-names dockerImages.get takes the image as a single path segment, so an image such as repo/team/app@sha256:... answered 404 and kept its digest-pinned name. The slashes in the image path are now sent as %2F. --- internal/cloudrun/registry.go | 6 ++- internal/cloudrun/registry_http_test.go | 71 +++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 internal/cloudrun/registry_http_test.go diff --git a/internal/cloudrun/registry.go b/internal/cloudrun/registry.go index 6cbf2ca0c..3c05037e6 100644 --- a/internal/cloudrun/registry.go +++ b/internal/cloudrun/registry.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "sort" "strings" @@ -154,10 +155,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, + parts.project, parts.location, parts.repo, url.PathEscape(parts.image), parts.digest, ) res, err := r.client.Do(&requests.RequestParams{ diff --git a/internal/cloudrun/registry_http_test.go b/internal/cloudrun/registry_http_test.go new file mode 100644 index 000000000..16db3263e --- /dev/null +++ b/internal/cloudrun/registry_http_test.go @@ -0,0 +1,71 @@ +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) +} From c0a2b1f5d1b6fab43d6ad190e38f69d93717b51b Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Fri, 13 Nov 2026 22:32:08 +0000 Subject: [PATCH 2/4] feat: resolve tag-pinned cloud-run digests through the Artifact Registry API kosli snapshot cloud-run read a tag-pinned image's manifest from the registry's OCI Distribution endpoint, which Artifact Registry authorises with the same permission that pulls image layers. An inventory tool then needs a grant that can pull every image in the project. The digest now comes from the Artifact Registry API's tags.get, a metadata read, for *-docker.pkg.dev images and for the gcr.io hosts that Artifact Registry serves. The manifest read stays as the fallback when the API lookup fails, so callers holding only the pull permission keep their digests. --- internal/cloudrun/cloudrun.go | 4 +- internal/cloudrun/registry.go | 110 +++++++++++++++++++++--- internal/cloudrun/registry_http_test.go | 104 ++++++++++++++++++++++ internal/cloudrun/registry_test.go | 56 ++++++++++++ 4 files changed, 262 insertions(+), 12 deletions(-) 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 3c05037e6..f50beb3a2 100644 --- a/internal/cloudrun/registry.go +++ b/internal/cloudrun/registry.go @@ -16,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 @@ -29,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 @@ -36,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) @@ -60,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 } @@ -70,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 @@ -116,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 @@ -158,8 +248,8 @@ func (r *gcpArtifactRegistryTagResolver) LatestTag(image string) (string, error) // 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, url.PathEscape(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{ diff --git a/internal/cloudrun/registry_http_test.go b/internal/cloudrun/registry_http_test.go index 16db3263e..40c40a363 100644 --- a/internal/cloudrun/registry_http_test.go +++ b/internal/cloudrun/registry_http_test.go @@ -69,3 +69,107 @@ func TestLatestTag_EscapesNestedImagePath(t *testing.T) { 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) +} diff --git a/internal/cloudrun/registry_test.go b/internal/cloudrun/registry_test.go index dd70fe16a..99bdeeed7 100644 --- a/internal/cloudrun/registry_test.go +++ b/internal/cloudrun/registry_test.go @@ -319,3 +319,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) + }) + } +} From 4042a0c976cb3306e4b709e2cc47a5ff7978a207 Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Fri, 13 Nov 2026 22:32:09 +0000 Subject: [PATCH 3/4] docs: name the metadata permissions cloud-run digest resolution needs The help asked for roles/artifactregistry.reader, which can pull every image in the repository. Digest and tag resolution now go through metadata reads, so the help names the two permissions they need and says that gcr.io hosts are covered as well. --- cmd/kosli/root.go | 2 +- cmd/kosli/snapshotCloudRun.go | 20 ++++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) 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..2b969f908 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 From 625e84b619bd62fda76e593f90ed04a2e857b72f Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Fri, 13 Nov 2026 22:32:09 +0000 Subject: [PATCH 4/4] feat: resolve cloud-run artifact names for gcr.io images with --resolve-names --resolve-names skipped digest-pinned images on the gcr.io hosts because Container Registry had no reverse lookup. Artifact Registry serves those hosts now, through repositories named after the host, and its dockerImages.get answers for them. The digest-pinned parser shares the host mapping the digest lookup uses, so both directions cover the same hosts. --- cmd/kosli/snapshotCloudRun.go | 3 +- internal/cloudrun/registry.go | 44 ++++++++++--------------- internal/cloudrun/registry_http_test.go | 38 +++++++++++++++++++++ internal/cloudrun/registry_test.go | 43 +++++++++++++++++++----- 4 files changed, 92 insertions(+), 36 deletions(-) diff --git a/cmd/kosli/snapshotCloudRun.go b/cmd/kosli/snapshotCloudRun.go index 2b969f908..72f2b70b5 100644 --- a/cmd/kosli/snapshotCloudRun.go +++ b/cmd/kosli/snapshotCloudRun.go @@ -56,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/registry.go b/internal/cloudrun/registry.go index f50beb3a2..22f97b1bf 100644 --- a/internal/cloudrun/registry.go +++ b/internal/cloudrun/registry.go @@ -221,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 @@ -303,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 index 40c40a363..5a9311eb3 100644 --- a/internal/cloudrun/registry_http_test.go +++ b/internal/cloudrun/registry_http_test.go @@ -173,3 +173,41 @@ func TestResolve_NonGCPRegistrySendsNoRequest(t *testing.T) { 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 99bdeeed7..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 ---