From e63822cfa4a54711f442056016ade949ea78e443 Mon Sep 17 00:00:00 2001 From: Tore Martin Hagen Date: Tue, 28 Apr 2026 11:43:00 +0200 Subject: [PATCH 1/9] feat(snapshot): add hidden cloud-run skeleton command (#4986) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registers `kosli snapshot cloud-run ENVIRONMENT-NAME` as a hidden, in-development command for Google Cloud Run. The command takes required `--project` and `--region` flags, forces dry-run mode, and prints a placeholder line — no GCP API calls or HTTP requests yet. This is the first slice of the Cloud Run feature; subsequent slices will add the GCP client wrapper, the end-to-end happy path, filtering, auth UX, and finally unhide the command. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/kosli/snapshot.go | 1 + cmd/kosli/snapshotCloudRun.go | 53 +++++++++++++++ cmd/kosli/snapshotCloudRun_test.go | 64 +++++++++++++++++++ .../2026-04-28-4986-google-cloud-run-1.md | 47 ++++++++++++++ 4 files changed, 165 insertions(+) create mode 100644 cmd/kosli/snapshotCloudRun.go create mode 100644 cmd/kosli/snapshotCloudRun_test.go create mode 100644 docs/handover/2026-04-28-4986-google-cloud-run-1.md diff --git a/cmd/kosli/snapshot.go b/cmd/kosli/snapshot.go index cd487f42e..4b43c483c 100644 --- a/cmd/kosli/snapshot.go +++ b/cmd/kosli/snapshot.go @@ -26,6 +26,7 @@ func newSnapshotCmd(out io.Writer) *cobra.Command { newSnapshotAzureAppsCmd(out), newSnapshotPathsCmd(out), newSnapshotPathCmd(out), + newSnapshotCloudRunCmd(out), ) return cmd diff --git a/cmd/kosli/snapshotCloudRun.go b/cmd/kosli/snapshotCloudRun.go new file mode 100644 index 000000000..871ea734b --- /dev/null +++ b/cmd/kosli/snapshotCloudRun.go @@ -0,0 +1,53 @@ +package main + +import ( + "fmt" + "io" + + "github.com/spf13/cobra" +) + +const snapshotCloudRunShortDesc = `Report a snapshot of running services in a Google Cloud Run project and region to Kosli. ` +const snapshotCloudRunLongDesc = snapshotCloudRunShortDesc + ` +Currently a hidden, in-development command — it always runs in dry-run mode and does not yet talk to GCP or to Kosli.` + +type snapshotCloudRunOptions struct { + project string + region string +} + +func newSnapshotCloudRunCmd(out io.Writer) *cobra.Command { + o := new(snapshotCloudRunOptions) + cmd := &cobra.Command{ + Use: "cloud-run ENVIRONMENT-NAME", + Short: snapshotCloudRunShortDesc, + Long: snapshotCloudRunLongDesc, + Hidden: true, + Args: cobra.ExactArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + if err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}); err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + global.DryRun = true + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(out, args) + }, + } + + cmd.Flags().StringVar(&o.project, "project", "", "[required] GCP project ID.") + cmd.Flags().StringVar(&o.region, "region", "", "[required] GCP region (e.g. europe-west1).") + addDryRunFlag(cmd) + + if err := RequireFlags(cmd, []string{"project", "region"}); err != nil { + logger.Error("failed to configure required flags: %v", err) + } + + return cmd +} + +func (o *snapshotCloudRunOptions) run(out io.Writer, args []string) error { + _, err := fmt.Fprintln(out, "cloud-run snapshot: not yet implemented (forced dry-run)") + return err +} diff --git a/cmd/kosli/snapshotCloudRun_test.go b/cmd/kosli/snapshotCloudRun_test.go new file mode 100644 index 000000000..9555d838e --- /dev/null +++ b/cmd/kosli/snapshotCloudRun_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/suite" +) + +type SnapshotCloudRunTestSuite struct { + suite.Suite + defaultKosliArguments string + envName string +} + +func (suite *SnapshotCloudRunTestSuite) SetupTest() { + suite.envName = "snapshot-cloud-run-env" + global = &GlobalOpts{ + ApiToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImNkNzg4OTg5In0.e8i_lA_QrEhFncb05Xw6E_tkCHU9QfcY4OLTVUCHffY", + Org: "docs-cmd-test-user", + Host: "http://localhost:8001", + } + suite.defaultKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken) +} + +func (suite *SnapshotCloudRunTestSuite) TestSnapshotCloudRunCmd() { + tests := []cmdTestCase{ + { + wantError: true, + name: "snapshot cloud-run fails if no args are provided", + cmd: fmt.Sprintf(`snapshot cloud-run --project p --region r %s`, suite.defaultKosliArguments), + golden: "Error: accepts 1 arg(s), received 0\n", + }, + { + wantError: true, + name: "snapshot cloud-run fails if 2 args are provided", + cmd: fmt.Sprintf(`snapshot cloud-run %s xxx --project p --region r %s`, suite.envName, suite.defaultKosliArguments), + golden: "Error: accepts 1 arg(s), received 2\n", + }, + { + wantError: true, + name: "snapshot cloud-run fails if --project is missing", + cmd: fmt.Sprintf(`snapshot cloud-run %s --region r %s`, suite.envName, suite.defaultKosliArguments), + golden: "Error: required flag(s) \"project\" not set\n", + }, + { + wantError: true, + name: "snapshot cloud-run fails if --region is missing", + cmd: fmt.Sprintf(`snapshot cloud-run %s --project p %s`, suite.envName, suite.defaultKosliArguments), + golden: "Error: required flag(s) \"region\" not set\n", + }, + { + name: "snapshot cloud-run succeeds with required args and prints the placeholder", + cmd: fmt.Sprintf(`snapshot cloud-run %s --project p --region r %s`, suite.envName, suite.defaultKosliArguments), + golden: "cloud-run snapshot: not yet implemented (forced dry-run)\n", + }, + } + + runTestCmd(suite.T(), tests) +} + +func TestSnapshotCloudRunCommandTestSuite(t *testing.T) { + suite.Run(t, new(SnapshotCloudRunTestSuite)) +} diff --git a/docs/handover/2026-04-28-4986-google-cloud-run-1.md b/docs/handover/2026-04-28-4986-google-cloud-run-1.md new file mode 100644 index 000000000..f5a543b63 --- /dev/null +++ b/docs/handover/2026-04-28-4986-google-cloud-run-1.md @@ -0,0 +1,47 @@ +# Handover: 4986-google-cloud-run-1 + +> **Last updated:** 2026-04-28 +> **Branch:** `4986-google-cloud-run-1` +> **Ticket:** https://github.com/kosli-dev/server/issues/4986 +> **Collaborators:** Tore Martin Hagen (engineer), Claude (claude-opus-4-7) + +--- + +## Problem Definition + +Add Google Cloud Run as a runtime environment type for `kosli snapshot`, mirroring the existing `kosli snapshot ecs` pattern. Customers deploying on GCP Cloud Run cannot currently use `kosli snapshot` to report what's running in their environments. + +The first branch (`-1`) covers the CLI skeleton only — registering `kosli snapshot cloud-run` as a hidden command with arg/flag validation, no GCP API calls or HTTP yet. Later branches will add the GCP client wrapper, the end-to-end happy path, filtering flags, multi-revision handling, auth UX, and docs. + +Constraints / acceptance criteria for the overall feature: +- Authentication via Application Default Credentials (ADC) — `GOOGLE_APPLICATION_CREDENTIALS`, `gcloud auth application-default login`, GCE/GKE metadata, Workload Identity Federation. +- Scope: project + region (Cloud Run has no cluster concept). Services only — Jobs are out of scope for this feature. +- Filtering flags: `--services`, `--services-regex`, `--exclude`, `--exclude-regex`. +- Server-side support for the `cloud-run` env type is a separate workstream. + +GCP test environment (provisioned, ADC already configured on this machine): +- Project: `hello-world-cli-demo` (#429671251962) +- Region: `europe-west1` +- Service: `hello-world` at `https://hello-world-saxojpsd4a-ew.a.run.app` + +--- + +## Decisions Made + +- The CLI command stays `Hidden: true` and forces `global.DryRun = true` until the feature is complete, so we can iterate without exposing it to customers and without risk of accidental writes against the server. Both locks are removed in a later slice once the end-to-end path and tests are in place. +- The first branch ships only the cobra skeleton (no GCP calls, no HTTP, no payload). The thinnest possible end-to-end wiring slice is deferred to a later branch so the GCP client wrapper can be designed and tested in isolation. +- Server-side `cloud-run` env type work is tracked separately. The CLI proceeds against a dry-run-only command until that lands; the CLI does not need to coordinate releases with the server side for this branch. + +--- + +## Next Steps + +Slice plan (each slice is a separate, independently-mergeable branch): + +- [x] **Slice 1 (this branch):** Skeleton command — `cmd/kosli/snapshotCloudRun.go` (Hidden, forced dry-run, stub `RunE`), register in `snapshot.go`, arg/flag validation tests. Done 2026-04-28: 5 cmdTestCase tests passing, `make lint` clean, hidden from `snapshot --help` but reachable directly. +- [ ] **Slice 2:** Internal `internal/gcprun` package — wraps `cloud.google.com/go/run/apiv2` to list services in project+region; unit-tested with a fake. +- [ ] **Slice 3:** End-to-end happy path — wire the package into `RunE`, build the snapshot payload, POST to the server `cloud-run` endpoint (still dry-run only). +- [ ] **Slice 4:** Filtering flags — `--services`, `--services-regex`, `--exclude`, `--exclude-regex`. +- [ ] **Slice 5:** Multi-revision / traffic splitting — handle services with multiple active revisions and services with no active revisions. +- [ ] **Slice 6:** Auth error UX — clear messages for ADC / `GOOGLE_APPLICATION_CREDENTIALS` failures and for missing project/region. +- [ ] **Slice 7:** Unhide the command, lift the forced dry-run, update CLI reference docs and examples. From d98c40cca95d95c282f537e7008143c0f7452c7d Mon Sep 17 00:00:00 2001 From: Tore Martin Hagen Date: Tue, 28 Apr 2026 12:42:45 +0200 Subject: [PATCH 2/9] feat(cloudrun): add internal/cloudrun package wrapping the Cloud Run Admin API (#4986) Slice 2 of the snapshot cloud-run feature. Adds a small, unit-tested wrapper around cloud.google.com/go/run/apiv2 that lists services in a GCP project + region and, for each service, returns one Revision per entry in the service's traffic configuration (any percent including 0%). TrafficTargetAllocationType_LATEST resolves via the service's LatestReadyRevision, and revisions referenced more than once are deduped. Digest extraction mirrors the ECS fallback in internal/aws/aws.go: use a @sha256: substring when present, else leave the digest empty rather than calling a registry. Production code reaches GCP through an unexported apiClient seam so tests substitute a fake without touching ADC. No command wiring yet; that's the next slice. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-04-28-4986-google-cloud-run-1.md | 5 +- go.mod | 21 +- go.sum | 51 ++- internal/cloudrun/cloudrun.go | 185 +++++++++++ internal/cloudrun/cloudrun_test.go | 297 ++++++++++++++++++ 5 files changed, 546 insertions(+), 13 deletions(-) create mode 100644 internal/cloudrun/cloudrun.go create mode 100644 internal/cloudrun/cloudrun_test.go diff --git a/docs/handover/2026-04-28-4986-google-cloud-run-1.md b/docs/handover/2026-04-28-4986-google-cloud-run-1.md index f5a543b63..e7087abd9 100644 --- a/docs/handover/2026-04-28-4986-google-cloud-run-1.md +++ b/docs/handover/2026-04-28-4986-google-cloud-run-1.md @@ -31,6 +31,9 @@ GCP test environment (provisioned, ADC already configured on this machine): - The CLI command stays `Hidden: true` and forces `global.DryRun = true` until the feature is complete, so we can iterate without exposing it to customers and without risk of accidental writes against the server. Both locks are removed in a later slice once the end-to-end path and tests are in place. - The first branch ships only the cobra skeleton (no GCP calls, no HTTP, no payload). The thinnest possible end-to-end wiring slice is deferred to a later branch so the GCP client wrapper can be designed and tested in isolation. - Server-side `cloud-run` env type work is tracked separately. The CLI proceeds against a dry-run-only command until that lands; the CLI does not need to coordinate releases with the server side for this branch. +- Package named `internal/cloudrun` (not `gcprun`) to mirror the user-facing command name `snapshot cloud-run`. If GCP integrations expand later we'll either rename or split into `internal/gcp/run`. +- `internal/cloudrun` reports all revisions referenced in `service.traffic[]` regardless of percent (including 0%). Trade-off: matches the user's framing of "running or could run" without dragging in retired revisions that aren't currently configured for traffic; canary 90/10 splits surface both revisions naturally. +- Digest extraction follows the ECS pattern (`internal/aws/aws.go:670-693`): use a `@sha256:` substring if present, else leave the digest empty rather than calling Artifact Registry. Registry-lookup mode (analogous to Azure's `--digests-source acr`) is deferred until customers ask for it. --- @@ -39,7 +42,7 @@ GCP test environment (provisioned, ADC already configured on this machine): Slice plan (each slice is a separate, independently-mergeable branch): - [x] **Slice 1 (this branch):** Skeleton command — `cmd/kosli/snapshotCloudRun.go` (Hidden, forced dry-run, stub `RunE`), register in `snapshot.go`, arg/flag validation tests. Done 2026-04-28: 5 cmdTestCase tests passing, `make lint` clean, hidden from `snapshot --help` but reachable directly. -- [ ] **Slice 2:** Internal `internal/gcprun` package — wraps `cloud.google.com/go/run/apiv2` to list services in project+region; unit-tested with a fake. +- [x] **Slice 2:** Internal `internal/cloudrun` package — wraps `cloud.google.com/go/run/apiv2` to list services in project+region; unit-tested with a fake. Done 2026-04-28: `Client.ListServices` returns `Service{Name, URI, Revisions}` with one `Revision{Name, Digests, CreatedAt}` per traffic-configured revision (any percent including 0%, with `LATEST` resolved via `LatestReadyRevision` and dupes removed). Digest extraction mirrors the ECS fallback (`@sha256:` parse, else empty string). 9 unit tests passing. - [ ] **Slice 3:** End-to-end happy path — wire the package into `RunE`, build the snapshot payload, POST to the server `cloud-run` endpoint (still dry-run only). - [ ] **Slice 4:** Filtering flags — `--services`, `--services-regex`, `--exclude`, `--exclude-regex`. - [ ] **Slice 5:** Multi-revision / traffic splitting — handle services with multiple active revisions and services with no active revisions. diff --git a/go.mod b/go.mod index 05b17cec9..9cb23ccb6 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/kosli-dev/cli go 1.25.9 require ( + cloud.google.com/go/run v1.19.0 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 github.com/Azure/azure-sdk-for-go/sdk/containers/azcontainerregistry v0.2.3 @@ -45,6 +46,8 @@ require ( gitlab.com/gitlab-org/api/client-go v1.46.0 golang.org/x/oauth2 v0.36.0 golang.org/x/term v0.42.0 + google.golang.org/api v0.274.0 + google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.35.0 k8s.io/apimachinery v0.35.0 @@ -56,6 +59,12 @@ require ( require ( al.essio.dev/pkg/shellescape v1.6.0 // indirect cel.dev/expr v0.25.1 // indirect + cloud.google.com/go v0.123.0 // indirect + cloud.google.com/go/auth v0.18.2 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/iam v1.7.0 // indirect + cloud.google.com/go/longrunning v0.9.0 // indirect dario.cat/mergo v1.0.2 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect @@ -126,7 +135,10 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect + github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect + github.com/googleapis/gax-go/v2 v2.21.0 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect @@ -196,6 +208,7 @@ require ( github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/yashtewari/glob-intersection v0.2.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 // indirect @@ -215,10 +228,10 @@ require ( golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.43.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect - google.golang.org/grpc v1.79.3 // indirect - google.golang.org/protobuf v1.36.11 // indirect + google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/grpc v1.80.0 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect diff --git a/go.sum b/go.sum index c45e855d7..1d1c00e8b 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,20 @@ al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeX al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= +cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.7.0 h1:JD3zh0C6LHl16aCn5Akff0+GELdp1+4hmh6ndoFLl8U= +cloud.google.com/go/iam v1.7.0/go.mod h1:tetWZW1PD/m6vcuY2Zj/aU0eCHNPuxedbnbRTyKXvdY= +cloud.google.com/go/longrunning v0.9.0 h1:0EzbDEGsAvOZNbqXopgniY0w0a1phvu5IdUFq8grmqY= +cloud.google.com/go/longrunning v0.9.0/go.mod h1:pkTz846W7bF4o2SzdWJ40Hu0Re+UoNT6Q5t+igIcb8E= +cloud.google.com/go/run v1.19.0 h1:kjXZKDwrUOeUYDd7/0TZ/iKsG3rJ3Lq3cyksTspcNSU= +cloud.google.com/go/run v1.19.0/go.mod h1:Z5wHbyFirI8XU48EPs5XJf/qmVm1SXZEhuS8EvZOuQU= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0= @@ -100,6 +114,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= @@ -157,6 +173,11 @@ github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bF github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= +github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww= github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= @@ -253,11 +274,17 @@ github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= +github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI= +github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= @@ -394,6 +421,8 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjL github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -496,6 +525,8 @@ gitlab.com/gitlab-org/api/client-go v1.46.0 h1:YxBWFZIFYKcGESCb9fpkwzouo+apyB9pr gitlab.com/gitlab-org/api/client-go v1.46.0/go.mod h1:FtgyU6g2HS5+fMhw6nLK96GBEEBx5MzntOiJWfIaiN8= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= @@ -569,15 +600,19 @@ golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.274.0 h1:aYhycS5QQCwxHLwfEHRRLf9yNsfvp1JadKKWBE54RFA= +google.golang.org/api v0.274.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0= -google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cloudrun/cloudrun.go b/internal/cloudrun/cloudrun.go new file mode 100644 index 000000000..1679591ff --- /dev/null +++ b/internal/cloudrun/cloudrun.go @@ -0,0 +1,185 @@ +// Package cloudrun reads Cloud Run service and revision data from GCP for +// snapshot reporting. The package is designed around an unexported apiClient +// interface so production code uses the real Cloud Run Admin API v2 and tests +// can swap in a fake without touching GCP. +package cloudrun + +import ( + "context" + "fmt" + "strings" + "time" + + run "cloud.google.com/go/run/apiv2" + "cloud.google.com/go/run/apiv2/runpb" + "google.golang.org/api/iterator" +) + +const sha256Marker = "@sha256:" + +// Service is a Cloud Run service together with the revisions referenced in +// its current traffic configuration (any percent, including 0%). +type Service struct { + Name string + URI string + Revisions []Revision +} + +// Revision is a single Cloud Run revision and the digest-pinned images of its +// containers. A digest value of "" means the image string was not digest-pinned +// and the digest could not be parsed without a registry lookup. +type Revision struct { + Name string + Digests map[string]string + CreatedAt time.Time +} + +// apiClient is the unexported seam that lets tests substitute a fake. +type apiClient interface { + listServices(ctx context.Context, project, region string) ([]*runpb.Service, error) + getRevision(ctx context.Context, name string) (*runpb.Revision, error) +} + +// Client fetches Cloud Run data from GCP. +type Client struct { + api apiClient +} + +// New returns a Client backed by the real Cloud Run Admin API v2 using +// Application Default Credentials. +func New(ctx context.Context) (*Client, error) { + services, err := run.NewServicesClient(ctx) + if err != nil { + return nil, fmt.Errorf("creating Cloud Run services client: %w", err) + } + revisions, err := run.NewRevisionsClient(ctx) + if err != nil { + return nil, fmt.Errorf("creating Cloud Run revisions client: %w", err) + } + return &Client{api: &gcpAPI{services: services, revisions: revisions}}, nil +} + +// ListServices returns every Cloud Run service in the given project+region, +// each populated with the revisions referenced in its traffic configuration. +// TrafficTarget entries of type LATEST are resolved via the service's +// LatestReadyRevision, and revisions referenced more than once are deduped. +func (c *Client) ListServices(ctx context.Context, project, region string) ([]Service, error) { + rawServices, err := c.api.listServices(ctx, project, region) + if err != nil { + return nil, err + } + out := make([]Service, 0, len(rawServices)) + for _, raw := range rawServices { + svc, err := c.toService(ctx, raw) + if err != nil { + return nil, err + } + out = append(out, svc) + } + return out, nil +} + +func (c *Client) toService(ctx context.Context, raw *runpb.Service) (Service, error) { + svc := Service{ + Name: shortName(raw.GetName()), + URI: raw.GetUri(), + } + revNames := trafficRevisionNames(raw) + for _, revShort := range revNames { + fullName := raw.GetName() + "/revisions/" + revShort + rev, err := c.api.getRevision(ctx, fullName) + if err != nil { + return Service{}, fmt.Errorf("getting revision %s: %w", fullName, err) + } + svc.Revisions = append(svc.Revisions, toRevision(rev)) + } + return svc, nil +} + +// trafficRevisionNames returns the deduped short names of revisions referenced +// in the service's traffic configuration. TrafficTarget entries of type LATEST +// are resolved to LatestReadyRevision; entries with an empty resolved name are +// skipped. +func trafficRevisionNames(svc *runpb.Service) []string { + seen := map[string]struct{}{} + out := []string{} + for _, t := range svc.GetTraffic() { + name := t.GetRevision() + if name == "" { + name = shortName(svc.GetLatestReadyRevision()) + } + if name == "" { + continue + } + if _, dup := seen[name]; dup { + continue + } + seen[name] = struct{}{} + out = append(out, name) + } + return out +} + +func toRevision(rev *runpb.Revision) Revision { + digests := make(map[string]string, len(rev.GetContainers())) + for _, container := range rev.GetContainers() { + image := container.GetImage() + digests[image] = parseDigest(image) + } + var createdAt time.Time + if ts := rev.GetCreateTime(); ts != nil { + createdAt = ts.AsTime() + } + return Revision{ + Name: shortName(rev.GetName()), + Digests: digests, + CreatedAt: createdAt, + } +} + +// parseDigest extracts the sha256 hex out of a digest-pinned image reference +// like "gcr.io/foo/bar@sha256:". Tag-pinned references and inputs without +// the marker yield an empty string, mirroring the ECS snapshot fallback. +func parseDigest(image string) string { + idx := strings.Index(image, sha256Marker) + if idx < 0 { + return "" + } + return image[idx+len(sha256Marker):] +} + +// shortName returns the last path component of a fully-qualified GCP resource +// name like "projects/p/locations/r/services/svc" -> "svc". Non-qualified +// inputs are returned unchanged. +func shortName(fullName string) string { + if i := strings.LastIndex(fullName, "/"); i >= 0 { + return fullName[i+1:] + } + return fullName +} + +// gcpAPI is the production apiClient backed by the Cloud Run Admin API v2. +type gcpAPI struct { + services *run.ServicesClient + revisions *run.RevisionsClient +} + +func (g *gcpAPI) listServices(ctx context.Context, project, region string) ([]*runpb.Service, error) { + parent := fmt.Sprintf("projects/%s/locations/%s", project, region) + it := g.services.ListServices(ctx, &runpb.ListServicesRequest{Parent: parent}) + var out []*runpb.Service + for { + svc, err := it.Next() + if err == iterator.Done { + return out, nil + } + if err != nil { + return nil, fmt.Errorf("listing Cloud Run services in %s: %w", parent, err) + } + out = append(out, svc) + } +} + +func (g *gcpAPI) getRevision(ctx context.Context, name string) (*runpb.Revision, error) { + return g.revisions.GetRevision(ctx, &runpb.GetRevisionRequest{Name: name}) +} diff --git a/internal/cloudrun/cloudrun_test.go b/internal/cloudrun/cloudrun_test.go new file mode 100644 index 000000000..ee4491b0d --- /dev/null +++ b/internal/cloudrun/cloudrun_test.go @@ -0,0 +1,297 @@ +package cloudrun + +import ( + "context" + "testing" + "time" + + "cloud.google.com/go/run/apiv2/runpb" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// fakeAPI is the in-memory test double for apiClient. +type fakeAPI struct { + services []*runpb.Service + revisions map[string]*runpb.Revision + listErr error + getErr error +} + +func (f *fakeAPI) listServices(_ context.Context, _, _ string) ([]*runpb.Service, error) { + if f.listErr != nil { + return nil, f.listErr + } + return f.services, nil +} + +func (f *fakeAPI) getRevision(_ context.Context, name string) (*runpb.Revision, error) { + if f.getErr != nil { + return nil, f.getErr + } + rev, ok := f.revisions[name] + if !ok { + return nil, errNotFound{name: name} + } + return rev, nil +} + +type errNotFound struct{ name string } + +func (e errNotFound) Error() string { return "revision not found: " + e.name } + +const ( + testProject = "hello-world-cli-demo" + testRegion = "europe-west1" +) + +func svcResource(name string) string { + return "projects/" + testProject + "/locations/" + testRegion + "/services/" + name +} + +func revResource(svc, rev string) string { + return svcResource(svc) + "/revisions/" + rev +} + +func newClient(fake *fakeAPI) *Client { + return &Client{api: fake} +} + +func TestListServices_SingleRevisionDigestPinned(t *testing.T) { + created := time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC) + fake := &fakeAPI{ + services: []*runpb.Service{ + { + Name: svcResource("svc1"), + Uri: "https://svc1.run.app", + Traffic: []*runpb.TrafficTarget{ + {Revision: "svc1-rev1", Percent: 100, Type: runpb.TrafficTargetAllocationType_TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION}, + }, + }, + }, + revisions: map[string]*runpb.Revision{ + revResource("svc1", "svc1-rev1"): { + Name: revResource("svc1", "svc1-rev1"), + CreateTime: timestamppb.New(created), + Containers: []*runpb.Container{ + {Image: "gcr.io/foo/bar@sha256:abc123"}, + }, + }, + }, + } + + got, err := newClient(fake).ListServices(context.Background(), testProject, testRegion) + require.NoError(t, err) + require.Len(t, got, 1) + + svc := got[0] + require.Equal(t, "svc1", svc.Name) + require.Equal(t, "https://svc1.run.app", svc.URI) + require.Len(t, svc.Revisions, 1) + + rev := svc.Revisions[0] + require.Equal(t, "svc1-rev1", rev.Name) + require.True(t, rev.CreatedAt.Equal(created), "CreatedAt = %v, want %v", rev.CreatedAt, created) + require.Equal(t, map[string]string{"gcr.io/foo/bar@sha256:abc123": "abc123"}, rev.Digests) +} + +func TestListServices_TagPinnedImageYieldsEmptyDigest(t *testing.T) { + fake := &fakeAPI{ + services: []*runpb.Service{ + { + Name: svcResource("svc1"), + Traffic: []*runpb.TrafficTarget{ + {Revision: "svc1-rev1", Percent: 100, Type: runpb.TrafficTargetAllocationType_TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION}, + }, + }, + }, + revisions: map[string]*runpb.Revision{ + revResource("svc1", "svc1-rev1"): { + Name: revResource("svc1", "svc1-rev1"), + Containers: []*runpb.Container{ + {Image: "gcr.io/foo/bar:v1"}, + }, + }, + }, + } + + got, err := newClient(fake).ListServices(context.Background(), testProject, testRegion) + require.NoError(t, err) + require.Equal(t, map[string]string{"gcr.io/foo/bar:v1": ""}, got[0].Revisions[0].Digests) +} + +func TestListServices_TrafficSplitReturnsBothRevisions(t *testing.T) { + fake := &fakeAPI{ + services: []*runpb.Service{ + { + Name: svcResource("svc1"), + Traffic: []*runpb.TrafficTarget{ + {Revision: "svc1-rev1", Percent: 90, Type: runpb.TrafficTargetAllocationType_TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION}, + {Revision: "svc1-rev2", Percent: 10, Type: runpb.TrafficTargetAllocationType_TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION}, + }, + }, + }, + revisions: map[string]*runpb.Revision{ + revResource("svc1", "svc1-rev1"): { + Name: revResource("svc1", "svc1-rev1"), + Containers: []*runpb.Container{{Image: "gcr.io/foo/bar@sha256:rev1"}}, + }, + revResource("svc1", "svc1-rev2"): { + Name: revResource("svc1", "svc1-rev2"), + Containers: []*runpb.Container{{Image: "gcr.io/foo/bar@sha256:rev2"}}, + }, + }, + } + + got, err := newClient(fake).ListServices(context.Background(), testProject, testRegion) + require.NoError(t, err) + require.Len(t, got, 1) + require.Len(t, got[0].Revisions, 2) + + names := []string{got[0].Revisions[0].Name, got[0].Revisions[1].Name} + require.ElementsMatch(t, []string{"svc1-rev1", "svc1-rev2"}, names) +} + +func TestListServices_ZeroPercentRevisionStillIncluded(t *testing.T) { + fake := &fakeAPI{ + services: []*runpb.Service{ + { + Name: svcResource("svc1"), + Traffic: []*runpb.TrafficTarget{ + {Revision: "svc1-rev1", Percent: 100, Type: runpb.TrafficTargetAllocationType_TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION}, + {Revision: "svc1-rev2", Percent: 0, Type: runpb.TrafficTargetAllocationType_TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION}, + }, + }, + }, + revisions: map[string]*runpb.Revision{ + revResource("svc1", "svc1-rev1"): {Name: revResource("svc1", "svc1-rev1"), Containers: []*runpb.Container{{Image: "img@sha256:rev1"}}}, + revResource("svc1", "svc1-rev2"): {Name: revResource("svc1", "svc1-rev2"), Containers: []*runpb.Container{{Image: "img@sha256:rev2"}}}, + }, + } + + got, err := newClient(fake).ListServices(context.Background(), testProject, testRegion) + require.NoError(t, err) + require.Len(t, got[0].Revisions, 2) +} + +func TestListServices_TrafficLatestResolvesToLatestReadyRevision(t *testing.T) { + fake := &fakeAPI{ + services: []*runpb.Service{ + { + Name: svcResource("svc1"), + LatestReadyRevision: revResource("svc1", "svc1-latest"), + Traffic: []*runpb.TrafficTarget{ + {Type: runpb.TrafficTargetAllocationType_TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST, Percent: 100}, + }, + }, + }, + revisions: map[string]*runpb.Revision{ + revResource("svc1", "svc1-latest"): { + Name: revResource("svc1", "svc1-latest"), + Containers: []*runpb.Container{{Image: "img@sha256:latest-digest"}}, + }, + }, + } + + got, err := newClient(fake).ListServices(context.Background(), testProject, testRegion) + require.NoError(t, err) + require.Len(t, got[0].Revisions, 1) + require.Equal(t, "svc1-latest", got[0].Revisions[0].Name) +} + +func TestListServices_DedupesRevisionReferencedTwice(t *testing.T) { + fake := &fakeAPI{ + services: []*runpb.Service{ + { + Name: svcResource("svc1"), + LatestReadyRevision: revResource("svc1", "svc1-rev1"), + Traffic: []*runpb.TrafficTarget{ + {Type: runpb.TrafficTargetAllocationType_TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST, Percent: 50}, + {Revision: "svc1-rev1", Percent: 50, Type: runpb.TrafficTargetAllocationType_TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION}, + }, + }, + }, + revisions: map[string]*runpb.Revision{ + revResource("svc1", "svc1-rev1"): { + Name: revResource("svc1", "svc1-rev1"), + Containers: []*runpb.Container{{Image: "img@sha256:rev1"}}, + }, + }, + } + + got, err := newClient(fake).ListServices(context.Background(), testProject, testRegion) + require.NoError(t, err) + require.Len(t, got[0].Revisions, 1, "the same revision must not appear twice") +} + +func TestListServices_MultipleContainersAllAppearInDigests(t *testing.T) { + fake := &fakeAPI{ + services: []*runpb.Service{ + { + Name: svcResource("svc1"), + Traffic: []*runpb.TrafficTarget{ + {Revision: "svc1-rev1", Percent: 100, Type: runpb.TrafficTargetAllocationType_TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION}, + }, + }, + }, + revisions: map[string]*runpb.Revision{ + revResource("svc1", "svc1-rev1"): { + Name: revResource("svc1", "svc1-rev1"), + Containers: []*runpb.Container{ + {Image: "gcr.io/foo/main@sha256:main"}, + {Image: "gcr.io/foo/sidecar@sha256:side"}, + }, + }, + }, + } + + got, err := newClient(fake).ListServices(context.Background(), testProject, testRegion) + require.NoError(t, err) + require.Equal(t, map[string]string{ + "gcr.io/foo/main@sha256:main": "main", + "gcr.io/foo/sidecar@sha256:side": "side", + }, got[0].Revisions[0].Digests) +} + +func TestListServices_ServiceWithNoTrafficTargetsHasEmptyRevisions(t *testing.T) { + fake := &fakeAPI{ + services: []*runpb.Service{ + {Name: svcResource("svc1")}, + }, + } + + got, err := newClient(fake).ListServices(context.Background(), testProject, testRegion) + require.NoError(t, err) + require.Len(t, got, 1) + require.Empty(t, got[0].Revisions) +} + +func TestListServices_MultipleServices(t *testing.T) { + fake := &fakeAPI{ + services: []*runpb.Service{ + { + Name: svcResource("svc-a"), + Traffic: []*runpb.TrafficTarget{ + {Revision: "svc-a-rev1", Percent: 100, Type: runpb.TrafficTargetAllocationType_TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION}, + }, + }, + { + Name: svcResource("svc-b"), + Traffic: []*runpb.TrafficTarget{ + {Revision: "svc-b-rev1", Percent: 100, Type: runpb.TrafficTargetAllocationType_TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION}, + }, + }, + }, + revisions: map[string]*runpb.Revision{ + revResource("svc-a", "svc-a-rev1"): {Name: revResource("svc-a", "svc-a-rev1"), Containers: []*runpb.Container{{Image: "img@sha256:a"}}}, + revResource("svc-b", "svc-b-rev1"): {Name: revResource("svc-b", "svc-b-rev1"), Containers: []*runpb.Container{{Image: "img@sha256:b"}}}, + }, + } + + got, err := newClient(fake).ListServices(context.Background(), testProject, testRegion) + require.NoError(t, err) + require.Len(t, got, 2) + + names := []string{got[0].Name, got[1].Name} + require.ElementsMatch(t, []string{"svc-a", "svc-b"}, names) +} From 1c081872b21f9771fa4cbe3f33b8059cf6d88714 Mon Sep 17 00:00:00 2001 From: Tore Martin Hagen Date: Tue, 28 Apr 2026 13:02:07 +0200 Subject: [PATCH 3/9] feat(snapshot): wire cloud-run command end-to-end via internal/cloudrun (#4986) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 3 of the snapshot cloud-run feature. The command now calls cloudrun.New + ListServices, flattens the result into an EnvRequest payload, and submits a PUT to /report/cloud-run via kosliClient.Do. The PreRunE-forced dry-run keeps the request from actually leaving the client, so the (still-missing) server-side endpoint is not on the path yet. Each artifact carries the GCP project and region in addition to service_name, so revision rows are self-describing — a small extension of the EcsEnvRequest shape. The command depends on a local cloudRunLister interface and a package- level newCloudRunClient variable, letting tests swap in a stub without touching ADC; integration was sanity-checked against the real hello-world-cli-demo project and produced the expected digest-pinned artifact. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/kosli/snapshotCloudRun.go | 49 +++++++- cmd/kosli/snapshotCloudRun_test.go | 42 ++++++- .../2026-04-28-4986-google-cloud-run-1.md | 4 +- internal/cloudrun/payload.go | 38 ++++++ internal/cloudrun/payload_test.go | 110 ++++++++++++++++++ 5 files changed, 234 insertions(+), 9 deletions(-) create mode 100644 internal/cloudrun/payload.go create mode 100644 internal/cloudrun/payload_test.go diff --git a/cmd/kosli/snapshotCloudRun.go b/cmd/kosli/snapshotCloudRun.go index 871ea734b..beafc8507 100644 --- a/cmd/kosli/snapshotCloudRun.go +++ b/cmd/kosli/snapshotCloudRun.go @@ -1,15 +1,29 @@ package main import ( - "fmt" + "context" "io" + "net/http" + "net/url" + "github.com/kosli-dev/cli/internal/cloudrun" + "github.com/kosli-dev/cli/internal/requests" "github.com/spf13/cobra" ) const snapshotCloudRunShortDesc = `Report a snapshot of running services in a Google Cloud Run project and region to Kosli. ` const snapshotCloudRunLongDesc = snapshotCloudRunShortDesc + ` -Currently a hidden, in-development command — it always runs in dry-run mode and does not yet talk to GCP or to Kosli.` +Currently a hidden, in-development command — it always runs in dry-run mode regardless of the --dry-run flag.` + +// cloudRunLister is the seam between the command and the GCP client. Tests +// override newCloudRunClient with a stub that returns canned services. +type cloudRunLister interface { + ListServices(ctx context.Context, project, region string) ([]cloudrun.Service, error) +} + +var newCloudRunClient = func(ctx context.Context) (cloudRunLister, error) { + return cloudrun.New(ctx) +} type snapshotCloudRunOptions struct { project string @@ -32,7 +46,7 @@ func newSnapshotCloudRunCmd(out io.Writer) *cobra.Command { return nil }, RunE: func(cmd *cobra.Command, args []string) error { - return o.run(out, args) + return o.run(args) }, } @@ -47,7 +61,32 @@ func newSnapshotCloudRunCmd(out io.Writer) *cobra.Command { return cmd } -func (o *snapshotCloudRunOptions) run(out io.Writer, args []string) error { - _, err := fmt.Fprintln(out, "cloud-run snapshot: not yet implemented (forced dry-run)") +func (o *snapshotCloudRunOptions) run(args []string) error { + envName := args[0] + reportURL, err := url.JoinPath(global.Host, "api/v2/environments", global.Org, envName, "report/cloud-run") + if err != nil { + return err + } + + ctx := context.Background() + client, err := newCloudRunClient(ctx) + if err != nil { + return err + } + services, err := client.ListServices(ctx, o.project, o.region) + if err != nil { + return err + } + + payload := cloudrun.ToEnvRequest(services, o.project, o.region) + + reqParams := &requests.RequestParams{ + Method: http.MethodPut, + URL: reportURL, + Payload: payload, + DryRun: global.DryRun, + Token: global.ApiToken, + } + _, err = kosliClient.Do(reqParams) return err } diff --git a/cmd/kosli/snapshotCloudRun_test.go b/cmd/kosli/snapshotCloudRun_test.go index 9555d838e..12c7307cf 100644 --- a/cmd/kosli/snapshotCloudRun_test.go +++ b/cmd/kosli/snapshotCloudRun_test.go @@ -1,12 +1,26 @@ package main import ( + "context" "fmt" "testing" + "time" + "github.com/kosli-dev/cli/internal/cloudrun" "github.com/stretchr/testify/suite" ) +type stubCloudRunLister struct { + services []cloudrun.Service + err error +} + +func (s stubCloudRunLister) ListServices(_ context.Context, _, _ string) ([]cloudrun.Service, error) { + return s.services, s.err +} + +var origNewCloudRunClient = newCloudRunClient + type SnapshotCloudRunTestSuite struct { suite.Suite defaultKosliArguments string @@ -21,6 +35,28 @@ func (suite *SnapshotCloudRunTestSuite) SetupTest() { Host: "http://localhost:8001", } suite.defaultKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken) + + newCloudRunClient = func(_ context.Context) (cloudRunLister, error) { + return stubCloudRunLister{ + services: []cloudrun.Service{ + { + Name: "hello-world", + URI: "https://hello-world.run.app", + Revisions: []cloudrun.Revision{ + { + Name: "hello-world-rev1", + Digests: map[string]string{"gcr.io/x/hello@sha256:abc": "abc"}, + CreatedAt: time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC), + }, + }, + }, + }, + }, nil + } +} + +func (suite *SnapshotCloudRunTestSuite) TearDownTest() { + newCloudRunClient = origNewCloudRunClient } func (suite *SnapshotCloudRunTestSuite) TestSnapshotCloudRunCmd() { @@ -50,9 +86,9 @@ func (suite *SnapshotCloudRunTestSuite) TestSnapshotCloudRunCmd() { golden: "Error: required flag(s) \"region\" not set\n", }, { - name: "snapshot cloud-run succeeds with required args and prints the placeholder", - cmd: fmt.Sprintf(`snapshot cloud-run %s --project p --region r %s`, suite.envName, suite.defaultKosliArguments), - golden: "cloud-run snapshot: not yet implemented (forced dry-run)\n", + name: "snapshot cloud-run dry-runs the report URL and payload built from the GCP client", + cmd: fmt.Sprintf(`snapshot cloud-run %s --project proj-x --region europe-west1 %s`, suite.envName, suite.defaultKosliArguments), + goldenRegex: `(?s)THIS IS A DRY-RUN.*report/cloud-run.*"revisionName": "hello-world-rev1".*"service_name": "hello-world".*"project": "proj-x".*"region": "europe-west1".*"gcr.io/x/hello@sha256:abc": "abc"`, }, } diff --git a/docs/handover/2026-04-28-4986-google-cloud-run-1.md b/docs/handover/2026-04-28-4986-google-cloud-run-1.md index e7087abd9..5d11023e9 100644 --- a/docs/handover/2026-04-28-4986-google-cloud-run-1.md +++ b/docs/handover/2026-04-28-4986-google-cloud-run-1.md @@ -34,6 +34,8 @@ GCP test environment (provisioned, ADC already configured on this machine): - Package named `internal/cloudrun` (not `gcprun`) to mirror the user-facing command name `snapshot cloud-run`. If GCP integrations expand later we'll either rename or split into `internal/gcp/run`. - `internal/cloudrun` reports all revisions referenced in `service.traffic[]` regardless of percent (including 0%). Trade-off: matches the user's framing of "running or could run" without dragging in retired revisions that aren't currently configured for traffic; canary 90/10 splits surface both revisions naturally. - Digest extraction follows the ECS pattern (`internal/aws/aws.go:670-693`): use a `@sha256:` substring if present, else leave the digest empty rather than calling Artifact Registry. Registry-lookup mode (analogous to Azure's `--digests-source acr`) is deferred until customers ask for it. +- Wire payload mirrors `EcsEnvRequest` plus `project` and `region` fields per artifact (so each row is self-describing). Endpoint name is `report/cloud-run` (kebab-case, parallels `report/azure-apps`). Server-side endpoint does not yet exist; the forced dry-run means no network call is made. +- Command depends on a local `cloudRunLister` interface and a package-level `newCloudRunClient` variable so tests can substitute a stub without touching ADC. The seam stays in `cmd/kosli/snapshotCloudRun.go` rather than being exposed from `internal/cloudrun` — keeps the public package surface minimal. --- @@ -43,7 +45,7 @@ Slice plan (each slice is a separate, independently-mergeable branch): - [x] **Slice 1 (this branch):** Skeleton command — `cmd/kosli/snapshotCloudRun.go` (Hidden, forced dry-run, stub `RunE`), register in `snapshot.go`, arg/flag validation tests. Done 2026-04-28: 5 cmdTestCase tests passing, `make lint` clean, hidden from `snapshot --help` but reachable directly. - [x] **Slice 2:** Internal `internal/cloudrun` package — wraps `cloud.google.com/go/run/apiv2` to list services in project+region; unit-tested with a fake. Done 2026-04-28: `Client.ListServices` returns `Service{Name, URI, Revisions}` with one `Revision{Name, Digests, CreatedAt}` per traffic-configured revision (any percent including 0%, with `LATEST` resolved via `LatestReadyRevision` and dupes removed). Digest extraction mirrors the ECS fallback (`@sha256:` parse, else empty string). 9 unit tests passing. -- [ ] **Slice 3:** End-to-end happy path — wire the package into `RunE`, build the snapshot payload, POST to the server `cloud-run` endpoint (still dry-run only). +- [x] **Slice 3:** End-to-end happy path — wire the package into `RunE`, build the snapshot payload, POST to the server `cloud-run` endpoint (still dry-run only). Done 2026-04-28: command now calls `cloudrun.New` + `ListServices`, builds an `EnvRequest` via `ToEnvRequest(services, project, region)`, and submits PUT `report/cloud-run` via `kosliClient.Do` (dry-run forced, so no network call leaves the client). Tested against the real `hello-world-cli-demo` GCP project — emits a digest-pinned artifact for the running `hello-world` service. - [ ] **Slice 4:** Filtering flags — `--services`, `--services-regex`, `--exclude`, `--exclude-regex`. - [ ] **Slice 5:** Multi-revision / traffic splitting — handle services with multiple active revisions and services with no active revisions. - [ ] **Slice 6:** Auth error UX — clear messages for ADC / `GOOGLE_APPLICATION_CREDENTIALS` failures and for missing project/region. diff --git a/internal/cloudrun/payload.go b/internal/cloudrun/payload.go new file mode 100644 index 000000000..a0b932a60 --- /dev/null +++ b/internal/cloudrun/payload.go @@ -0,0 +1,38 @@ +package cloudrun + +// EnvRequest is the PUT body sent to the Kosli "report/cloud-run" endpoint. +// It mirrors the shape of EcsEnvRequest in internal/aws. +type EnvRequest struct { + Artifacts []*RevisionData `json:"artifacts"` +} + +// RevisionData represents one Cloud Run revision in the snapshot payload. +// One artifact is emitted per revision in each service's traffic configuration. +type RevisionData struct { + RevisionName string `json:"revisionName"` + Service string `json:"service_name,omitempty"` + Project string `json:"project,omitempty"` + Region string `json:"region,omitempty"` + Digests map[string]string `json:"digests"` + CreatedAt int64 `json:"creationTimestamp"` +} + +// ToEnvRequest flattens services into a list of revision artifacts. Services +// with no revisions contribute nothing, mirroring the ECS behaviour of +// services with no running tasks. +func ToEnvRequest(services []Service, project, region string) *EnvRequest { + artifacts := []*RevisionData{} + for _, svc := range services { + for _, rev := range svc.Revisions { + artifacts = append(artifacts, &RevisionData{ + RevisionName: rev.Name, + Service: svc.Name, + Project: project, + Region: region, + Digests: rev.Digests, + CreatedAt: rev.CreatedAt.Unix(), + }) + } + } + return &EnvRequest{Artifacts: artifacts} +} diff --git a/internal/cloudrun/payload_test.go b/internal/cloudrun/payload_test.go new file mode 100644 index 000000000..ba08cbaa5 --- /dev/null +++ b/internal/cloudrun/payload_test.go @@ -0,0 +1,110 @@ +package cloudrun + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestToEnvRequest_EmptyInput(t *testing.T) { + got := ToEnvRequest(nil, "p", "r") + require.NotNil(t, got) + require.Empty(t, got.Artifacts) +} + +func TestToEnvRequest_SingleServiceSingleRevision(t *testing.T) { + created := time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC) + services := []Service{ + { + Name: "svc-a", + URI: "https://svc-a.run.app", + Revisions: []Revision{ + { + Name: "svc-a-rev1", + Digests: map[string]string{"img@sha256:aaa": "aaa"}, + CreatedAt: created, + }, + }, + }, + } + + got := ToEnvRequest(services, "proj-1", "europe-west1") + require.Len(t, got.Artifacts, 1) + + art := got.Artifacts[0] + require.Equal(t, "svc-a-rev1", art.RevisionName) + require.Equal(t, "svc-a", art.Service) + require.Equal(t, "proj-1", art.Project) + require.Equal(t, "europe-west1", art.Region) + require.Equal(t, map[string]string{"img@sha256:aaa": "aaa"}, art.Digests) + require.Equal(t, created.Unix(), art.CreatedAt) +} + +func TestToEnvRequest_MultipleServicesMultipleRevisions(t *testing.T) { + services := []Service{ + { + Name: "svc-a", + Revisions: []Revision{ + {Name: "a-rev1", Digests: map[string]string{"img@sha256:a1": "a1"}}, + {Name: "a-rev2", Digests: map[string]string{"img@sha256:a2": "a2"}}, + }, + }, + { + Name: "svc-b", + Revisions: []Revision{ + {Name: "b-rev1", Digests: map[string]string{"img@sha256:b1": "b1"}}, + }, + }, + } + + got := ToEnvRequest(services, "proj-1", "europe-west1") + require.Len(t, got.Artifacts, 3) + + revisionNames := []string{got.Artifacts[0].RevisionName, got.Artifacts[1].RevisionName, got.Artifacts[2].RevisionName} + require.Equal(t, []string{"a-rev1", "a-rev2", "b-rev1"}, revisionNames) + + require.Equal(t, "svc-a", got.Artifacts[0].Service) + require.Equal(t, "svc-a", got.Artifacts[1].Service) + require.Equal(t, "svc-b", got.Artifacts[2].Service) +} + +func TestToEnvRequest_ServiceWithNoRevisionsContributesNothing(t *testing.T) { + services := []Service{ + {Name: "empty-svc"}, + { + Name: "svc-a", + Revisions: []Revision{ + {Name: "rev", Digests: map[string]string{"img@sha256:x": "x"}}, + }, + }, + } + + got := ToEnvRequest(services, "p", "r") + require.Len(t, got.Artifacts, 1) + require.Equal(t, "svc-a", got.Artifacts[0].Service) +} + +func TestToEnvRequest_ProjectAndRegionOnEveryArtifact(t *testing.T) { + services := []Service{ + {Name: "a", Revisions: []Revision{{Name: "a1"}}}, + {Name: "b", Revisions: []Revision{{Name: "b1"}}}, + } + + got := ToEnvRequest(services, "proj-x", "us-central1") + + for _, art := range got.Artifacts { + require.Equal(t, "proj-x", art.Project) + require.Equal(t, "us-central1", art.Region) + } +} + +func TestToEnvRequest_ZeroCreatedAtSerialisesAsZero(t *testing.T) { + services := []Service{ + {Name: "svc", Revisions: []Revision{{Name: "rev"}}}, + } + + got := ToEnvRequest(services, "p", "r") + require.Len(t, got.Artifacts, 1) + require.Equal(t, time.Time{}.Unix(), got.Artifacts[0].CreatedAt) +} From 254261473ffce6ffe9b7633008534992db776226 Mon Sep 17 00:00:00 2001 From: Tore Martin Hagen Date: Tue, 28 Apr 2026 13:10:26 +0200 Subject: [PATCH 4/9] feat(snapshot): add service filtering flags to cloud-run command (#4986) Slice 4 of the snapshot cloud-run feature. Adds --services, --services-regex, --exclude, and --exclude-regex, mirroring the ECS service filtering shape and reusing the existing filters.ResourceFilterOptions struct. PreRunE rejects the four include/exclude mutex pairs. Filtering is applied in the command after cloudrun.ListServices returns. Services excluded by name still incur their per-revision API round-trips; pushing the filter into the GCP wrapper to skip those calls is a tractable follow-up if the round-trip cost becomes a bottleneck. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/kosli/root.go | 4 + cmd/kosli/snapshotCloudRun.go | 34 +++++- cmd/kosli/snapshotCloudRun_test.go | 107 +++++++++++++++--- .../2026-04-28-4986-google-cloud-run-1.md | 2 +- 4 files changed, 127 insertions(+), 20 deletions(-) diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 171ee6215..6bdc98967 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -176,6 +176,10 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file, 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." + cloudRunServicesFlag = "[optional] The comma-separated list of Cloud Run service names to snapshot. Can't be used together with --exclude or --exclude-regex." + cloudRunServicesRegexFlag = "[optional] The comma-separated list of Cloud Run service 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 names to exclude. Can't be used together with --services or --services-regex." + cloudRunExcludeRegexFlag = "[optional] The comma-separated list of Cloud Run service name regex patterns to exclude. Can't be used together with --services or --services-regex." kubeconfigFlag = "[defaulted] The kubeconfig path for the target cluster." namespacesFlag = "[optional] The comma separated list of namespaces names to report artifacts info from. Can't be used together with --exclude-namespaces or --exclude-namespaces-regex." excludeNamespacesFlag = "[optional] The comma separated list of namespaces names to exclude from reporting artifacts info from. Requires cluster-wide read permissions for pods and namespaces. Can't be used together with --namespaces or --namespaces-regex." diff --git a/cmd/kosli/snapshotCloudRun.go b/cmd/kosli/snapshotCloudRun.go index beafc8507..c46a59121 100644 --- a/cmd/kosli/snapshotCloudRun.go +++ b/cmd/kosli/snapshotCloudRun.go @@ -7,6 +7,7 @@ import ( "net/url" "github.com/kosli-dev/cli/internal/cloudrun" + "github.com/kosli-dev/cli/internal/filters" "github.com/kosli-dev/cli/internal/requests" "github.com/spf13/cobra" ) @@ -26,12 +27,14 @@ var newCloudRunClient = func(ctx context.Context) (cloudRunLister, error) { } type snapshotCloudRunOptions struct { - project string - region string + project string + region string + serviceFilter *filters.ResourceFilterOptions } func newSnapshotCloudRunCmd(out io.Writer) *cobra.Command { o := new(snapshotCloudRunOptions) + o.serviceFilter = new(filters.ResourceFilterOptions) cmd := &cobra.Command{ Use: "cloud-run ENVIRONMENT-NAME", Short: snapshotCloudRunShortDesc, @@ -42,6 +45,16 @@ func newSnapshotCloudRunCmd(out io.Writer) *cobra.Command { if err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}); err != nil { return ErrorBeforePrintingUsage(cmd, err.Error()) } + for _, pair := range [][]string{ + {"services", "exclude"}, + {"services", "exclude-regex"}, + {"services-regex", "exclude"}, + {"services-regex", "exclude-regex"}, + } { + if err := MuXRequiredFlags(cmd, pair, false); err != nil { + return err + } + } global.DryRun = true return nil }, @@ -52,6 +65,10 @@ func newSnapshotCloudRunCmd(out io.Writer) *cobra.Command { cmd.Flags().StringVar(&o.project, "project", "", "[required] GCP project ID.") cmd.Flags().StringVar(&o.region, "region", "", "[required] GCP region (e.g. europe-west1).") + cmd.Flags().StringSliceVar(&o.serviceFilter.IncludeNames, "services", []string{}, cloudRunServicesFlag) + cmd.Flags().StringSliceVar(&o.serviceFilter.IncludeNamesRegex, "services-regex", []string{}, cloudRunServicesRegexFlag) + cmd.Flags().StringSliceVar(&o.serviceFilter.ExcludeNames, "exclude", []string{}, cloudRunExcludeFlag) + cmd.Flags().StringSliceVar(&o.serviceFilter.ExcludeNamesRegex, "exclude-regex", []string{}, cloudRunExcludeRegexFlag) addDryRunFlag(cmd) if err := RequireFlags(cmd, []string{"project", "region"}); err != nil { @@ -78,7 +95,18 @@ func (o *snapshotCloudRunOptions) run(args []string) error { return err } - payload := cloudrun.ToEnvRequest(services, o.project, o.region) + filtered := services[:0] + for _, svc := range services { + include, err := o.serviceFilter.ShouldInclude(svc.Name) + if err != nil { + return err + } + if include { + filtered = append(filtered, svc) + } + } + + payload := cloudrun.ToEnvRequest(filtered, o.project, o.region) reqParams := &requests.RequestParams{ Method: http.MethodPut, diff --git a/cmd/kosli/snapshotCloudRun_test.go b/cmd/kosli/snapshotCloudRun_test.go index 12c7307cf..865874aef 100644 --- a/cmd/kosli/snapshotCloudRun_test.go +++ b/cmd/kosli/snapshotCloudRun_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/kosli-dev/cli/internal/cloudrun" + "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) @@ -27,6 +28,35 @@ type SnapshotCloudRunTestSuite struct { envName string } +// stubServices returns two Cloud Run services so filter tests can verify +// inclusion and exclusion in a single run. +func stubServices() []cloudrun.Service { + return []cloudrun.Service{ + { + Name: "alpha", + URI: "https://alpha.run.app", + Revisions: []cloudrun.Revision{ + { + Name: "alpha-rev1", + Digests: map[string]string{"gcr.io/x/alpha@sha256:aaa": "aaa"}, + CreatedAt: time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC), + }, + }, + }, + { + Name: "beta", + URI: "https://beta.run.app", + Revisions: []cloudrun.Revision{ + { + Name: "beta-rev1", + Digests: map[string]string{"gcr.io/x/beta@sha256:bbb": "bbb"}, + CreatedAt: time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC), + }, + }, + }, + } +} + func (suite *SnapshotCloudRunTestSuite) SetupTest() { suite.envName = "snapshot-cloud-run-env" global = &GlobalOpts{ @@ -37,21 +67,7 @@ func (suite *SnapshotCloudRunTestSuite) SetupTest() { suite.defaultKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken) newCloudRunClient = func(_ context.Context) (cloudRunLister, error) { - return stubCloudRunLister{ - services: []cloudrun.Service{ - { - Name: "hello-world", - URI: "https://hello-world.run.app", - Revisions: []cloudrun.Revision{ - { - Name: "hello-world-rev1", - Digests: map[string]string{"gcr.io/x/hello@sha256:abc": "abc"}, - CreatedAt: time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC), - }, - }, - }, - }, - }, nil + return stubCloudRunLister{services: stubServices()}, nil } } @@ -88,13 +104,72 @@ func (suite *SnapshotCloudRunTestSuite) TestSnapshotCloudRunCmd() { { name: "snapshot cloud-run dry-runs the report URL and payload built from the GCP client", cmd: fmt.Sprintf(`snapshot cloud-run %s --project proj-x --region europe-west1 %s`, suite.envName, suite.defaultKosliArguments), - goldenRegex: `(?s)THIS IS A DRY-RUN.*report/cloud-run.*"revisionName": "hello-world-rev1".*"service_name": "hello-world".*"project": "proj-x".*"region": "europe-west1".*"gcr.io/x/hello@sha256:abc": "abc"`, + goldenRegex: `(?s)THIS IS A DRY-RUN.*report/cloud-run.*"service_name": "alpha".*"service_name": "beta"`, + }, + { + wantError: true, + name: "snapshot cloud-run fails if --services and --exclude are set", + cmd: fmt.Sprintf(`snapshot cloud-run %s --project p --region r --services alpha --exclude beta %s`, suite.envName, suite.defaultKosliArguments), + golden: "Error: only one of --services, --exclude is allowed\n", + }, + { + wantError: true, + name: "snapshot cloud-run fails if --services and --exclude-regex are set", + cmd: fmt.Sprintf(`snapshot cloud-run %s --project p --region r --services alpha --exclude-regex "^b" %s`, suite.envName, suite.defaultKosliArguments), + golden: "Error: only one of --services, --exclude-regex is allowed\n", + }, + { + wantError: true, + name: "snapshot cloud-run fails if --services-regex and --exclude are set", + cmd: fmt.Sprintf(`snapshot cloud-run %s --project p --region r --services-regex "^a" --exclude beta %s`, suite.envName, suite.defaultKosliArguments), + golden: "Error: only one of --services-regex, --exclude is allowed\n", + }, + { + wantError: true, + name: "snapshot cloud-run fails if --services-regex and --exclude-regex are set", + cmd: fmt.Sprintf(`snapshot cloud-run %s --project p --region r --services-regex "^a" --exclude-regex "^b" %s`, suite.envName, suite.defaultKosliArguments), + golden: "Error: only one of --services-regex, --exclude-regex is allowed\n", }, } runTestCmd(suite.T(), tests) } +// runFilteredCmd executes the command and returns the combined output for +// substring assertions. Filter tests need to assert both presence (kept +// service appears) and absence (excluded service does not appear), so they +// cannot use the single-assertion cmdTestCase table. +func (suite *SnapshotCloudRunTestSuite) runFilteredCmd(filterArgs string) string { + cmd := fmt.Sprintf(`snapshot cloud-run %s --project p --region r %s %s`, suite.envName, filterArgs, suite.defaultKosliArguments) + _, combined, _, _, err := executeCommandC(cmd) + require.NoError(suite.T(), err, "command failed: %s", combined) + return combined +} + +func (suite *SnapshotCloudRunTestSuite) TestSnapshotCloudRunFilter_Services() { + out := suite.runFilteredCmd("--services alpha") + require.Contains(suite.T(), out, `"service_name": "alpha"`) + require.NotContains(suite.T(), out, `"service_name": "beta"`) +} + +func (suite *SnapshotCloudRunTestSuite) TestSnapshotCloudRunFilter_ServicesRegex() { + out := suite.runFilteredCmd(`--services-regex "^al"`) + require.Contains(suite.T(), out, `"service_name": "alpha"`) + require.NotContains(suite.T(), out, `"service_name": "beta"`) +} + +func (suite *SnapshotCloudRunTestSuite) TestSnapshotCloudRunFilter_Exclude() { + out := suite.runFilteredCmd("--exclude alpha") + require.NotContains(suite.T(), out, `"service_name": "alpha"`) + require.Contains(suite.T(), out, `"service_name": "beta"`) +} + +func (suite *SnapshotCloudRunTestSuite) TestSnapshotCloudRunFilter_ExcludeRegex() { + out := suite.runFilteredCmd(`--exclude-regex "^al"`) + require.NotContains(suite.T(), out, `"service_name": "alpha"`) + require.Contains(suite.T(), out, `"service_name": "beta"`) +} + func TestSnapshotCloudRunCommandTestSuite(t *testing.T) { suite.Run(t, new(SnapshotCloudRunTestSuite)) } diff --git a/docs/handover/2026-04-28-4986-google-cloud-run-1.md b/docs/handover/2026-04-28-4986-google-cloud-run-1.md index 5d11023e9..de6ed6a85 100644 --- a/docs/handover/2026-04-28-4986-google-cloud-run-1.md +++ b/docs/handover/2026-04-28-4986-google-cloud-run-1.md @@ -46,7 +46,7 @@ Slice plan (each slice is a separate, independently-mergeable branch): - [x] **Slice 1 (this branch):** Skeleton command — `cmd/kosli/snapshotCloudRun.go` (Hidden, forced dry-run, stub `RunE`), register in `snapshot.go`, arg/flag validation tests. Done 2026-04-28: 5 cmdTestCase tests passing, `make lint` clean, hidden from `snapshot --help` but reachable directly. - [x] **Slice 2:** Internal `internal/cloudrun` package — wraps `cloud.google.com/go/run/apiv2` to list services in project+region; unit-tested with a fake. Done 2026-04-28: `Client.ListServices` returns `Service{Name, URI, Revisions}` with one `Revision{Name, Digests, CreatedAt}` per traffic-configured revision (any percent including 0%, with `LATEST` resolved via `LatestReadyRevision` and dupes removed). Digest extraction mirrors the ECS fallback (`@sha256:` parse, else empty string). 9 unit tests passing. - [x] **Slice 3:** End-to-end happy path — wire the package into `RunE`, build the snapshot payload, POST to the server `cloud-run` endpoint (still dry-run only). Done 2026-04-28: command now calls `cloudrun.New` + `ListServices`, builds an `EnvRequest` via `ToEnvRequest(services, project, region)`, and submits PUT `report/cloud-run` via `kosliClient.Do` (dry-run forced, so no network call leaves the client). Tested against the real `hello-world-cli-demo` GCP project — emits a digest-pinned artifact for the running `hello-world` service. -- [ ] **Slice 4:** Filtering flags — `--services`, `--services-regex`, `--exclude`, `--exclude-regex`. +- [x] **Slice 4:** Filtering flags — `--services`, `--services-regex`, `--exclude`, `--exclude-regex`. Done 2026-04-28: backed by `filters.ResourceFilterOptions` (same struct ECS uses); 4 mutex pairs validated in `PreRunE`. Filter is applied in the command after `cloudrun.ListServices` returns — services excluded by name still cost their revision-fetch round-trips. If that becomes a bottleneck, push the filter into `cloudrun.ListServices` so excluded services skip the per-revision API calls. - [ ] **Slice 5:** Multi-revision / traffic splitting — handle services with multiple active revisions and services with no active revisions. - [ ] **Slice 6:** Auth error UX — clear messages for ADC / `GOOGLE_APPLICATION_CREDENTIALS` failures and for missing project/region. - [ ] **Slice 7:** Unhide the command, lift the forced dry-run, update CLI reference docs and examples. From 176df4e0ba58d4d12b92336bbd6f4798380acf89 Mon Sep 17 00:00:00 2001 From: Tore Martin Hagen Date: Tue, 28 Apr 2026 13:38:45 +0200 Subject: [PATCH 5/9] feat(cloudrun): friendly auth and not-found error messages (#4986) Slice 6 of the snapshot cloud-run feature. cloudrun.Classify maps gRPC Unauthenticated / PermissionDenied / NotFound responses into actionable messages and preserves the underlying error via %w; other codes pass through. The Unauthenticated message names all three credential sources (env var, gcloud command, GCE/GKE metadata server or Workload Identity) because the production deployment is a cluster cron job, not a local-dev gcloud session. Classification lives in internal/cloudrun (the package owns GCP knowledge) but is applied at the command boundary, not inside Client.ListServices. Doing it inside the Client would double-wrap real-call errors when the command also classified them, and it would bypass the stubbed test path entirely. Calling Classify once at the command boundary covers both real and stub error sources. cloudrun.New now wraps construction errors with a generic "GCP client setup failed" prefix; the cluster case rarely fails here, and the SDK message is preserved for diagnosis. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/kosli/snapshotCloudRun.go | 2 +- cmd/kosli/snapshotCloudRun_test.go | 18 ++++++ .../2026-04-28-4986-google-cloud-run-1.md | 5 +- go.mod | 2 +- internal/cloudrun/cloudrun.go | 9 ++- internal/cloudrun/errors.go | 45 ++++++++++++++ internal/cloudrun/errors_test.go | 61 +++++++++++++++++++ 7 files changed, 135 insertions(+), 7 deletions(-) create mode 100644 internal/cloudrun/errors.go create mode 100644 internal/cloudrun/errors_test.go diff --git a/cmd/kosli/snapshotCloudRun.go b/cmd/kosli/snapshotCloudRun.go index c46a59121..7f24a212b 100644 --- a/cmd/kosli/snapshotCloudRun.go +++ b/cmd/kosli/snapshotCloudRun.go @@ -92,7 +92,7 @@ func (o *snapshotCloudRunOptions) run(args []string) error { } services, err := client.ListServices(ctx, o.project, o.region) if err != nil { - return err + return cloudrun.Classify(err, o.project, o.region) } filtered := services[:0] diff --git a/cmd/kosli/snapshotCloudRun_test.go b/cmd/kosli/snapshotCloudRun_test.go index 865874aef..a13438d93 100644 --- a/cmd/kosli/snapshotCloudRun_test.go +++ b/cmd/kosli/snapshotCloudRun_test.go @@ -9,6 +9,8 @@ import ( "github.com/kosli-dev/cli/internal/cloudrun" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) type stubCloudRunLister struct { @@ -170,6 +172,22 @@ func (suite *SnapshotCloudRunTestSuite) TestSnapshotCloudRunFilter_ExcludeRegex( require.Contains(suite.T(), out, `"service_name": "beta"`) } +// TestSnapshotCloudRunCmd_UnauthenticatedReturnsFriendlyError verifies that a +// gRPC Unauthenticated error from GCP surfaces as the actionable ADC message +// rather than a raw SDK string. +func (suite *SnapshotCloudRunTestSuite) TestSnapshotCloudRunCmd_UnauthenticatedReturnsFriendlyError() { + newCloudRunClient = func(_ context.Context) (cloudRunLister, error) { + return stubCloudRunLister{err: status.Error(codes.Unauthenticated, "token expired")}, nil + } + + cmd := fmt.Sprintf(`snapshot cloud-run %s --project p --region r %s`, suite.envName, suite.defaultKosliArguments) + _, combined, _, _, err := executeCommandC(cmd) + + require.Error(suite.T(), err) + require.Contains(suite.T(), combined, "GCP authentication failed") + require.Contains(suite.T(), combined, "metadata server") +} + func TestSnapshotCloudRunCommandTestSuite(t *testing.T) { suite.Run(t, new(SnapshotCloudRunTestSuite)) } diff --git a/docs/handover/2026-04-28-4986-google-cloud-run-1.md b/docs/handover/2026-04-28-4986-google-cloud-run-1.md index de6ed6a85..774f096fc 100644 --- a/docs/handover/2026-04-28-4986-google-cloud-run-1.md +++ b/docs/handover/2026-04-28-4986-google-cloud-run-1.md @@ -36,6 +36,7 @@ GCP test environment (provisioned, ADC already configured on this machine): - Digest extraction follows the ECS pattern (`internal/aws/aws.go:670-693`): use a `@sha256:` substring if present, else leave the digest empty rather than calling Artifact Registry. Registry-lookup mode (analogous to Azure's `--digests-source acr`) is deferred until customers ask for it. - Wire payload mirrors `EcsEnvRequest` plus `project` and `region` fields per artifact (so each row is self-describing). Endpoint name is `report/cloud-run` (kebab-case, parallels `report/azure-apps`). Server-side endpoint does not yet exist; the forced dry-run means no network call is made. - Command depends on a local `cloudRunLister` interface and a package-level `newCloudRunClient` variable so tests can substitute a stub without touching ADC. The seam stays in `cmd/kosli/snapshotCloudRun.go` rather than being exposed from `internal/cloudrun` — keeps the public package surface minimal. +- Error classification (`Classify`) lives in `internal/cloudrun` (GCP knowledge belongs to the package) but is *applied* at the command layer, not inside `Client.ListServices`. Why: applying it inside `ListServices` would double-wrap real-call errors when the command also classified them, and bypass the friendly path entirely for stub-driven tests. Calling it once at the command boundary covers both real and stub error sources. --- @@ -47,6 +48,6 @@ Slice plan (each slice is a separate, independently-mergeable branch): - [x] **Slice 2:** Internal `internal/cloudrun` package — wraps `cloud.google.com/go/run/apiv2` to list services in project+region; unit-tested with a fake. Done 2026-04-28: `Client.ListServices` returns `Service{Name, URI, Revisions}` with one `Revision{Name, Digests, CreatedAt}` per traffic-configured revision (any percent including 0%, with `LATEST` resolved via `LatestReadyRevision` and dupes removed). Digest extraction mirrors the ECS fallback (`@sha256:` parse, else empty string). 9 unit tests passing. - [x] **Slice 3:** End-to-end happy path — wire the package into `RunE`, build the snapshot payload, POST to the server `cloud-run` endpoint (still dry-run only). Done 2026-04-28: command now calls `cloudrun.New` + `ListServices`, builds an `EnvRequest` via `ToEnvRequest(services, project, region)`, and submits PUT `report/cloud-run` via `kosliClient.Do` (dry-run forced, so no network call leaves the client). Tested against the real `hello-world-cli-demo` GCP project — emits a digest-pinned artifact for the running `hello-world` service. - [x] **Slice 4:** Filtering flags — `--services`, `--services-regex`, `--exclude`, `--exclude-regex`. Done 2026-04-28: backed by `filters.ResourceFilterOptions` (same struct ECS uses); 4 mutex pairs validated in `PreRunE`. Filter is applied in the command after `cloudrun.ListServices` returns — services excluded by name still cost their revision-fetch round-trips. If that becomes a bottleneck, push the filter into `cloudrun.ListServices` so excluded services skip the per-revision API calls. -- [ ] **Slice 5:** Multi-revision / traffic splitting — handle services with multiple active revisions and services with no active revisions. -- [ ] **Slice 6:** Auth error UX — clear messages for ADC / `GOOGLE_APPLICATION_CREDENTIALS` failures and for missing project/region. +- [x] **Slice 5:** ~~Multi-revision / traffic splitting — handle services with multiple active revisions and services with no active revisions.~~ Dropped 2026-04-28: multi-revision (traffic splitting), `LATEST` resolution, and dedup were all completed in Slice 2; the only remaining edge case (services with no active revisions emitting a placeholder artifact) was deferred until the server-side wire contract is defined, since picking the format unilaterally now risks rework. Re-open as a small slice once the server contract lands. +- [x] **Slice 6:** Auth error UX — clear messages for ADC / `GOOGLE_APPLICATION_CREDENTIALS` failures and for missing project/region. Done 2026-04-28: `cloudrun.Classify(err, project, region)` maps gRPC `Unauthenticated` → ADC advice, `PermissionDenied` → `roles/run.viewer` advice, `NotFound` → "project or region not found"; other codes pass through. Auth message names all three credential sources (env var, `gcloud auth application-default login`, GCE/GKE metadata server / Workload Identity) since the production deployment is a GKE cron job. `cloudrun.New(ctx)` errors get a generic "GCP client setup failed" prefix. - [ ] **Slice 7:** Unhide the command, lift the forced dry-run, update CLI reference docs and examples. diff --git a/go.mod b/go.mod index 9cb23ccb6..1320f5b02 100644 --- a/go.mod +++ b/go.mod @@ -47,6 +47,7 @@ require ( golang.org/x/oauth2 v0.36.0 golang.org/x/term v0.42.0 google.golang.org/api v0.274.0 + google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.35.0 @@ -231,7 +232,6 @@ require ( google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/grpc v1.80.0 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect diff --git a/internal/cloudrun/cloudrun.go b/internal/cloudrun/cloudrun.go index 1679591ff..b296287bf 100644 --- a/internal/cloudrun/cloudrun.go +++ b/internal/cloudrun/cloudrun.go @@ -46,15 +46,18 @@ type Client struct { } // New returns a Client backed by the real Cloud Run Admin API v2 using -// Application Default Credentials. +// Application Default Credentials. Construction errors (typically rare in a +// cluster cron job, since the metadata server provides credentials) are +// wrapped with a generic "GCP client setup failed" prefix; the SDK's own +// message is preserved via %w for diagnosis. func New(ctx context.Context) (*Client, error) { services, err := run.NewServicesClient(ctx) if err != nil { - return nil, fmt.Errorf("creating Cloud Run services client: %w", err) + return nil, fmt.Errorf("GCP client setup failed: %w", err) } revisions, err := run.NewRevisionsClient(ctx) if err != nil { - return nil, fmt.Errorf("creating Cloud Run revisions client: %w", err) + return nil, fmt.Errorf("GCP client setup failed: %w", err) } return &Client{api: &gcpAPI{services: services, revisions: revisions}}, nil } diff --git a/internal/cloudrun/errors.go b/internal/cloudrun/errors.go new file mode 100644 index 000000000..d683c6b3c --- /dev/null +++ b/internal/cloudrun/errors.go @@ -0,0 +1,45 @@ +package cloudrun + +import ( + "fmt" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// Classify wraps a Cloud Run SDK error with a user-actionable message based on +// the gRPC status code. Errors without a recognised code (or non-gRPC errors) +// pass through unchanged so callers can still inspect them. +// +// project and region are interpolated into messages where they help the user +// localise the failure (e.g. NotFound on a misspelled project). +func Classify(err error, project, region string) error { + if err == nil { + return nil + } + s, ok := status.FromError(err) + if !ok { + return err + } + switch s.Code() { + case codes.Unauthenticated: + return fmt.Errorf( + "GCP authentication failed: ensure Application Default Credentials are available "+ + "(GOOGLE_APPLICATION_CREDENTIALS, 'gcloud auth application-default login', "+ + "or GCE/GKE metadata server / Workload Identity) (underlying error: %w)", + err, + ) + case codes.PermissionDenied: + return fmt.Errorf( + "GCP permission denied: the caller needs 'roles/run.viewer' on project %q (underlying error: %w)", + project, err, + ) + case codes.NotFound: + return fmt.Errorf( + "GCP project %q or region %q not found or not accessible (underlying error: %w)", + project, region, err, + ) + default: + return err + } +} diff --git a/internal/cloudrun/errors_test.go b/internal/cloudrun/errors_test.go new file mode 100644 index 000000000..ad5065898 --- /dev/null +++ b/internal/cloudrun/errors_test.go @@ -0,0 +1,61 @@ +package cloudrun + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestClassify_NilStaysNil(t *testing.T) { + require.NoError(t, Classify(nil, "p", "r")) +} + +func TestClassify_NonGRPCErrorPassesThrough(t *testing.T) { + original := errors.New("some plain go error") + got := Classify(original, "p", "r") + require.Same(t, original, got) +} + +func TestClassify_UnknownGRPCCodePassesThrough(t *testing.T) { + original := status.Error(codes.ResourceExhausted, "rate limited") + got := Classify(original, "p", "r") + require.Same(t, original, got) +} + +func TestClassify_UnauthenticatedReturnsADCAdvice(t *testing.T) { + original := status.Error(codes.Unauthenticated, "token expired") + got := Classify(original, "proj-1", "europe-west1") + + require.Error(t, got) + require.Contains(t, got.Error(), "GCP authentication failed") + require.Contains(t, got.Error(), "GOOGLE_APPLICATION_CREDENTIALS") + require.Contains(t, got.Error(), "gcloud auth application-default login") + require.Contains(t, got.Error(), "metadata server") + require.Contains(t, got.Error(), "Workload Identity") + require.ErrorIs(t, got, original, "underlying error must be preserved via %%w") +} + +func TestClassify_PermissionDeniedNamesProjectAndRoleViewer(t *testing.T) { + original := status.Error(codes.PermissionDenied, "missing iam role") + got := Classify(original, "proj-1", "europe-west1") + + require.Error(t, got) + require.Contains(t, got.Error(), "GCP permission denied") + require.Contains(t, got.Error(), "roles/run.viewer") + require.Contains(t, got.Error(), `"proj-1"`) + require.ErrorIs(t, got, original) +} + +func TestClassify_NotFoundNamesProjectAndRegion(t *testing.T) { + original := status.Error(codes.NotFound, "no such resource") + got := Classify(original, "bad-project", "europe-west1") + + require.Error(t, got) + require.Contains(t, got.Error(), "not found or not accessible") + require.Contains(t, got.Error(), `"bad-project"`) + require.Contains(t, got.Error(), `"europe-west1"`) + require.ErrorIs(t, got, original) +} From fa367b34934f8ea14a6cdea021f1bafa4b006c3f Mon Sep 17 00:00:00 2001 From: Tore Martin Hagen Date: Tue, 28 Apr 2026 14:39:49 +0200 Subject: [PATCH 6/9] fix(cloudrun): align snapshot payload with server's snapshot-examples doc (#4986) Three changes brings the payload into line with the conventions documented in the server repo's out-snapshot-examples.txt: - Added the top-level "type": "cloud-run" literal that every other env-type report ships explicitly. Without it, the (still-to-come) CloudRunReport model on the server would only accept the URL default; with it, the request is unambiguous. - Renamed the per-artifact "service_name" to "serviceName" (camelCase) matching the doc's K8S/ECS examples. The existing CLI's ECS code uses snake_case, but a new type is better off following the doc. - Dropped the per-artifact "project" and "region" fields. The doc notes extra="forbid" on every Pydantic model, so unilaterally picking custom field names risks 422 once the server defines its CloudRunReport. Project + region are already in the URL and flags, mirroring how ECS reports don't carry account/region per artifact. Verified end-to-end against the hello-world-cli-demo project; payload now matches the doc shape exactly. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/kosli/snapshotCloudRun.go | 2 +- cmd/kosli/snapshotCloudRun_test.go | 18 ++++---- .../2026-04-28-4986-google-cloud-run-1.md | 5 ++- internal/cloudrun/payload.go | 21 ++++++---- internal/cloudrun/payload_test.go | 41 +++++++------------ 5 files changed, 40 insertions(+), 47 deletions(-) diff --git a/cmd/kosli/snapshotCloudRun.go b/cmd/kosli/snapshotCloudRun.go index 7f24a212b..c7927faa4 100644 --- a/cmd/kosli/snapshotCloudRun.go +++ b/cmd/kosli/snapshotCloudRun.go @@ -106,7 +106,7 @@ func (o *snapshotCloudRunOptions) run(args []string) error { } } - payload := cloudrun.ToEnvRequest(filtered, o.project, o.region) + payload := cloudrun.ToEnvRequest(filtered) reqParams := &requests.RequestParams{ Method: http.MethodPut, diff --git a/cmd/kosli/snapshotCloudRun_test.go b/cmd/kosli/snapshotCloudRun_test.go index a13438d93..c85cb97cb 100644 --- a/cmd/kosli/snapshotCloudRun_test.go +++ b/cmd/kosli/snapshotCloudRun_test.go @@ -106,7 +106,7 @@ func (suite *SnapshotCloudRunTestSuite) TestSnapshotCloudRunCmd() { { name: "snapshot cloud-run dry-runs the report URL and payload built from the GCP client", cmd: fmt.Sprintf(`snapshot cloud-run %s --project proj-x --region europe-west1 %s`, suite.envName, suite.defaultKosliArguments), - goldenRegex: `(?s)THIS IS A DRY-RUN.*report/cloud-run.*"service_name": "alpha".*"service_name": "beta"`, + goldenRegex: `(?s)THIS IS A DRY-RUN.*report/cloud-run.*"type": "cloud-run".*"serviceName": "alpha".*"serviceName": "beta"`, }, { wantError: true, @@ -150,26 +150,26 @@ func (suite *SnapshotCloudRunTestSuite) runFilteredCmd(filterArgs string) string func (suite *SnapshotCloudRunTestSuite) TestSnapshotCloudRunFilter_Services() { out := suite.runFilteredCmd("--services alpha") - require.Contains(suite.T(), out, `"service_name": "alpha"`) - require.NotContains(suite.T(), out, `"service_name": "beta"`) + require.Contains(suite.T(), out, `"serviceName": "alpha"`) + require.NotContains(suite.T(), out, `"serviceName": "beta"`) } func (suite *SnapshotCloudRunTestSuite) TestSnapshotCloudRunFilter_ServicesRegex() { out := suite.runFilteredCmd(`--services-regex "^al"`) - require.Contains(suite.T(), out, `"service_name": "alpha"`) - require.NotContains(suite.T(), out, `"service_name": "beta"`) + require.Contains(suite.T(), out, `"serviceName": "alpha"`) + require.NotContains(suite.T(), out, `"serviceName": "beta"`) } func (suite *SnapshotCloudRunTestSuite) TestSnapshotCloudRunFilter_Exclude() { out := suite.runFilteredCmd("--exclude alpha") - require.NotContains(suite.T(), out, `"service_name": "alpha"`) - require.Contains(suite.T(), out, `"service_name": "beta"`) + require.NotContains(suite.T(), out, `"serviceName": "alpha"`) + require.Contains(suite.T(), out, `"serviceName": "beta"`) } func (suite *SnapshotCloudRunTestSuite) TestSnapshotCloudRunFilter_ExcludeRegex() { out := suite.runFilteredCmd(`--exclude-regex "^al"`) - require.NotContains(suite.T(), out, `"service_name": "alpha"`) - require.Contains(suite.T(), out, `"service_name": "beta"`) + require.NotContains(suite.T(), out, `"serviceName": "alpha"`) + require.Contains(suite.T(), out, `"serviceName": "beta"`) } // TestSnapshotCloudRunCmd_UnauthenticatedReturnsFriendlyError verifies that a diff --git a/docs/handover/2026-04-28-4986-google-cloud-run-1.md b/docs/handover/2026-04-28-4986-google-cloud-run-1.md index 774f096fc..adb4687c4 100644 --- a/docs/handover/2026-04-28-4986-google-cloud-run-1.md +++ b/docs/handover/2026-04-28-4986-google-cloud-run-1.md @@ -34,7 +34,7 @@ GCP test environment (provisioned, ADC already configured on this machine): - Package named `internal/cloudrun` (not `gcprun`) to mirror the user-facing command name `snapshot cloud-run`. If GCP integrations expand later we'll either rename or split into `internal/gcp/run`. - `internal/cloudrun` reports all revisions referenced in `service.traffic[]` regardless of percent (including 0%). Trade-off: matches the user's framing of "running or could run" without dragging in retired revisions that aren't currently configured for traffic; canary 90/10 splits surface both revisions naturally. - Digest extraction follows the ECS pattern (`internal/aws/aws.go:670-693`): use a `@sha256:` substring if present, else leave the digest empty rather than calling Artifact Registry. Registry-lookup mode (analogous to Azure's `--digests-source acr`) is deferred until customers ask for it. -- Wire payload mirrors `EcsEnvRequest` plus `project` and `region` fields per artifact (so each row is self-describing). Endpoint name is `report/cloud-run` (kebab-case, parallels `report/azure-apps`). Server-side endpoint does not yet exist; the forced dry-run means no network call is made. +- Wire payload follows the server's `out-snapshot-examples.txt` reference: top-level `{"type": "cloud-run", "artifacts": [...]}` with camelCase per-artifact fields (`revisionName`, `serviceName`, `digests`, `creationTimestamp`). Endpoint is `report/cloud-run` (kebab-case, parallels `report/azure-apps`). Server-side endpoint does not yet exist; the forced dry-run means no network call is made. Initial design (Slice 3) added `project`/`region` per artifact mirroring ECS's `cluster_name`; reverted in Slice 3.5 because the doc specifies `extra="forbid"` on every Pydantic model and project/region are derivable from the URL + flags. - Command depends on a local `cloudRunLister` interface and a package-level `newCloudRunClient` variable so tests can substitute a stub without touching ADC. The seam stays in `cmd/kosli/snapshotCloudRun.go` rather than being exposed from `internal/cloudrun` — keeps the public package surface minimal. - Error classification (`Classify`) lives in `internal/cloudrun` (GCP knowledge belongs to the package) but is *applied* at the command layer, not inside `Client.ListServices`. Why: applying it inside `ListServices` would double-wrap real-call errors when the command also classified them, and bypass the friendly path entirely for stub-driven tests. Calling it once at the command boundary covers both real and stub error sources. @@ -46,7 +46,8 @@ Slice plan (each slice is a separate, independently-mergeable branch): - [x] **Slice 1 (this branch):** Skeleton command — `cmd/kosli/snapshotCloudRun.go` (Hidden, forced dry-run, stub `RunE`), register in `snapshot.go`, arg/flag validation tests. Done 2026-04-28: 5 cmdTestCase tests passing, `make lint` clean, hidden from `snapshot --help` but reachable directly. - [x] **Slice 2:** Internal `internal/cloudrun` package — wraps `cloud.google.com/go/run/apiv2` to list services in project+region; unit-tested with a fake. Done 2026-04-28: `Client.ListServices` returns `Service{Name, URI, Revisions}` with one `Revision{Name, Digests, CreatedAt}` per traffic-configured revision (any percent including 0%, with `LATEST` resolved via `LatestReadyRevision` and dupes removed). Digest extraction mirrors the ECS fallback (`@sha256:` parse, else empty string). 9 unit tests passing. -- [x] **Slice 3:** End-to-end happy path — wire the package into `RunE`, build the snapshot payload, POST to the server `cloud-run` endpoint (still dry-run only). Done 2026-04-28: command now calls `cloudrun.New` + `ListServices`, builds an `EnvRequest` via `ToEnvRequest(services, project, region)`, and submits PUT `report/cloud-run` via `kosliClient.Do` (dry-run forced, so no network call leaves the client). Tested against the real `hello-world-cli-demo` GCP project — emits a digest-pinned artifact for the running `hello-world` service. +- [x] **Slice 3:** End-to-end happy path — wire the package into `RunE`, build the snapshot payload, POST to the server `cloud-run` endpoint (still dry-run only). Done 2026-04-28: command now calls `cloudrun.New` + `ListServices`, builds an `EnvRequest` via `ToEnvRequest(services)`, and submits PUT `report/cloud-run` via `kosliClient.Do` (dry-run forced, so no network call leaves the client). Tested against the real `hello-world-cli-demo` GCP project — emits a digest-pinned artifact for the running `hello-world` service. +- [x] **Slice 3.5:** Align payload with the server's snapshot-examples doc. Done 2026-04-28: added top-level `"type": "cloud-run"`; renamed `service_name` → `serviceName` (camelCase, matching the doc's K8S/ECS examples); dropped per-artifact `project` and `region` (would be rejected by `extra="forbid"` once the server defines a `CloudRunReport` model — they're identifiable from the URL + flags anyway, like ECS region). - [x] **Slice 4:** Filtering flags — `--services`, `--services-regex`, `--exclude`, `--exclude-regex`. Done 2026-04-28: backed by `filters.ResourceFilterOptions` (same struct ECS uses); 4 mutex pairs validated in `PreRunE`. Filter is applied in the command after `cloudrun.ListServices` returns — services excluded by name still cost their revision-fetch round-trips. If that becomes a bottleneck, push the filter into `cloudrun.ListServices` so excluded services skip the per-revision API calls. - [x] **Slice 5:** ~~Multi-revision / traffic splitting — handle services with multiple active revisions and services with no active revisions.~~ Dropped 2026-04-28: multi-revision (traffic splitting), `LATEST` resolution, and dedup were all completed in Slice 2; the only remaining edge case (services with no active revisions emitting a placeholder artifact) was deferred until the server-side wire contract is defined, since picking the format unilaterally now risks rework. Re-open as a small slice once the server contract lands. - [x] **Slice 6:** Auth error UX — clear messages for ADC / `GOOGLE_APPLICATION_CREDENTIALS` failures and for missing project/region. Done 2026-04-28: `cloudrun.Classify(err, project, region)` maps gRPC `Unauthenticated` → ADC advice, `PermissionDenied` → `roles/run.viewer` advice, `NotFound` → "project or region not found"; other codes pass through. Auth message names all three credential sources (env var, `gcloud auth application-default login`, GCE/GKE metadata server / Workload Identity) since the production deployment is a GKE cron job. `cloudrun.New(ctx)` errors get a generic "GCP client setup failed" prefix. diff --git a/internal/cloudrun/payload.go b/internal/cloudrun/payload.go index a0b932a60..85e90640e 100644 --- a/internal/cloudrun/payload.go +++ b/internal/cloudrun/payload.go @@ -1,8 +1,15 @@ package cloudrun +// reportType is the literal sent in the top-level "type" field, mirroring the +// "K8S", "ECS", "azure-apps", … values used by the other env-type reports. +const reportType = "cloud-run" + // EnvRequest is the PUT body sent to the Kosli "report/cloud-run" endpoint. -// It mirrors the shape of EcsEnvRequest in internal/aws. +// Field naming mirrors the conventions documented in the server's +// out-snapshot-examples.txt (top-level "type" + "artifacts", camelCase +// per-artifact fields). type EnvRequest struct { + Type string `json:"type"` Artifacts []*RevisionData `json:"artifacts"` } @@ -10,9 +17,7 @@ type EnvRequest struct { // One artifact is emitted per revision in each service's traffic configuration. type RevisionData struct { RevisionName string `json:"revisionName"` - Service string `json:"service_name,omitempty"` - Project string `json:"project,omitempty"` - Region string `json:"region,omitempty"` + ServiceName string `json:"serviceName,omitempty"` Digests map[string]string `json:"digests"` CreatedAt int64 `json:"creationTimestamp"` } @@ -20,19 +25,17 @@ type RevisionData struct { // ToEnvRequest flattens services into a list of revision artifacts. Services // with no revisions contribute nothing, mirroring the ECS behaviour of // services with no running tasks. -func ToEnvRequest(services []Service, project, region string) *EnvRequest { +func ToEnvRequest(services []Service) *EnvRequest { artifacts := []*RevisionData{} for _, svc := range services { for _, rev := range svc.Revisions { artifacts = append(artifacts, &RevisionData{ RevisionName: rev.Name, - Service: svc.Name, - Project: project, - Region: region, + ServiceName: svc.Name, Digests: rev.Digests, CreatedAt: rev.CreatedAt.Unix(), }) } } - return &EnvRequest{Artifacts: artifacts} + return &EnvRequest{Type: reportType, Artifacts: artifacts} } diff --git a/internal/cloudrun/payload_test.go b/internal/cloudrun/payload_test.go index ba08cbaa5..f30fa474d 100644 --- a/internal/cloudrun/payload_test.go +++ b/internal/cloudrun/payload_test.go @@ -7,8 +7,13 @@ import ( "github.com/stretchr/testify/require" ) +func TestToEnvRequest_TypeIsCloudRun(t *testing.T) { + got := ToEnvRequest(nil) + require.Equal(t, "cloud-run", got.Type) +} + func TestToEnvRequest_EmptyInput(t *testing.T) { - got := ToEnvRequest(nil, "p", "r") + got := ToEnvRequest(nil) require.NotNil(t, got) require.Empty(t, got.Artifacts) } @@ -29,14 +34,12 @@ func TestToEnvRequest_SingleServiceSingleRevision(t *testing.T) { }, } - got := ToEnvRequest(services, "proj-1", "europe-west1") + got := ToEnvRequest(services) require.Len(t, got.Artifacts, 1) art := got.Artifacts[0] require.Equal(t, "svc-a-rev1", art.RevisionName) - require.Equal(t, "svc-a", art.Service) - require.Equal(t, "proj-1", art.Project) - require.Equal(t, "europe-west1", art.Region) + require.Equal(t, "svc-a", art.ServiceName) require.Equal(t, map[string]string{"img@sha256:aaa": "aaa"}, art.Digests) require.Equal(t, created.Unix(), art.CreatedAt) } @@ -58,15 +61,15 @@ func TestToEnvRequest_MultipleServicesMultipleRevisions(t *testing.T) { }, } - got := ToEnvRequest(services, "proj-1", "europe-west1") + got := ToEnvRequest(services) require.Len(t, got.Artifacts, 3) revisionNames := []string{got.Artifacts[0].RevisionName, got.Artifacts[1].RevisionName, got.Artifacts[2].RevisionName} require.Equal(t, []string{"a-rev1", "a-rev2", "b-rev1"}, revisionNames) - require.Equal(t, "svc-a", got.Artifacts[0].Service) - require.Equal(t, "svc-a", got.Artifacts[1].Service) - require.Equal(t, "svc-b", got.Artifacts[2].Service) + require.Equal(t, "svc-a", got.Artifacts[0].ServiceName) + require.Equal(t, "svc-a", got.Artifacts[1].ServiceName) + require.Equal(t, "svc-b", got.Artifacts[2].ServiceName) } func TestToEnvRequest_ServiceWithNoRevisionsContributesNothing(t *testing.T) { @@ -80,23 +83,9 @@ func TestToEnvRequest_ServiceWithNoRevisionsContributesNothing(t *testing.T) { }, } - got := ToEnvRequest(services, "p", "r") + got := ToEnvRequest(services) require.Len(t, got.Artifacts, 1) - require.Equal(t, "svc-a", got.Artifacts[0].Service) -} - -func TestToEnvRequest_ProjectAndRegionOnEveryArtifact(t *testing.T) { - services := []Service{ - {Name: "a", Revisions: []Revision{{Name: "a1"}}}, - {Name: "b", Revisions: []Revision{{Name: "b1"}}}, - } - - got := ToEnvRequest(services, "proj-x", "us-central1") - - for _, art := range got.Artifacts { - require.Equal(t, "proj-x", art.Project) - require.Equal(t, "us-central1", art.Region) - } + require.Equal(t, "svc-a", got.Artifacts[0].ServiceName) } func TestToEnvRequest_ZeroCreatedAtSerialisesAsZero(t *testing.T) { @@ -104,7 +93,7 @@ func TestToEnvRequest_ZeroCreatedAtSerialisesAsZero(t *testing.T) { {Name: "svc", Revisions: []Revision{{Name: "rev"}}}, } - got := ToEnvRequest(services, "p", "r") + got := ToEnvRequest(services) require.Len(t, got.Artifacts, 1) require.Equal(t, time.Time{}.Unix(), got.Artifacts[0].CreatedAt) } From cdb24292353930ca56bcdc0e47ba88632f52b435 Mon Sep 17 00:00:00 2001 From: Tore Martin Hagen Date: Wed, 29 Apr 2026 09:09:07 +0200 Subject: [PATCH 7/9] chore(cloudrun): use service_name (snake_case) and number test cases (#4986) The serviceName JSON tag was originally chosen to match the server's out-snapshot-examples doc, but the existing CLI's ECS code sends cluster_name/service_name on the wire and is in production today; Azure does the same with app_name/app_kind/digests_source. The doc disagrees with reality, so align with reality. The wire convention across types now is: unique-ID field camelCase (revisionName, taskArn, podName), grouping field snake_case (service_name, cluster_name, app_name), digests + creationTimestamp universal. Also numbered the cmdTestCase entries in TestSnapshotCloudRunCmd (01-09) to match the convention from attestSonar_test.go, which makes individual cases easier to spot when scanning suite output. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/kosli/snapshotCloudRun_test.go | 36 +++++++++---------- .../2026-04-28-4986-google-cloud-run-1.md | 2 +- internal/cloudrun/payload.go | 2 +- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/cmd/kosli/snapshotCloudRun_test.go b/cmd/kosli/snapshotCloudRun_test.go index c85cb97cb..dbdb08aeb 100644 --- a/cmd/kosli/snapshotCloudRun_test.go +++ b/cmd/kosli/snapshotCloudRun_test.go @@ -81,54 +81,54 @@ func (suite *SnapshotCloudRunTestSuite) TestSnapshotCloudRunCmd() { tests := []cmdTestCase{ { wantError: true, - name: "snapshot cloud-run fails if no args are provided", + name: "01 snapshot cloud-run fails if no args are provided", cmd: fmt.Sprintf(`snapshot cloud-run --project p --region r %s`, suite.defaultKosliArguments), golden: "Error: accepts 1 arg(s), received 0\n", }, { wantError: true, - name: "snapshot cloud-run fails if 2 args are provided", + name: "02 snapshot cloud-run fails if 2 args are provided", cmd: fmt.Sprintf(`snapshot cloud-run %s xxx --project p --region r %s`, suite.envName, suite.defaultKosliArguments), golden: "Error: accepts 1 arg(s), received 2\n", }, { wantError: true, - name: "snapshot cloud-run fails if --project is missing", + name: "03 snapshot cloud-run fails if --project is missing", cmd: fmt.Sprintf(`snapshot cloud-run %s --region r %s`, suite.envName, suite.defaultKosliArguments), golden: "Error: required flag(s) \"project\" not set\n", }, { wantError: true, - name: "snapshot cloud-run fails if --region is missing", + name: "04 snapshot cloud-run fails if --region is missing", cmd: fmt.Sprintf(`snapshot cloud-run %s --project p %s`, suite.envName, suite.defaultKosliArguments), golden: "Error: required flag(s) \"region\" not set\n", }, { - name: "snapshot cloud-run dry-runs the report URL and payload built from the GCP client", + name: "05 snapshot cloud-run dry-runs the report URL and payload built from the GCP client", cmd: fmt.Sprintf(`snapshot cloud-run %s --project proj-x --region europe-west1 %s`, suite.envName, suite.defaultKosliArguments), - goldenRegex: `(?s)THIS IS A DRY-RUN.*report/cloud-run.*"type": "cloud-run".*"serviceName": "alpha".*"serviceName": "beta"`, + goldenRegex: `(?s)THIS IS A DRY-RUN.*report/cloud-run.*"type": "cloud-run".*"service_name": "alpha".*"service_name": "beta"`, }, { wantError: true, - name: "snapshot cloud-run fails if --services and --exclude are set", + name: "06 snapshot cloud-run fails if --services and --exclude are set", cmd: fmt.Sprintf(`snapshot cloud-run %s --project p --region r --services alpha --exclude beta %s`, suite.envName, suite.defaultKosliArguments), golden: "Error: only one of --services, --exclude is allowed\n", }, { wantError: true, - name: "snapshot cloud-run fails if --services and --exclude-regex are set", + name: "07 snapshot cloud-run fails if --services and --exclude-regex are set", cmd: fmt.Sprintf(`snapshot cloud-run %s --project p --region r --services alpha --exclude-regex "^b" %s`, suite.envName, suite.defaultKosliArguments), golden: "Error: only one of --services, --exclude-regex is allowed\n", }, { wantError: true, - name: "snapshot cloud-run fails if --services-regex and --exclude are set", + name: "08 snapshot cloud-run fails if --services-regex and --exclude are set", cmd: fmt.Sprintf(`snapshot cloud-run %s --project p --region r --services-regex "^a" --exclude beta %s`, suite.envName, suite.defaultKosliArguments), golden: "Error: only one of --services-regex, --exclude is allowed\n", }, { wantError: true, - name: "snapshot cloud-run fails if --services-regex and --exclude-regex are set", + name: "09 snapshot cloud-run fails if --services-regex and --exclude-regex are set", cmd: fmt.Sprintf(`snapshot cloud-run %s --project p --region r --services-regex "^a" --exclude-regex "^b" %s`, suite.envName, suite.defaultKosliArguments), golden: "Error: only one of --services-regex, --exclude-regex is allowed\n", }, @@ -150,26 +150,26 @@ func (suite *SnapshotCloudRunTestSuite) runFilteredCmd(filterArgs string) string func (suite *SnapshotCloudRunTestSuite) TestSnapshotCloudRunFilter_Services() { out := suite.runFilteredCmd("--services alpha") - require.Contains(suite.T(), out, `"serviceName": "alpha"`) - require.NotContains(suite.T(), out, `"serviceName": "beta"`) + require.Contains(suite.T(), out, `"service_name": "alpha"`) + require.NotContains(suite.T(), out, `"service_name": "beta"`) } func (suite *SnapshotCloudRunTestSuite) TestSnapshotCloudRunFilter_ServicesRegex() { out := suite.runFilteredCmd(`--services-regex "^al"`) - require.Contains(suite.T(), out, `"serviceName": "alpha"`) - require.NotContains(suite.T(), out, `"serviceName": "beta"`) + require.Contains(suite.T(), out, `"service_name": "alpha"`) + require.NotContains(suite.T(), out, `"service_name": "beta"`) } func (suite *SnapshotCloudRunTestSuite) TestSnapshotCloudRunFilter_Exclude() { out := suite.runFilteredCmd("--exclude alpha") - require.NotContains(suite.T(), out, `"serviceName": "alpha"`) - require.Contains(suite.T(), out, `"serviceName": "beta"`) + require.NotContains(suite.T(), out, `"service_name": "alpha"`) + require.Contains(suite.T(), out, `"service_name": "beta"`) } func (suite *SnapshotCloudRunTestSuite) TestSnapshotCloudRunFilter_ExcludeRegex() { out := suite.runFilteredCmd(`--exclude-regex "^al"`) - require.NotContains(suite.T(), out, `"serviceName": "alpha"`) - require.Contains(suite.T(), out, `"serviceName": "beta"`) + require.NotContains(suite.T(), out, `"service_name": "alpha"`) + require.Contains(suite.T(), out, `"service_name": "beta"`) } // TestSnapshotCloudRunCmd_UnauthenticatedReturnsFriendlyError verifies that a diff --git a/docs/handover/2026-04-28-4986-google-cloud-run-1.md b/docs/handover/2026-04-28-4986-google-cloud-run-1.md index adb4687c4..172a4c9b7 100644 --- a/docs/handover/2026-04-28-4986-google-cloud-run-1.md +++ b/docs/handover/2026-04-28-4986-google-cloud-run-1.md @@ -47,7 +47,7 @@ Slice plan (each slice is a separate, independently-mergeable branch): - [x] **Slice 1 (this branch):** Skeleton command — `cmd/kosli/snapshotCloudRun.go` (Hidden, forced dry-run, stub `RunE`), register in `snapshot.go`, arg/flag validation tests. Done 2026-04-28: 5 cmdTestCase tests passing, `make lint` clean, hidden from `snapshot --help` but reachable directly. - [x] **Slice 2:** Internal `internal/cloudrun` package — wraps `cloud.google.com/go/run/apiv2` to list services in project+region; unit-tested with a fake. Done 2026-04-28: `Client.ListServices` returns `Service{Name, URI, Revisions}` with one `Revision{Name, Digests, CreatedAt}` per traffic-configured revision (any percent including 0%, with `LATEST` resolved via `LatestReadyRevision` and dupes removed). Digest extraction mirrors the ECS fallback (`@sha256:` parse, else empty string). 9 unit tests passing. - [x] **Slice 3:** End-to-end happy path — wire the package into `RunE`, build the snapshot payload, POST to the server `cloud-run` endpoint (still dry-run only). Done 2026-04-28: command now calls `cloudrun.New` + `ListServices`, builds an `EnvRequest` via `ToEnvRequest(services)`, and submits PUT `report/cloud-run` via `kosliClient.Do` (dry-run forced, so no network call leaves the client). Tested against the real `hello-world-cli-demo` GCP project — emits a digest-pinned artifact for the running `hello-world` service. -- [x] **Slice 3.5:** Align payload with the server's snapshot-examples doc. Done 2026-04-28: added top-level `"type": "cloud-run"`; renamed `service_name` → `serviceName` (camelCase, matching the doc's K8S/ECS examples); dropped per-artifact `project` and `region` (would be rejected by `extra="forbid"` once the server defines a `CloudRunReport` model — they're identifiable from the URL + flags anyway, like ECS region). +- [x] **Slice 3.5:** Align payload with the server's snapshot-examples doc. Done 2026-04-28: added top-level `"type": "cloud-run"`; first renamed `service_name` → `serviceName` (matching the *doc*'s K8S/ECS examples); then on 2026-04-29 reverted the rename to `service_name` because the actual wire format used by the existing ECS CLI is snake_case (the doc disagrees with reality on ECS). Final convention: unique-ID field camelCase (`revisionName`), grouping field snake_case (`service_name`), `digests` and `creationTimestamp` as universal. Per-artifact `project`/`region` stay dropped (would be rejected by `extra="forbid"` once the server defines a `CloudRunReport` model). - [x] **Slice 4:** Filtering flags — `--services`, `--services-regex`, `--exclude`, `--exclude-regex`. Done 2026-04-28: backed by `filters.ResourceFilterOptions` (same struct ECS uses); 4 mutex pairs validated in `PreRunE`. Filter is applied in the command after `cloudrun.ListServices` returns — services excluded by name still cost their revision-fetch round-trips. If that becomes a bottleneck, push the filter into `cloudrun.ListServices` so excluded services skip the per-revision API calls. - [x] **Slice 5:** ~~Multi-revision / traffic splitting — handle services with multiple active revisions and services with no active revisions.~~ Dropped 2026-04-28: multi-revision (traffic splitting), `LATEST` resolution, and dedup were all completed in Slice 2; the only remaining edge case (services with no active revisions emitting a placeholder artifact) was deferred until the server-side wire contract is defined, since picking the format unilaterally now risks rework. Re-open as a small slice once the server contract lands. - [x] **Slice 6:** Auth error UX — clear messages for ADC / `GOOGLE_APPLICATION_CREDENTIALS` failures and for missing project/region. Done 2026-04-28: `cloudrun.Classify(err, project, region)` maps gRPC `Unauthenticated` → ADC advice, `PermissionDenied` → `roles/run.viewer` advice, `NotFound` → "project or region not found"; other codes pass through. Auth message names all three credential sources (env var, `gcloud auth application-default login`, GCE/GKE metadata server / Workload Identity) since the production deployment is a GKE cron job. `cloudrun.New(ctx)` errors get a generic "GCP client setup failed" prefix. diff --git a/internal/cloudrun/payload.go b/internal/cloudrun/payload.go index 85e90640e..b99bbc9c5 100644 --- a/internal/cloudrun/payload.go +++ b/internal/cloudrun/payload.go @@ -17,7 +17,7 @@ type EnvRequest struct { // One artifact is emitted per revision in each service's traffic configuration. type RevisionData struct { RevisionName string `json:"revisionName"` - ServiceName string `json:"serviceName,omitempty"` + ServiceName string `json:"service_name,omitempty"` Digests map[string]string `json:"digests"` CreatedAt int64 `json:"creationTimestamp"` } From 88c820268ca00e8ecb8402a787287e55e6fa13a8 Mon Sep 17 00:00:00 2001 From: Tore Martin Hagen Date: Wed, 29 Apr 2026 09:29:16 +0200 Subject: [PATCH 8/9] fix(cloudrun): close gRPC clients and clarify slice filtering (#4986) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR review feedback. Two issues: cloudrun.New opened gRPC services and revisions clients but never closed them. For a one-shot CLI invocation the OS reclaims the sockets, but the gRPC library logs noisy "clientConn switching balancer" warnings on stderr and the keepalive goroutines outlive the work. There was also a partial-failure leak: if NewRevisionsClient returned an error after services succeeded, services was abandoned. Adds Client.Close() releasing both connections (with the second attempted even when the first errors) and a cleanup of services in the partial-failure path. The command defers Close via an io.Closer type-assert, so the test stub does not need to implement it. Also replaced `filtered := services[:0]` (correct in-place reuse but a subtle idiom — relies on range copying each element before the overwrite via append) with `make([]cloudrun.Service, 0, len(services))` for clarity. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/kosli/snapshotCloudRun.go | 5 ++++- internal/cloudrun/cloudrun.go | 19 ++++++++++++++++++- internal/cloudrun/cloudrun_test.go | 4 ++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/cmd/kosli/snapshotCloudRun.go b/cmd/kosli/snapshotCloudRun.go index c7927faa4..c4cd17e16 100644 --- a/cmd/kosli/snapshotCloudRun.go +++ b/cmd/kosli/snapshotCloudRun.go @@ -90,12 +90,15 @@ func (o *snapshotCloudRunOptions) run(args []string) error { if err != nil { return err } + if closer, ok := client.(io.Closer); ok { + defer func() { _ = closer.Close() }() + } services, err := client.ListServices(ctx, o.project, o.region) if err != nil { return cloudrun.Classify(err, o.project, o.region) } - filtered := services[:0] + filtered := make([]cloudrun.Service, 0, len(services)) for _, svc := range services { include, err := o.serviceFilter.ShouldInclude(svc.Name) if err != nil { diff --git a/internal/cloudrun/cloudrun.go b/internal/cloudrun/cloudrun.go index b296287bf..adc926f37 100644 --- a/internal/cloudrun/cloudrun.go +++ b/internal/cloudrun/cloudrun.go @@ -49,7 +49,7 @@ type Client struct { // Application Default Credentials. Construction errors (typically rare in a // cluster cron job, since the metadata server provides credentials) are // wrapped with a generic "GCP client setup failed" prefix; the SDK's own -// message is preserved via %w for diagnosis. +// message is preserved via %w for diagnosis. Callers should defer Close(). func New(ctx context.Context) (*Client, error) { services, err := run.NewServicesClient(ctx) if err != nil { @@ -57,11 +57,28 @@ func New(ctx context.Context) (*Client, error) { } revisions, err := run.NewRevisionsClient(ctx) if err != nil { + _ = services.Close() return nil, fmt.Errorf("GCP client setup failed: %w", err) } return &Client{api: &gcpAPI{services: services, revisions: revisions}}, nil } +// Close releases the underlying gRPC connections. Safe to call on a Client +// constructed with a fake apiClient (returns nil). Returns the first error +// from closing either client; the second is always attempted. +func (c *Client) Close() error { + g, ok := c.api.(*gcpAPI) + if !ok { + return nil + } + sErr := g.services.Close() + rErr := g.revisions.Close() + if sErr != nil { + return sErr + } + return rErr +} + // ListServices returns every Cloud Run service in the given project+region, // each populated with the revisions referenced in its traffic configuration. // TrafficTarget entries of type LATEST are resolved via the service's diff --git a/internal/cloudrun/cloudrun_test.go b/internal/cloudrun/cloudrun_test.go index ee4491b0d..3fd19579f 100644 --- a/internal/cloudrun/cloudrun_test.go +++ b/internal/cloudrun/cloudrun_test.go @@ -266,6 +266,10 @@ func TestListServices_ServiceWithNoTrafficTargetsHasEmptyRevisions(t *testing.T) require.Empty(t, got[0].Revisions) } +func TestClient_CloseOnFakeAPIIsNoOp(t *testing.T) { + require.NoError(t, newClient(&fakeAPI{}).Close()) +} + func TestListServices_MultipleServices(t *testing.T) { fake := &fakeAPI{ services: []*runpb.Service{ From eb064b20188a7f07fb1cd5180f4e4a840ada7bdd Mon Sep 17 00:00:00 2001 From: Tore Martin Hagen Date: Wed, 29 Apr 2026 13:28:00 +0200 Subject: [PATCH 9/9] feat(snapshot): integrate cloud-run with the Kosli server (#4986) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lifts the forced dry-run lock that's been on since Slice 1, so the command actually PUTs the snapshot to /report/cloud-run. Adds the ECS-style success log "[N] revisions were reported to environment X" when the request succeeds outside dry-run. Test changes: - SetupTest now creates the env with type "cloud-run" via CreateEnv, the same way ECS/K8S tests do. Confirms the local server's CloudRunReport endpoint accepts the new env-type literal. - The dry-run-asserting tests (cmdTestCase 05 and the four TestSnapshotCloudRunFilter_* cases) take explicit --dry-run since the global default is no longer forced. - New TestSnapshotCloudRunCmd_HappyPathReportsToServer exercises the full CLI → local Kosli server roundtrip with the GCP client stubbed, asserting the success log line. - Stub fixtures use 64-char hex digests because the server's CloudRunReport validates `^[a-f0-9]{64}$` per digest value. Hidden: true stays for now; unhiding + CLI reference docs land in the next slice. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/kosli/snapshotCloudRun.go | 6 ++-- cmd/kosli/snapshotCloudRun_test.go | 30 +++++++++++++++---- .../2026-04-28-4986-google-cloud-run-1.md | 4 ++- 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/cmd/kosli/snapshotCloudRun.go b/cmd/kosli/snapshotCloudRun.go index c4cd17e16..8248e0d7d 100644 --- a/cmd/kosli/snapshotCloudRun.go +++ b/cmd/kosli/snapshotCloudRun.go @@ -14,7 +14,7 @@ import ( const snapshotCloudRunShortDesc = `Report a snapshot of running services in a Google Cloud Run project and region to Kosli. ` const snapshotCloudRunLongDesc = snapshotCloudRunShortDesc + ` -Currently a hidden, in-development command — it always runs in dry-run mode regardless of the --dry-run flag.` +Currently a hidden, in-development command. Use --dry-run to inspect the payload without sending it to Kosli.` // cloudRunLister is the seam between the command and the GCP client. Tests // override newCloudRunClient with a stub that returns canned services. @@ -55,7 +55,6 @@ func newSnapshotCloudRunCmd(out io.Writer) *cobra.Command { return err } } - global.DryRun = true return nil }, RunE: func(cmd *cobra.Command, args []string) error { @@ -119,5 +118,8 @@ func (o *snapshotCloudRunOptions) run(args []string) error { Token: global.ApiToken, } _, err = kosliClient.Do(reqParams) + if err == nil && !global.DryRun { + logger.Info("[%d] revisions were reported to environment %s", len(payload.Artifacts), envName) + } return err } diff --git a/cmd/kosli/snapshotCloudRun_test.go b/cmd/kosli/snapshotCloudRun_test.go index dbdb08aeb..e9d11dc4d 100644 --- a/cmd/kosli/snapshotCloudRun_test.go +++ b/cmd/kosli/snapshotCloudRun_test.go @@ -31,8 +31,13 @@ type SnapshotCloudRunTestSuite struct { } // stubServices returns two Cloud Run services so filter tests can verify -// inclusion and exclusion in a single run. +// inclusion and exclusion in a single run. Digests are full 64-char hex +// because the server's CloudRunReport model rejects anything else. func stubServices() []cloudrun.Service { + const ( + alphaDigest = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + betaDigest = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ) return []cloudrun.Service{ { Name: "alpha", @@ -40,7 +45,7 @@ func stubServices() []cloudrun.Service { Revisions: []cloudrun.Revision{ { Name: "alpha-rev1", - Digests: map[string]string{"gcr.io/x/alpha@sha256:aaa": "aaa"}, + Digests: map[string]string{"gcr.io/x/alpha@sha256:" + alphaDigest: alphaDigest}, CreatedAt: time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC), }, }, @@ -51,7 +56,7 @@ func stubServices() []cloudrun.Service { Revisions: []cloudrun.Revision{ { Name: "beta-rev1", - Digests: map[string]string{"gcr.io/x/beta@sha256:bbb": "bbb"}, + Digests: map[string]string{"gcr.io/x/beta@sha256:" + betaDigest: betaDigest}, CreatedAt: time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC), }, }, @@ -71,6 +76,8 @@ func (suite *SnapshotCloudRunTestSuite) SetupTest() { newCloudRunClient = func(_ context.Context) (cloudRunLister, error) { return stubCloudRunLister{services: stubServices()}, nil } + + CreateEnv(global.Org, suite.envName, "cloud-run", suite.T()) } func (suite *SnapshotCloudRunTestSuite) TearDownTest() { @@ -105,7 +112,7 @@ func (suite *SnapshotCloudRunTestSuite) TestSnapshotCloudRunCmd() { }, { name: "05 snapshot cloud-run dry-runs the report URL and payload built from the GCP client", - cmd: fmt.Sprintf(`snapshot cloud-run %s --project proj-x --region europe-west1 %s`, suite.envName, suite.defaultKosliArguments), + cmd: fmt.Sprintf(`snapshot cloud-run %s --project proj-x --region europe-west1 --dry-run %s`, suite.envName, suite.defaultKosliArguments), goldenRegex: `(?s)THIS IS A DRY-RUN.*report/cloud-run.*"type": "cloud-run".*"service_name": "alpha".*"service_name": "beta"`, }, { @@ -142,7 +149,7 @@ func (suite *SnapshotCloudRunTestSuite) TestSnapshotCloudRunCmd() { // service appears) and absence (excluded service does not appear), so they // cannot use the single-assertion cmdTestCase table. func (suite *SnapshotCloudRunTestSuite) runFilteredCmd(filterArgs string) string { - cmd := fmt.Sprintf(`snapshot cloud-run %s --project p --region r %s %s`, suite.envName, filterArgs, suite.defaultKosliArguments) + cmd := fmt.Sprintf(`snapshot cloud-run %s --project p --region r --dry-run %s %s`, suite.envName, filterArgs, suite.defaultKosliArguments) _, combined, _, _, err := executeCommandC(cmd) require.NoError(suite.T(), err, "command failed: %s", combined) return combined @@ -172,6 +179,19 @@ func (suite *SnapshotCloudRunTestSuite) TestSnapshotCloudRunFilter_ExcludeRegex( require.Contains(suite.T(), out, `"service_name": "beta"`) } +// TestSnapshotCloudRunCmd_HappyPathReportsToServer exercises the full +// CLI → local Kosli server roundtrip with the GCP client stubbed: the env is +// already created in SetupTest with type "cloud-run", and the command is +// expected to PUT the snapshot and emit the "[N] revisions were reported" +// success log mirroring ECS. +func (suite *SnapshotCloudRunTestSuite) TestSnapshotCloudRunCmd_HappyPathReportsToServer() { + cmd := fmt.Sprintf(`snapshot cloud-run %s --project p --region r %s`, suite.envName, suite.defaultKosliArguments) + _, combined, _, _, err := executeCommandC(cmd) + + require.NoError(suite.T(), err, "command failed: %s", combined) + require.Contains(suite.T(), combined, fmt.Sprintf("[2] revisions were reported to environment %s", suite.envName)) +} + // TestSnapshotCloudRunCmd_UnauthenticatedReturnsFriendlyError verifies that a // gRPC Unauthenticated error from GCP surfaces as the actionable ADC message // rather than a raw SDK string. diff --git a/docs/handover/2026-04-28-4986-google-cloud-run-1.md b/docs/handover/2026-04-28-4986-google-cloud-run-1.md index 172a4c9b7..a44e303bd 100644 --- a/docs/handover/2026-04-28-4986-google-cloud-run-1.md +++ b/docs/handover/2026-04-28-4986-google-cloud-run-1.md @@ -37,6 +37,7 @@ GCP test environment (provisioned, ADC already configured on this machine): - Wire payload follows the server's `out-snapshot-examples.txt` reference: top-level `{"type": "cloud-run", "artifacts": [...]}` with camelCase per-artifact fields (`revisionName`, `serviceName`, `digests`, `creationTimestamp`). Endpoint is `report/cloud-run` (kebab-case, parallels `report/azure-apps`). Server-side endpoint does not yet exist; the forced dry-run means no network call is made. Initial design (Slice 3) added `project`/`region` per artifact mirroring ECS's `cluster_name`; reverted in Slice 3.5 because the doc specifies `extra="forbid"` on every Pydantic model and project/region are derivable from the URL + flags. - Command depends on a local `cloudRunLister` interface and a package-level `newCloudRunClient` variable so tests can substitute a stub without touching ADC. The seam stays in `cmd/kosli/snapshotCloudRun.go` rather than being exposed from `internal/cloudrun` — keeps the public package surface minimal. - Error classification (`Classify`) lives in `internal/cloudrun` (GCP knowledge belongs to the package) but is *applied* at the command layer, not inside `Client.ListServices`. Why: applying it inside `ListServices` would double-wrap real-call errors when the command also classified them, and bypass the friendly path entirely for stub-driven tests. Calling it once at the command boundary covers both real and stub error sources. +- Test stub fixtures (`stubServices` in `cmd/kosli/snapshotCloudRun_test.go`) use full 64-char hex digests, not short placeholders. Why: the server's `CloudRunReport` Pydantic model validates `^[a-f0-9]{64}$|''` per digest value, so the integration test would 422 with the previous `"aaa"`/`"bbb"` fixtures. --- @@ -51,4 +52,5 @@ Slice plan (each slice is a separate, independently-mergeable branch): - [x] **Slice 4:** Filtering flags — `--services`, `--services-regex`, `--exclude`, `--exclude-regex`. Done 2026-04-28: backed by `filters.ResourceFilterOptions` (same struct ECS uses); 4 mutex pairs validated in `PreRunE`. Filter is applied in the command after `cloudrun.ListServices` returns — services excluded by name still cost their revision-fetch round-trips. If that becomes a bottleneck, push the filter into `cloudrun.ListServices` so excluded services skip the per-revision API calls. - [x] **Slice 5:** ~~Multi-revision / traffic splitting — handle services with multiple active revisions and services with no active revisions.~~ Dropped 2026-04-28: multi-revision (traffic splitting), `LATEST` resolution, and dedup were all completed in Slice 2; the only remaining edge case (services with no active revisions emitting a placeholder artifact) was deferred until the server-side wire contract is defined, since picking the format unilaterally now risks rework. Re-open as a small slice once the server contract lands. - [x] **Slice 6:** Auth error UX — clear messages for ADC / `GOOGLE_APPLICATION_CREDENTIALS` failures and for missing project/region. Done 2026-04-28: `cloudrun.Classify(err, project, region)` maps gRPC `Unauthenticated` → ADC advice, `PermissionDenied` → `roles/run.viewer` advice, `NotFound` → "project or region not found"; other codes pass through. Auth message names all three credential sources (env var, `gcloud auth application-default login`, GCE/GKE metadata server / Workload Identity) since the production deployment is a GKE cron job. `cloudrun.New(ctx)` errors get a generic "GCP client setup failed" prefix. -- [ ] **Slice 7:** Unhide the command, lift the forced dry-run, update CLI reference docs and examples. +- [x] **Slice 7 (split):** Server integration. Done 2026-04-29: lifted the forced `global.DryRun = true` lock; added the ECS-style `[N] revisions were reported to environment X` success log; `SetupTest` now creates the env via `CreateEnv(global.Org, suite.envName, "cloud-run", suite.T())` (proves env-type acceptance against the local docker-compose server image, which the user has set to a build that includes the new endpoint); existing dry-run-asserting tests got explicit `--dry-run` flags; new `TestSnapshotCloudRunCmd_HappyPathReportsToServer` exercises the full CLI → local Kosli server roundtrip with the GCP client stubbed and asserts the success log line. +- [ ] **Slice 8:** Unhide the command (`Hidden: true` → removed) + CLI reference docs / examples regeneration. Held until the docs side is ready.