Skip to content

Commit 028551d

Browse files
cristimclaude
andauthored
fix(cli): prompt for an existing service account when GCP setup skips creation (#2211)
* fix(cli): prompt for an existing service account when GCP setup skips creation gcpStepCreateServiceAccount returned the fabricated default email cudly-service-account@<project>.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, <name>@...gserviceaccount.com) and fails loud otherwise. Wizard-driving tests gain the extra email line. Closes #2007 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 1836029 commit 028551d

5 files changed

Lines changed: 67 additions & 7 deletions

File tree

‎cmd/configure_gcp.go‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"strings"
1919
"syscall"
2020
"time"
21+
"unicode"
2122

2223
"github.com/aws/aws-sdk-go-v2/aws"
2324
awsconfig "github.com/aws/aws-sdk-go-v2/config"
@@ -751,8 +752,6 @@ func gcpStepSelectProject(ctx context.Context, reader *bufio.Reader) (string, er
751752
// SDK. It fails loud on any SDK error (no CLI fallback).
752753
func gcpStepCreateServiceAccount(ctx context.Context, reader *bufio.Reader, projectID string) (string, error) {
753754
saName := "cudly-service-account"
754-
saEmail := fmt.Sprintf("%s@%s.iam.gserviceaccount.com", saName, projectID)
755-
756755
fmt.Println()
757756
fmt.Println("Step 3: Create Service Account")
758757
fmt.Println("------------------------------")
@@ -770,14 +769,33 @@ func gcpStepCreateServiceAccount(ctx context.Context, reader *bufio.Reader, proj
770769
if createErr != nil {
771770
return "", createErr
772771
}
773-
saEmail = email
774-
fmt.Printf("Service account created: %s\n", saEmail)
772+
fmt.Printf("Service account created: %s\n", email)
773+
return email, nil
775774
case "s", "skip":
776775
fmt.Println("Skipping Create Service Account")
776+
return promptExistingGCPServiceAccount(reader)
777777
default:
778778
fmt.Printf("Unknown option, skipping\n")
779+
return promptExistingGCPServiceAccount(reader)
780+
}
781+
}
782+
783+
// promptExistingGCPServiceAccount asks for the email of an existing service
784+
// account when creation is skipped, so later steps never act on a principal
785+
// that was neither created nor supplied by the operator.
786+
func promptExistingGCPServiceAccount(reader *bufio.Reader) (string, error) {
787+
email, err := readRequiredInputLine(reader, "Skipped. Enter the existing service account email (name@project.iam.gserviceaccount.com): ", "service-account email")
788+
if err != nil {
789+
return "", err
790+
}
791+
email = strings.ToLower(email)
792+
local, domain, ok := strings.Cut(email, "@")
793+
label, hasSuffix := strings.CutSuffix(domain, ".gserviceaccount.com")
794+
if !ok || local == "" || !hasSuffix || label == "" || strings.Contains(domain, "@") ||
795+
strings.IndexFunc(email, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }) >= 0 {
796+
return "", fmt.Errorf("invalid service account email %q: expected a name@<project>.gserviceaccount.com address", email)
779797
}
780-
return saEmail, nil
798+
return email, nil
781799
}
782800

783801
// gcpCustomRole is a single-permission custom role the wizard provisions.

‎cmd/configure_gcp_iam_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ func runGCPIAMFixture(t *testing.T, scenario string) {
180180
stdin, writeErr := os.Create(filepath.Join(dir, "stdin"))
181181
require.NoError(t, writeErr)
182182
defer stdin.Close()
183-
_, writeErr = io.WriteString(stdin, "s\ns\ns\nfixture-project\ns\nr\n")
183+
_, writeErr = io.WriteString(stdin, "s\ns\ns\nfixture-project\ns\ncudly-service-account@fixture-project.iam.gserviceaccount.com\nr\n")
184184
require.NoError(t, writeErr)
185185
_, writeErr = stdin.Seek(0, io.SeekStart)
186186
require.NoError(t, writeErr)

‎cmd/configure_gcp_signal_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ func startGCPInterruptChild(t *testing.T, phase string) (*exec.Cmd, string, io.R
115115
child.Stderr = stderr
116116
require.NoError(t, child.Start())
117117
t.Cleanup(func() { _ = child.Process.Kill() })
118-
input := "s\ns\ns\nfixture-project\ns\ns\n"
118+
input := "s\ns\ns\nfixture-project\ns\ncudly-service-account@fixture-project.iam.gserviceaccount.com\ns\n"
119119
if phase != "prompt" {
120120
input += "r\n"
121121
}

‎cmd/configure_gcp_test.go‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package main
22

33
import (
4+
"bufio"
45
"context"
56
"encoding/base64"
67
"errors"
78
"os"
89
"path/filepath"
10+
"strings"
911
"testing"
1012

1113
"github.com/stretchr/testify/assert"
@@ -398,3 +400,41 @@ func TestGcloudDeleteKeyCommand(t *testing.T) {
398400
"gcloud iam service-accounts keys delete abc123 --iam-account=sa@proj.iam.gserviceaccount.com",
399401
gcloudDeleteKeyCommand(testMintedKeyName))
400402
}
403+
404+
// --- gcpStepCreateServiceAccount ---
405+
406+
func TestGCPStepCreateServiceAccount_SkipUsesOperatorEmail(t *testing.T) {
407+
const existing = "existing-sa@my-proj.iam.gserviceaccount.com"
408+
reader := bufio.NewReader(strings.NewReader("s\n " + existing + " \n"))
409+
410+
email, err := gcpStepCreateServiceAccount(context.Background(), reader, "my-proj")
411+
412+
require.NoError(t, err)
413+
assert.Equal(t, existing, email)
414+
assert.NotEqual(t, "cudly-service-account@my-proj.iam.gserviceaccount.com", email)
415+
}
416+
417+
func TestGCPStepCreateServiceAccount_UnknownOptionPromptsForEmail(t *testing.T) {
418+
const existing = "other@my-proj.iam.gserviceaccount.com"
419+
reader := bufio.NewReader(strings.NewReader("x\n" + existing + "\n"))
420+
421+
email, err := gcpStepCreateServiceAccount(context.Background(), reader, "my-proj")
422+
423+
require.NoError(t, err)
424+
assert.Equal(t, existing, email)
425+
}
426+
427+
func TestGCPStepCreateServiceAccount_SkipRejectsInvalidEmail(t *testing.T) {
428+
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"} {
429+
email, err := gcpStepCreateServiceAccount(context.Background(), bufio.NewReader(strings.NewReader(in)), "my-proj")
430+
require.Error(t, err, "input %q", in)
431+
assert.Empty(t, email)
432+
}
433+
}
434+
435+
func TestGCPStepCreateServiceAccount_SkipAcceptsUppercaseEmail(t *testing.T) {
436+
const existing = "Existing-SA@My-Proj.IAM.GServiceAccount.com"
437+
email, err := gcpStepCreateServiceAccount(context.Background(), bufio.NewReader(strings.NewReader("s\n"+existing+"\n")), "my-proj")
438+
require.NoError(t, err)
439+
assert.Equal(t, strings.ToLower(existing), email)
440+
}

‎docs/cli/cloud-setup.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ When `--skip-setup` is not set, the command runs an interactive guided flow:
110110
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.
111111
6. **`gcloud iam service-accounts keys create ~/cudly-gcp-key.json`** - downloads a JSON key to your home directory.
112112

113+
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.
114+
113115
After the guided steps (or with `--skip-setup --credentials-file <path>`), the command reads and validates the JSON file and writes it to the `<stack-name>-GCPCredentials` secret.
114116

115117
### Non-interactive usage

0 commit comments

Comments
 (0)