Skip to content

Commit 20076e2

Browse files
ToreMerkelyclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 660eb49 commit 20076e2

4 files changed

Lines changed: 127 additions & 20 deletions

File tree

cmd/kosli/root.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,10 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file,
176176
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."
177177
ecsExcludeServicesFlag = "[optional] The comma-separated list of ECS service names to exclude. Can't be used together with --services or --services-regex."
178178
ecsExcludeServicesRegexFlag = "[optional] The comma-separated list of ECS service name regex patterns to exclude. Can't be used together with --services or --services-regex."
179+
cloudRunServicesFlag = "[optional] The comma-separated list of Cloud Run service names to snapshot. Can't be used together with --exclude or --exclude-regex."
180+
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."
181+
cloudRunExcludeFlag = "[optional] The comma-separated list of Cloud Run service names to exclude. Can't be used together with --services or --services-regex."
182+
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."
179183
kubeconfigFlag = "[defaulted] The kubeconfig path for the target cluster."
180184
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."
181185
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."

cmd/kosli/snapshotCloudRun.go

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"net/url"
88

99
"github.com/kosli-dev/cli/internal/cloudrun"
10+
"github.com/kosli-dev/cli/internal/filters"
1011
"github.com/kosli-dev/cli/internal/requests"
1112
"github.com/spf13/cobra"
1213
)
@@ -26,12 +27,14 @@ var newCloudRunClient = func(ctx context.Context) (cloudRunLister, error) {
2627
}
2728

2829
type snapshotCloudRunOptions struct {
29-
project string
30-
region string
30+
project string
31+
region string
32+
serviceFilter *filters.ResourceFilterOptions
3133
}
3234

3335
func newSnapshotCloudRunCmd(out io.Writer) *cobra.Command {
3436
o := new(snapshotCloudRunOptions)
37+
o.serviceFilter = new(filters.ResourceFilterOptions)
3538
cmd := &cobra.Command{
3639
Use: "cloud-run ENVIRONMENT-NAME",
3740
Short: snapshotCloudRunShortDesc,
@@ -42,6 +45,16 @@ func newSnapshotCloudRunCmd(out io.Writer) *cobra.Command {
4245
if err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}); err != nil {
4346
return ErrorBeforePrintingUsage(cmd, err.Error())
4447
}
48+
for _, pair := range [][]string{
49+
{"services", "exclude"},
50+
{"services", "exclude-regex"},
51+
{"services-regex", "exclude"},
52+
{"services-regex", "exclude-regex"},
53+
} {
54+
if err := MuXRequiredFlags(cmd, pair, false); err != nil {
55+
return err
56+
}
57+
}
4558
global.DryRun = true
4659
return nil
4760
},
@@ -52,6 +65,10 @@ func newSnapshotCloudRunCmd(out io.Writer) *cobra.Command {
5265

5366
cmd.Flags().StringVar(&o.project, "project", "", "[required] GCP project ID.")
5467
cmd.Flags().StringVar(&o.region, "region", "", "[required] GCP region (e.g. europe-west1).")
68+
cmd.Flags().StringSliceVar(&o.serviceFilter.IncludeNames, "services", []string{}, cloudRunServicesFlag)
69+
cmd.Flags().StringSliceVar(&o.serviceFilter.IncludeNamesRegex, "services-regex", []string{}, cloudRunServicesRegexFlag)
70+
cmd.Flags().StringSliceVar(&o.serviceFilter.ExcludeNames, "exclude", []string{}, cloudRunExcludeFlag)
71+
cmd.Flags().StringSliceVar(&o.serviceFilter.ExcludeNamesRegex, "exclude-regex", []string{}, cloudRunExcludeRegexFlag)
5572
addDryRunFlag(cmd)
5673

5774
if err := RequireFlags(cmd, []string{"project", "region"}); err != nil {
@@ -78,7 +95,18 @@ func (o *snapshotCloudRunOptions) run(args []string) error {
7895
return err
7996
}
8097

81-
payload := cloudrun.ToEnvRequest(services, o.project, o.region)
98+
filtered := services[:0]
99+
for _, svc := range services {
100+
include, err := o.serviceFilter.ShouldInclude(svc.Name)
101+
if err != nil {
102+
return err
103+
}
104+
if include {
105+
filtered = append(filtered, svc)
106+
}
107+
}
108+
109+
payload := cloudrun.ToEnvRequest(filtered, o.project, o.region)
82110

83111
reqParams := &requests.RequestParams{
84112
Method: http.MethodPut,

cmd/kosli/snapshotCloudRun_test.go

Lines changed: 91 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"time"
88

99
"github.com/kosli-dev/cli/internal/cloudrun"
10+
"github.com/stretchr/testify/require"
1011
"github.com/stretchr/testify/suite"
1112
)
1213

@@ -27,6 +28,35 @@ type SnapshotCloudRunTestSuite struct {
2728
envName string
2829
}
2930

31+
// stubServices returns two Cloud Run services so filter tests can verify
32+
// inclusion and exclusion in a single run.
33+
func stubServices() []cloudrun.Service {
34+
return []cloudrun.Service{
35+
{
36+
Name: "alpha",
37+
URI: "https://alpha.run.app",
38+
Revisions: []cloudrun.Revision{
39+
{
40+
Name: "alpha-rev1",
41+
Digests: map[string]string{"gcr.io/x/alpha@sha256:aaa": "aaa"},
42+
CreatedAt: time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC),
43+
},
44+
},
45+
},
46+
{
47+
Name: "beta",
48+
URI: "https://beta.run.app",
49+
Revisions: []cloudrun.Revision{
50+
{
51+
Name: "beta-rev1",
52+
Digests: map[string]string{"gcr.io/x/beta@sha256:bbb": "bbb"},
53+
CreatedAt: time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC),
54+
},
55+
},
56+
},
57+
}
58+
}
59+
3060
func (suite *SnapshotCloudRunTestSuite) SetupTest() {
3161
suite.envName = "snapshot-cloud-run-env"
3262
global = &GlobalOpts{
@@ -37,21 +67,7 @@ func (suite *SnapshotCloudRunTestSuite) SetupTest() {
3767
suite.defaultKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken)
3868

3969
newCloudRunClient = func(_ context.Context) (cloudRunLister, error) {
40-
return stubCloudRunLister{
41-
services: []cloudrun.Service{
42-
{
43-
Name: "hello-world",
44-
URI: "https://hello-world.run.app",
45-
Revisions: []cloudrun.Revision{
46-
{
47-
Name: "hello-world-rev1",
48-
Digests: map[string]string{"gcr.io/x/hello@sha256:abc": "abc"},
49-
CreatedAt: time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC),
50-
},
51-
},
52-
},
53-
},
54-
}, nil
70+
return stubCloudRunLister{services: stubServices()}, nil
5571
}
5672
}
5773

@@ -88,13 +104,72 @@ func (suite *SnapshotCloudRunTestSuite) TestSnapshotCloudRunCmd() {
88104
{
89105
name: "snapshot cloud-run dry-runs the report URL and payload built from the GCP client",
90106
cmd: fmt.Sprintf(`snapshot cloud-run %s --project proj-x --region europe-west1 %s`, suite.envName, suite.defaultKosliArguments),
91-
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"`,
107+
goldenRegex: `(?s)THIS IS A DRY-RUN.*report/cloud-run.*"service_name": "alpha".*"service_name": "beta"`,
108+
},
109+
{
110+
wantError: true,
111+
name: "snapshot cloud-run fails if --services and --exclude are set",
112+
cmd: fmt.Sprintf(`snapshot cloud-run %s --project p --region r --services alpha --exclude beta %s`, suite.envName, suite.defaultKosliArguments),
113+
golden: "Error: only one of --services, --exclude is allowed\n",
114+
},
115+
{
116+
wantError: true,
117+
name: "snapshot cloud-run fails if --services and --exclude-regex are set",
118+
cmd: fmt.Sprintf(`snapshot cloud-run %s --project p --region r --services alpha --exclude-regex "^b" %s`, suite.envName, suite.defaultKosliArguments),
119+
golden: "Error: only one of --services, --exclude-regex is allowed\n",
120+
},
121+
{
122+
wantError: true,
123+
name: "snapshot cloud-run fails if --services-regex and --exclude are set",
124+
cmd: fmt.Sprintf(`snapshot cloud-run %s --project p --region r --services-regex "^a" --exclude beta %s`, suite.envName, suite.defaultKosliArguments),
125+
golden: "Error: only one of --services-regex, --exclude is allowed\n",
126+
},
127+
{
128+
wantError: true,
129+
name: "snapshot cloud-run fails if --services-regex and --exclude-regex are set",
130+
cmd: fmt.Sprintf(`snapshot cloud-run %s --project p --region r --services-regex "^a" --exclude-regex "^b" %s`, suite.envName, suite.defaultKosliArguments),
131+
golden: "Error: only one of --services-regex, --exclude-regex is allowed\n",
92132
},
93133
}
94134

95135
runTestCmd(suite.T(), tests)
96136
}
97137

138+
// runFilteredCmd executes the command and returns the combined output for
139+
// substring assertions. Filter tests need to assert both presence (kept
140+
// service appears) and absence (excluded service does not appear), so they
141+
// cannot use the single-assertion cmdTestCase table.
142+
func (suite *SnapshotCloudRunTestSuite) runFilteredCmd(filterArgs string) string {
143+
cmd := fmt.Sprintf(`snapshot cloud-run %s --project p --region r %s %s`, suite.envName, filterArgs, suite.defaultKosliArguments)
144+
_, combined, _, _, err := executeCommandC(cmd)
145+
require.NoError(suite.T(), err, "command failed: %s", combined)
146+
return combined
147+
}
148+
149+
func (suite *SnapshotCloudRunTestSuite) TestSnapshotCloudRunFilter_Services() {
150+
out := suite.runFilteredCmd("--services alpha")
151+
require.Contains(suite.T(), out, `"service_name": "alpha"`)
152+
require.NotContains(suite.T(), out, `"service_name": "beta"`)
153+
}
154+
155+
func (suite *SnapshotCloudRunTestSuite) TestSnapshotCloudRunFilter_ServicesRegex() {
156+
out := suite.runFilteredCmd(`--services-regex "^al"`)
157+
require.Contains(suite.T(), out, `"service_name": "alpha"`)
158+
require.NotContains(suite.T(), out, `"service_name": "beta"`)
159+
}
160+
161+
func (suite *SnapshotCloudRunTestSuite) TestSnapshotCloudRunFilter_Exclude() {
162+
out := suite.runFilteredCmd("--exclude alpha")
163+
require.NotContains(suite.T(), out, `"service_name": "alpha"`)
164+
require.Contains(suite.T(), out, `"service_name": "beta"`)
165+
}
166+
167+
func (suite *SnapshotCloudRunTestSuite) TestSnapshotCloudRunFilter_ExcludeRegex() {
168+
out := suite.runFilteredCmd(`--exclude-regex "^al"`)
169+
require.NotContains(suite.T(), out, `"service_name": "alpha"`)
170+
require.Contains(suite.T(), out, `"service_name": "beta"`)
171+
}
172+
98173
func TestSnapshotCloudRunCommandTestSuite(t *testing.T) {
99174
suite.Run(t, new(SnapshotCloudRunTestSuite))
100175
}

docs/handover/2026-04-28-4986-google-cloud-run-1.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ Slice plan (each slice is a separate, independently-mergeable branch):
4646
- [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.
4747
- [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.
4848
- [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.
49-
- [ ] **Slice 4:** Filtering flags — `--services`, `--services-regex`, `--exclude`, `--exclude-regex`.
49+
- [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.
5050
- [ ] **Slice 5:** Multi-revision / traffic splitting — handle services with multiple active revisions and services with no active revisions.
5151
- [ ] **Slice 6:** Auth error UX — clear messages for ADC / `GOOGLE_APPLICATION_CREDENTIALS` failures and for missing project/region.
5252
- [ ] **Slice 7:** Unhide the command, lift the forced dry-run, update CLI reference docs and examples.

0 commit comments

Comments
 (0)