diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 01c376817..e2a22fa4c 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -182,6 +182,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/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..8248e0d7d --- /dev/null +++ b/cmd/kosli/snapshotCloudRun.go @@ -0,0 +1,125 @@ +package main + +import ( + "context" + "io" + "net/http" + "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" +) + +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. 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. +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 + 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, + 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()) + } + 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 + } + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(args) + }, + } + + 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 { + logger.Error("failed to configure required flags: %v", err) + } + + return cmd +} + +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 + } + 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 := make([]cloudrun.Service, 0, len(services)) + 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) + + reqParams := &requests.RequestParams{ + Method: http.MethodPut, + URL: reportURL, + Payload: payload, + DryRun: global.DryRun, + 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 new file mode 100644 index 000000000..e9d11dc4d --- /dev/null +++ b/cmd/kosli/snapshotCloudRun_test.go @@ -0,0 +1,213 @@ +package main + +import ( + "context" + "fmt" + "testing" + "time" + + "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 { + 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 + envName string +} + +// stubServices returns two Cloud Run services so filter tests can verify +// 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", + URI: "https://alpha.run.app", + Revisions: []cloudrun.Revision{ + { + Name: "alpha-rev1", + Digests: map[string]string{"gcr.io/x/alpha@sha256:" + alphaDigest: alphaDigest}, + 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:" + betaDigest: betaDigest}, + CreatedAt: time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC), + }, + }, + }, + } +} + +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) + + newCloudRunClient = func(_ context.Context) (cloudRunLister, error) { + return stubCloudRunLister{services: stubServices()}, nil + } + + CreateEnv(global.Org, suite.envName, "cloud-run", suite.T()) +} + +func (suite *SnapshotCloudRunTestSuite) TearDownTest() { + newCloudRunClient = origNewCloudRunClient +} + +func (suite *SnapshotCloudRunTestSuite) TestSnapshotCloudRunCmd() { + tests := []cmdTestCase{ + { + wantError: true, + 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: "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: "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: "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: "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 --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"`, + }, + { + wantError: true, + 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: "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: "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: "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", + }, + } + + 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 --dry-run %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"`) +} + +// 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. +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 new file mode 100644 index 000000000..a44e303bd --- /dev/null +++ b/docs/handover/2026-04-28-4986-google-cloud-run-1.md @@ -0,0 +1,56 @@ +# 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. +- 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 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. + +--- + +## 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. +- [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"`; 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. +- [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. diff --git a/go.mod b/go.mod index 05b17cec9..1320f5b02 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,9 @@ 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/grpc v1.80.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 +60,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 +136,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 +209,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 +229,9 @@ 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 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..adc926f37 --- /dev/null +++ b/internal/cloudrun/cloudrun.go @@ -0,0 +1,205 @@ +// 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. 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. Callers should defer Close(). +func New(ctx context.Context) (*Client, error) { + services, err := run.NewServicesClient(ctx) + if err != nil { + return nil, fmt.Errorf("GCP client setup failed: %w", err) + } + 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 +// 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..3fd19579f --- /dev/null +++ b/internal/cloudrun/cloudrun_test.go @@ -0,0 +1,301 @@ +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 TestClient_CloseOnFakeAPIIsNoOp(t *testing.T) { + require.NoError(t, newClient(&fakeAPI{}).Close()) +} + +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) +} 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) +} diff --git a/internal/cloudrun/payload.go b/internal/cloudrun/payload.go new file mode 100644 index 000000000..b99bbc9c5 --- /dev/null +++ b/internal/cloudrun/payload.go @@ -0,0 +1,41 @@ +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. +// 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"` +} + +// 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"` + ServiceName string `json:"service_name,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) *EnvRequest { + artifacts := []*RevisionData{} + for _, svc := range services { + for _, rev := range svc.Revisions { + artifacts = append(artifacts, &RevisionData{ + RevisionName: rev.Name, + ServiceName: svc.Name, + Digests: rev.Digests, + CreatedAt: rev.CreatedAt.Unix(), + }) + } + } + return &EnvRequest{Type: reportType, Artifacts: artifacts} +} diff --git a/internal/cloudrun/payload_test.go b/internal/cloudrun/payload_test.go new file mode 100644 index 000000000..f30fa474d --- /dev/null +++ b/internal/cloudrun/payload_test.go @@ -0,0 +1,99 @@ +package cloudrun + +import ( + "testing" + "time" + + "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) + 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) + require.Len(t, got.Artifacts, 1) + + art := got.Artifacts[0] + require.Equal(t, "svc-a-rev1", art.RevisionName) + 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) +} + +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) + 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].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) { + services := []Service{ + {Name: "empty-svc"}, + { + Name: "svc-a", + Revisions: []Revision{ + {Name: "rev", Digests: map[string]string{"img@sha256:x": "x"}}, + }, + }, + } + + got := ToEnvRequest(services) + require.Len(t, got.Artifacts, 1) + require.Equal(t, "svc-a", got.Artifacts[0].ServiceName) +} + +func TestToEnvRequest_ZeroCreatedAtSerialisesAsZero(t *testing.T) { + services := []Service{ + {Name: "svc", Revisions: []Revision{{Name: "rev"}}}, + } + + got := ToEnvRequest(services) + require.Len(t, got.Artifacts, 1) + require.Equal(t, time.Time{}.Unix(), got.Artifacts[0].CreatedAt) +}