Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cmd/kosli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
1 change: 1 addition & 0 deletions cmd/kosli/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ func newSnapshotCmd(out io.Writer) *cobra.Command {
newSnapshotAzureAppsCmd(out),
newSnapshotPathsCmd(out),
newSnapshotPathCmd(out),
newSnapshotCloudRunCmd(out),
)

return cmd
Expand Down
120 changes: 120 additions & 0 deletions cmd/kosli/snapshotCloudRun.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
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 — it always runs in dry-run mode regardless of the --dry-run flag.`
Comment thread
ToreMerkely marked this conversation as resolved.
Outdated

// 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) {
Comment thread
ToreMerkely marked this conversation as resolved.
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
}
}
global.DryRun = true
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
}
services, err := client.ListServices(ctx, o.project, o.region)
if err != nil {
return cloudrun.Classify(err, o.project, o.region)
}
Comment thread
ToreMerkely marked this conversation as resolved.

filtered := services[:0]
Comment thread
ToreMerkely marked this conversation as resolved.
Outdated
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)
return err
}
193 changes: 193 additions & 0 deletions cmd/kosli/snapshotCloudRun_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
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.
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{
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
}
}

func (suite *SnapshotCloudRunTestSuite) TearDownTest() {
newCloudRunClient = origNewCloudRunClient
}

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 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"`,
},
{
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, `"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, `"serviceName": "alpha"`)
require.NotContains(suite.T(), out, `"serviceName": "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"`)
}

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"`)
}

// 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))
}
Loading
Loading