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..9e1d2811a 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