From 9c7a96aca8413d5cf5c295d076768ef493bce123 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 16 Oct 2026 03:07:38 +0200 Subject: [PATCH 1/2] fix(cli): prompt for an existing service account when GCP setup skips creation gcpStepCreateServiceAccount returned the fabricated default email cudly-service-account@.iam.gserviceaccount.com on skip and on unknown input, so the grant and key steps acted on a principal that was never created or verified and failed later with an opaque IAM error. On skip or unknown input it now asks for the existing service account email, validates it (non-empty, no whitespace, @...gserviceaccount.com) and fails loud otherwise. Wizard-driving tests gain the extra email line. Closes #2007 Co-Authored-By: Claude Opus 5.5 --- cmd/configure_gcp.go | 28 ++++++++++++++++++---- cmd/configure_gcp_iam_test.go | 2 +- cmd/configure_gcp_signal_test.go | 2 +- cmd/configure_gcp_test.go | 40 ++++++++++++++++++++++++++++++++ docs/cli/cloud-setup.md | 2 ++ 5 files changed, 67 insertions(+), 7 deletions(-) diff --git a/cmd/configure_gcp.go b/cmd/configure_gcp.go index c16ddbd43..0dc3bb78e 100644 --- a/cmd/configure_gcp.go +++ b/cmd/configure_gcp.go @@ -18,6 +18,7 @@ import ( "strings" "syscall" "time" + "unicode" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" @@ -751,8 +752,6 @@ func gcpStepSelectProject(ctx context.Context, reader *bufio.Reader) (string, er // SDK. It fails loud on any SDK error (no CLI fallback). func gcpStepCreateServiceAccount(ctx context.Context, reader *bufio.Reader, projectID string) (string, error) { saName := "cudly-service-account" - saEmail := fmt.Sprintf("%s@%s.iam.gserviceaccount.com", saName, projectID) - fmt.Println() fmt.Println("Step 3: Create Service Account") fmt.Println("------------------------------") @@ -770,14 +769,33 @@ func gcpStepCreateServiceAccount(ctx context.Context, reader *bufio.Reader, proj if createErr != nil { return "", createErr } - saEmail = email - fmt.Printf("Service account created: %s\n", saEmail) + fmt.Printf("Service account created: %s\n", email) + return email, nil case "s", "skip": fmt.Println("Skipping Create Service Account") + return promptExistingGCPServiceAccount(reader) default: fmt.Printf("Unknown option, skipping\n") + return promptExistingGCPServiceAccount(reader) + } +} + +// promptExistingGCPServiceAccount asks for the email of an existing service +// account when creation is skipped, so later steps never act on a principal +// that was neither created nor supplied by the operator. +func promptExistingGCPServiceAccount(reader *bufio.Reader) (string, error) { + email, err := readRequiredInputLine(reader, "Skipped. Enter the existing service account email (name@project.iam.gserviceaccount.com): ", "service-account email") + if err != nil { + return "", err + } + email = strings.ToLower(email) + local, domain, ok := strings.Cut(email, "@") + label, hasSuffix := strings.CutSuffix(domain, ".gserviceaccount.com") + if !ok || local == "" || !hasSuffix || label == "" || strings.Contains(domain, "@") || + strings.IndexFunc(email, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }) >= 0 { + return "", fmt.Errorf("invalid service account email %q: expected a name@.gserviceaccount.com address", email) } - return saEmail, nil + return email, nil } // gcpCustomRole is a single-permission custom role the wizard provisions. diff --git a/cmd/configure_gcp_iam_test.go b/cmd/configure_gcp_iam_test.go index 54d9fdd26..aefe0381e 100644 --- a/cmd/configure_gcp_iam_test.go +++ b/cmd/configure_gcp_iam_test.go @@ -180,7 +180,7 @@ func runGCPIAMFixture(t *testing.T, scenario string) { stdin, writeErr := os.Create(filepath.Join(dir, "stdin")) require.NoError(t, writeErr) defer stdin.Close() - _, writeErr = io.WriteString(stdin, "s\ns\ns\nfixture-project\ns\nr\n") + _, writeErr = io.WriteString(stdin, "s\ns\ns\nfixture-project\ns\ncudly-service-account@fixture-project.iam.gserviceaccount.com\nr\n") require.NoError(t, writeErr) _, writeErr = stdin.Seek(0, io.SeekStart) require.NoError(t, writeErr) diff --git a/cmd/configure_gcp_signal_test.go b/cmd/configure_gcp_signal_test.go index d9b37c115..de3a9eec6 100644 --- a/cmd/configure_gcp_signal_test.go +++ b/cmd/configure_gcp_signal_test.go @@ -115,7 +115,7 @@ func startGCPInterruptChild(t *testing.T, phase string) (*exec.Cmd, string, io.R child.Stderr = stderr require.NoError(t, child.Start()) t.Cleanup(func() { _ = child.Process.Kill() }) - input := "s\ns\ns\nfixture-project\ns\ns\n" + input := "s\ns\ns\nfixture-project\ns\ncudly-service-account@fixture-project.iam.gserviceaccount.com\ns\n" if phase != "prompt" { input += "r\n" } diff --git a/cmd/configure_gcp_test.go b/cmd/configure_gcp_test.go index 1e8f7ca48..9f9657695 100644 --- a/cmd/configure_gcp_test.go +++ b/cmd/configure_gcp_test.go @@ -1,11 +1,13 @@ package main import ( + "bufio" "context" "encoding/base64" "errors" "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -398,3 +400,41 @@ func TestGcloudDeleteKeyCommand(t *testing.T) { "gcloud iam service-accounts keys delete abc123 --iam-account=sa@proj.iam.gserviceaccount.com", gcloudDeleteKeyCommand(testMintedKeyName)) } + +// --- gcpStepCreateServiceAccount ---------------------------------------------- + +func TestGCPStepCreateServiceAccount_SkipUsesOperatorEmail(t *testing.T) { + const existing = "existing-sa@my-proj.iam.gserviceaccount.com" + reader := bufio.NewReader(strings.NewReader("s\n " + existing + " \n")) + + email, err := gcpStepCreateServiceAccount(context.Background(), reader, "my-proj") + + require.NoError(t, err) + assert.Equal(t, existing, email) + assert.NotEqual(t, "cudly-service-account@my-proj.iam.gserviceaccount.com", email) +} + +func TestGCPStepCreateServiceAccount_UnknownOptionPromptsForEmail(t *testing.T) { + const existing = "other@my-proj.iam.gserviceaccount.com" + reader := bufio.NewReader(strings.NewReader("x\n" + existing + "\n")) + + email, err := gcpStepCreateServiceAccount(context.Background(), reader, "my-proj") + + require.NoError(t, err) + assert.Equal(t, existing, email) +} + +func TestGCPStepCreateServiceAccount_SkipRejectsInvalidEmail(t *testing.T) { + for _, in := range []string{"s\n\n", "s\nnot-an-email\n", "s\nuser@example.com\n", "s\n@x.gserviceaccount.com\n", "s\na@b@c.gserviceaccount.com\n", "s\na@.gserviceaccount.com\n", "s\n ", "s\na\x00@x.gserviceaccount.com\n", "s\n"} { + email, err := gcpStepCreateServiceAccount(context.Background(), bufio.NewReader(strings.NewReader(in)), "my-proj") + require.Error(t, err, "input %q", in) + assert.Empty(t, email) + } +} + +func TestGCPStepCreateServiceAccount_SkipAcceptsUppercaseEmail(t *testing.T) { + const existing = "Existing-SA@My-Proj.IAM.GServiceAccount.com" + email, err := gcpStepCreateServiceAccount(context.Background(), bufio.NewReader(strings.NewReader("s\n"+existing+"\n")), "my-proj") + require.NoError(t, err) + assert.Equal(t, strings.ToLower(existing), email) +} diff --git a/docs/cli/cloud-setup.md b/docs/cli/cloud-setup.md index 3ff847d1c..98b47ba7a 100644 --- a/docs/cli/cloud-setup.md +++ b/docs/cli/cloud-setup.md @@ -110,6 +110,8 @@ When `--skip-setup` is not set, the command runs an interactive guided flow: 5. **Grant IAM roles via SDK** - creates or validates the project custom role `cudlyCommitmentPurchaser`, then grants it and `roles/compute.viewer` to the Service Account. 6. **`gcloud iam service-accounts keys create ~/cudly-gcp-key.json`** - downloads a JSON key to your home directory. +Choosing Skip at the service-account step prompts for the email of an existing service account, which is used for the role grant and key steps. + After the guided steps (or with `--skip-setup --credentials-file `), the command reads and validates the JSON file and writes it to the `-GCPCredentials` secret. ### Non-interactive usage From b5af0358a22c132a84e3212f90812b321826883a Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 16 Oct 2026 04:23:17 +0200 Subject: [PATCH 2/2] test(cli): shorten GCP test section header below the secret-scan width The 46-dash run matched the AWS-secret pattern in the git-secrets allowlist self-test. Co-Authored-By: Claude Opus 5.5 --- cmd/configure_gcp_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/configure_gcp_test.go b/cmd/configure_gcp_test.go index 9f9657695..9e1d2811a 100644 --- a/cmd/configure_gcp_test.go +++ b/cmd/configure_gcp_test.go @@ -401,7 +401,7 @@ func TestGcloudDeleteKeyCommand(t *testing.T) { gcloudDeleteKeyCommand(testMintedKeyName)) } -// --- gcpStepCreateServiceAccount ---------------------------------------------- +// --- gcpStepCreateServiceAccount --- func TestGCPStepCreateServiceAccount_SkipUsesOperatorEmail(t *testing.T) { const existing = "existing-sa@my-proj.iam.gserviceaccount.com"