diff --git a/.golangci.yml b/.golangci.yml index 0819b9791..5a6e5bd64 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -113,7 +113,7 @@ linters: # Suppressed per-file rather than per-line so #nosec G204 can lead the comment. - linters: - noctx - path: (cmd/configure_gcp|cmd/configure_azure)\.go + path: cmd/configure_(gcp|azure)(_exec|_setup)?\.go # noctx: http.Get and net.Listen in test helpers appropriately use the # background context; threading a test context through every helper would # add noise without improving test correctness. diff --git a/cmd/configure_gcp.go b/cmd/configure_gcp.go index ea9cb3387..dfb1399fa 100644 --- a/cmd/configure_gcp.go +++ b/cmd/configure_gcp.go @@ -4,30 +4,20 @@ import ( "bufio" "context" "encoding/json" - "errors" "fmt" "log" "os" - "os/exec" "os/signal" "path/filepath" "regexp" - "slices" "strings" "sync/atomic" "syscall" - "time" - "unicode" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/secretsmanager" "github.com/spf13/cobra" - "golang.org/x/oauth2/google" - "google.golang.org/api/cloudresourcemanager/v1" - "google.golang.org/api/googleapi" - iamv1 "google.golang.org/api/iam/v1" - "google.golang.org/api/option" ) // gcpProjectIDRegex validates GCP project IDs (lowercase letters, digits, hyphens, 6-30 chars). @@ -290,546 +280,3 @@ func printGCPConfigurationSuccess(creds GCPCredentials) { fmt.Printf("Project ID: %s\n", creds.ProjectID) fmt.Println("\nCUDly can now manage GCP Committed Use Discounts.") } - -// gcpSDKCallTimeout bounds each GCP SDK helper so an ADC lookup or API call -// cannot hang indefinitely (the calls inherit context.Background()). -const gcpSDKCallTimeout = 60 * time.Second - -// newGCPAPIOption returns an oauth2 token source option for the google.golang.org -// API client, using Application Default Credentials. ADC resolves credentials in -// priority order: GOOGLE_APPLICATION_CREDENTIALS env var, gcloud ADC cache -// (populated by "gcloud auth application-default login"), Workload Identity, -// Metadata Server. -// -// NOTE: "gcloud auth login" (wizard Step 1) updates the gcloud user session but -// does NOT populate the ADC cache; the wizard's Step 1b -// ("gcloud auth application-default login") does that. If ADC is still not -// available (e.g. the operator skipped Step 1b) these calls fail loud with a -// hint to run "gcloud auth application-default login". -func newGCPAPIOption(ctx context.Context) (option.ClientOption, error) { - ts, err := google.DefaultTokenSource(ctx, - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/iam", - ) - if err != nil { - return nil, fmt.Errorf("failed to obtain GCP Application Default Credentials: %w\n"+ - "Hint: run 'gcloud auth application-default login' first", err) - } - return option.WithTokenSource(ts), nil -} - -// listGCPProjects lists GCP projects accessible to the operator via the Cloud -// Resource Manager API v1 and prints them in a table. This replaces the -// "gcloud projects list" CLI call. -func listGCPProjects(ctx context.Context) error { - ctx, cancel := context.WithTimeout(ctx, gcpSDKCallTimeout) - defer cancel() - - opt, err := newGCPAPIOption(ctx) - if err != nil { - return err - } - - svc, err := cloudresourcemanager.NewService(ctx, opt) - if err != nil { - return fmt.Errorf("failed to create resource manager client: %w", err) - } - - fmt.Printf("%-30s %-25s %s\n", "NAME", "PROJECT_ID", "PROJECT_NUMBER") - fmt.Println(strings.Repeat("-", 80)) - - req := svc.Projects.List() - if err := req.Pages(ctx, func(page *cloudresourcemanager.ListProjectsResponse) error { - for _, p := range page.Projects { - fmt.Printf("%-30s %-25s %d\n", p.Name, p.ProjectId, p.ProjectNumber) - } - return nil - }); err != nil { - return fmt.Errorf("failed to list GCP projects: %w", err) - } - return nil -} - -// createGCPServiceAccount creates a GCP IAM service account via the IAM API v1. -// This replaces "gcloud iam service-accounts create". -func createGCPServiceAccount(ctx context.Context, projectID, saName string) (string, error) { - ctx, cancel := context.WithTimeout(ctx, gcpSDKCallTimeout) - defer cancel() - - opt, err := newGCPAPIOption(ctx) - if err != nil { - return "", err - } - - svc, err := iamv1.NewService(ctx, opt) - if err != nil { - return "", fmt.Errorf("failed to create IAM client: %w", err) - } - - req := &iamv1.CreateServiceAccountRequest{ - AccountId: saName, - ServiceAccount: &iamv1.ServiceAccount{ - DisplayName: "CUDly Service Account", - Description: "Service account for CUDly commitment management", - }, - } - - sa, err := svc.Projects.ServiceAccounts.Create("projects/"+projectID, req).Context(ctx).Do() - if err != nil { - return "", fmt.Errorf("failed to create service account: %w", err) - } - - return sa.Email, nil -} - -// grantGCPIAMRole grants an IAM role to a service account on a project via the -// Cloud Resource Manager API v1. This replaces -// "gcloud projects add-iam-policy-binding". -func grantGCPIAMRole(ctx context.Context, projectID, member, role string) error { - ctx, cancel := context.WithTimeout(ctx, gcpSDKCallTimeout) - defer cancel() - - opt, err := newGCPAPIOption(ctx) - if err != nil { - return err - } - - svc, err := cloudresourcemanager.NewService(ctx, opt) - if err != nil { - return fmt.Errorf("failed to create resource manager client: %w", err) - } - - // Request policy version 3 so conditional (IAM condition) bindings are - // returned and preserved on the read-modify-write round-trip; otherwise - // the SetIamPolicy below would silently drop them. - policy, err := svc.Projects.GetIamPolicy(projectID, &cloudresourcemanager.GetIamPolicyRequest{ - Options: &cloudresourcemanager.GetPolicyOptions{RequestedPolicyVersion: 3}, - }).Context(ctx).Do() - if err != nil { - return fmt.Errorf("failed to get IAM policy for project %s: %w", projectID, err) - } - - changed, err := addMemberToPolicyBinding(policy, member, role) - if err != nil { - return err - } - if !changed { - // Member already bound to the role; nothing to write. - return nil - } - - // Write the policy back at version 3 to retain any conditional bindings. - if policy.Version < 3 { - policy.Version = 3 - } - _, err = svc.Projects.SetIamPolicy(projectID, &cloudresourcemanager.SetIamPolicyRequest{ - Policy: policy, - }).Context(ctx).Do() - if err != nil { - return fmt.Errorf("failed to set IAM policy on project %s: %w", projectID, err) - } - return nil -} - -// Existing conditions must not be silently widened or treated as unconditional grants. -func addMemberToPolicyBinding(policy *cloudresourcemanager.Policy, member, role string) (bool, error) { - conditionalMember := false - var unconditional *cloudresourcemanager.Binding - for _, b := range policy.Bindings { - if b.Role != role { - continue - } - for _, m := range b.Members { - if m == member { - if b.Condition == nil { - return false, nil - } - conditionalMember = true - } - } - if b.Condition == nil { - unconditional = b - } - } - if conditionalMember { - return false, fmt.Errorf("%s has only conditional access to %s; review the existing IAM condition before granting unconditional access", member, role) - } - if unconditional != nil { - unconditional.Members = append(unconditional.Members, member) - return true, nil - } - policy.Bindings = append(policy.Bindings, &cloudresourcemanager.Binding{ - Role: role, - Members: []string{member}, - }) - return true, nil -} - -// runGCPSetupCommands guides the operator through GCP setup. -// -// Step 1 (gcloud auth login) and Step 1b (gcloud auth application-default -// login): performed via the GCP CLI. Both are interactive browser-based OAuth -// flows that cannot be replicated through SDK calls on behalf of an operator -// who does not yet have a credential. Step 1 establishes the gcloud user -// session; Step 1b populates the Application Default Credentials (ADC) cache -// that every SDK-based step below authenticates through. Both are run in one -// pass so a fresh operator completes setup without re-running the wizard. -// -// Step 2 (list projects): performed via the Cloud Resource Manager SDK v1, -// using Application Default Credentials (ADC). Fails loud if ADC is not -// available (no CLI fallback). -// -// Step 3 (gcloud config set project): sets local gcloud config state. There is -// no cloud-API equivalent for writing to the local gcloud configuration file, -// so this step remains a CLI call. -// -// Steps 4-6 (create SA, grant role, create key): performed via GCP IAM and -// Cloud Resource Manager SDK v1 APIs using ADC. Fail loud on any SDK error -// (no CLI fallback). -func runGCPSetupCommands(ctx context.Context, reader *bufio.Reader, beginUpload func() context.Context) (keyFile, keyName string, err error) { - err = gcpStepLogin(reader) - if err != nil { - return "", "", err - } - - projectID, err := gcpStepSelectProject(ctx, reader) - if err != nil { - return "", "", err - } - - saEmail, err := gcpStepCreateServiceAccount(ctx, reader, projectID) - if err != nil { - return "", "", err - } - - err = gcpStepGrantRole(ctx, reader, projectID, saEmail) - if err != nil { - return "", "", err - } - - return gcpStepCreateKey(reader, saEmail, beginUpload) -} - -// gcpStepLogin runs the two interactive gcloud logins the wizard needs: -// "gcloud auth login" (user session) and "gcloud auth application-default -// login" (ADC cache). Both are browser-based OAuth flows with no SDK -// equivalent. The ADC login is required because the SDK-based steps below -// (list projects, create service account, grant role, create key) authenticate -// through Application Default Credentials, which "gcloud auth login" alone does -// NOT populate -- so without it a fresh operator would hard-abort at Step 2. -func gcpStepLogin(reader *bufio.Reader) error { - fmt.Println("Step 1: GCP Login") - fmt.Println("-----------------") - fmt.Println("This opens a browser window for GCP authentication (user session).") - fmt.Println() - if err := promptAndRunGCPCommand(reader, "GCP Login", "gcloud auth login", "gcloud", "auth", "login"); err != nil { - return err - } - - fmt.Println() - fmt.Println("Step 1b: GCP Application Default Credentials Login") - fmt.Println("-------------------------------------------------") - fmt.Println("This opens a browser window to populate the Application Default") - fmt.Println("Credentials (ADC) cache used by the SDK-based steps below") - fmt.Println("(list projects, create service account, grant role, create key).") - fmt.Println() - return promptAndRunGCPCommand(reader, "GCP ADC Login", - "gcloud auth application-default login", - "gcloud", "auth", "application-default", "login") -} - -// gcpStepSelectProject optionally lists projects and prompts for a project ID. -// The listing is behind a [R]un/[S]kip prompt so an operator who already knows -// their project ID can proceed even if the SDK listing would fail; when RUN it -// fails loud (no CLI fallback), instructing the operator to run -// "gcloud auth application-default login" first. -func gcpStepSelectProject(ctx context.Context, reader *bufio.Reader) (string, error) { - fmt.Println() - fmt.Println("Step 2: Select Project") - fmt.Println("----------------------") - - run, err := promptRunOrSkipListing(reader, "the GCP project listing (via SDK)") - if err != nil { - return "", err - } - if run { - fmt.Println("Listing your GCP projects via SDK (Application Default Credentials)...") - fmt.Println() - if err = listGCPProjects(ctx); err != nil { - return "", fmt.Errorf("failed to list GCP projects via SDK: %w\n"+ - "Ensure Application Default Credentials are set: run 'gcloud auth application-default login' first", err) - } - fmt.Println() - } - - projectID, err := readRequiredInputLine(reader, "Enter your Project ID: ", "project ID") - if err != nil { - return "", err - } - if err := validateGCPProjectID(projectID); err != nil { - return "", err - } - - // Set the project in the local gcloud config. This is a local operation - // (writes to ~/.config/gcloud/properties) with no cloud-API equivalent. - fmt.Println() - fmt.Println("Setting gcloud project context (local config)...") - // #nosec G204 G702 -- local gcloud config write: fixed argv ("gcloud config set project"), projectID pre-validated by validateGCPProjectID (strict regex) just above, passed as a discrete argv element with no shell, so it cannot inject - cmd := exec.Command("gcloud", "config", "set", "project", projectID) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return "", fmt.Errorf("failed to set project: %w", err) - } - return projectID, nil -} - -// gcpStepCreateServiceAccount creates the CUDly service account via the IAM -// 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" - fmt.Println() - fmt.Println("Step 3: Create Service Account") - fmt.Println("------------------------------") - fmt.Println("This creates a GCP Service Account for CUDly via the IAM API.") - fmt.Println() - fmt.Printf("[r]un, [S]kip (default)? (creates service account '%s' via SDK) ", saName) - - choice, err := reader.ReadString('\n') - if err != nil { - return "", fmt.Errorf("failed to read service-account choice: %w", err) - } - switch strings.ToLower(strings.TrimSpace(choice)) { - case "r", "run": - email, createErr := createGCPServiceAccount(ctx, projectID, saName) - if createErr != nil { - return "", createErr - } - 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 email, nil -} - -// gcpCustomRole is a single-permission custom role the wizard provisions. -type gcpCustomRole struct { - id string - title string - permission string -} - -var ( - gcpPurchaserRole = gcpCustomRole{id: "cudlyCommitmentPurchaser", title: "CUDly Commitment Purchaser", permission: "compute.commitments.create"} - // gcpRecommendationReaderRole covers the project-scoped - // google.compute.commitment.UsageCommitmentRecommender list call. - gcpRecommendationReaderRole = gcpCustomRole{id: "cudlyRecommendationReader", title: "CUDly Recommendation Reader", permission: "recommender.usageCommitmentRecommendations.list"} -) - -func ensureGCPCustomRole(ctx context.Context, projectID string, spec gcpCustomRole) (string, error) { - ctx, cancel := context.WithTimeout(ctx, gcpSDKCallTimeout) - defer cancel() - opt, err := newGCPAPIOption(ctx) - if err != nil { - return "", err - } - svc, err := iamv1.NewService(ctx, opt) - if err != nil { - return "", fmt.Errorf("failed to create IAM client: %w", err) - } - parent := "projects/" + projectID - name := parent + "/roles/" + spec.id - want := []string{spec.permission} - role, err := svc.Projects.Roles.Get(name).Context(ctx).Do() - var apiErr *googleapi.Error - if errors.As(err, &apiErr) && apiErr.Code == 404 { - role, err = svc.Projects.Roles.Create(parent, &iamv1.CreateRoleRequest{ - RoleId: spec.id, - Role: &iamv1.Role{Title: spec.title, Stage: "GA", IncludedPermissions: want}, - }).Context(ctx).Do() - } - if err != nil { - return "", fmt.Errorf("failed to provision custom role %s; review the role and setup permissions before retrying: %w", name, err) - } - if role.Name != name || role.Deleted || role.Stage == "DISABLED" || !slices.Equal(role.IncludedPermissions, want) { - return "", fmt.Errorf("unsafe custom role %s: expected an enabled role containing only %s; review it before retrying", name, spec.permission) - } - return name, nil -} - -func gcpStepGrantRole(ctx context.Context, reader *bufio.Reader, projectID, saEmail string) error { - member := fmt.Sprintf("serviceAccount:%s", saEmail) - - fmt.Println() - fmt.Println("Step 4: Grant IAM Roles") - fmt.Println("-----------------------") - fmt.Println("Grant the required roles to the service account.") - fmt.Println() - fmt.Println("Prerequisite: enable recommender.googleapis.com in the service account's project (this step does not enable it).") - fmt.Printf("[r]un, [S]kip (default)? (creates or validates %s with %s and %s with %s, then grants both and roles/compute.viewer to %s on project %s via SDK) ", - gcpPurchaserRole.id, gcpPurchaserRole.permission, gcpRecommendationReaderRole.id, gcpRecommendationReaderRole.permission, saEmail, projectID) - - choice, err := reader.ReadString('\n') - if err != nil { - return fmt.Errorf("failed to read grant-role choice: %w", err) - } - switch strings.ToLower(strings.TrimSpace(choice)) { - case "r", "run": - grants := []string{"roles/compute.viewer"} - for _, spec := range []gcpCustomRole{gcpPurchaserRole, gcpRecommendationReaderRole} { - role, roleErr := ensureGCPCustomRole(ctx, projectID, spec) - if roleErr != nil { - return roleErr - } - grants = append(grants, role) - } - for _, grant := range grants { - if grantErr := grantGCPIAMRole(ctx, projectID, member, grant); grantErr != nil { - return grantErr - } - fmt.Printf("Role %s granted to %s on project %s.\n", grant, saEmail, projectID) - } - case "s", "skip", "": - fmt.Println("Skipping Grant IAM Roles") - default: - fmt.Printf("Unknown option, skipping\n") - } - return nil -} - -// gcpStepCreateKey creates a JSON key file for the service account. It returns -// the written key-file path and the key resource name only when a key was -// actually created; on skip or an unknown choice it returns empty strings so -// the caller knows to prompt for an existing credentials file instead of -// assuming one was written. -func gcpStepCreateKey(reader *bufio.Reader, saEmail string, beginUpload func() context.Context) (keyFile, keyName string, err error) { - keyFile, err = newMintedGCPKeyPath() - if err != nil { - return "", "", err - } - - fmt.Println() - fmt.Println("Step 5: Create and Download Key") - fmt.Println("-------------------------------") - fmt.Println("Create a JSON key file for the service account.") - fmt.Println() - fmt.Printf("[r]un, [S]kip (default)? (creates key for %s via SDK; the local copy is removed after upload) ", saEmail) - - choice, err := reader.ReadString('\n') - if err != nil { - return "", "", errors.Join(fmt.Errorf("failed to read create-key choice: %w", err), removeMintedGCPKey(keyFile)) - } - switch strings.ToLower(strings.TrimSpace(choice)) { - case "r", "run": - keyName, err = createGCPServiceAccountKey(beginUpload(), saEmail, keyFile) - if err != nil { - return "", "", errors.Join(err, removeMintedGCPKey(keyFile)) - } - return keyFile, keyName, nil - case "s", "skip", "": - fmt.Println("Skipping Create Key") - default: - fmt.Printf("Unknown option, skipping\n") - } - - // No key file was written; the caller will prompt for an existing one. - fmt.Println() - return "", "", removeMintedGCPKey(keyFile) -} - -// readRequiredInputLine prints prompt, reads a line, trims whitespace, and -// returns an error if the result is empty. -func readRequiredInputLine(reader *bufio.Reader, prompt, fieldName string) (string, error) { - fmt.Print(prompt) - value, err := readTrimmedLine(reader) - if err != nil { - return "", fmt.Errorf("failed to read %s: %w", fieldName, err) - } - if value == "" { - return "", fmt.Errorf("%s is required", fieldName) - } - return value, nil -} - -// promptAndRunGCPCommand shows a command and asks to run or skip. -// It is used only for the interactive "gcloud auth login" auth bootstrap -// (Step 1), which has no SDK equivalent that preserves the cached-credential UX. -func promptAndRunGCPCommand(reader *bufio.Reader, name, displayCmd, program string, args ...string) error { - fmt.Printf("Command: %s\n", displayCmd) - fmt.Println() - fmt.Printf("[r]un, [S]kip (default)? ") - - choice, err := readTrimmedLine(reader) - if err != nil { - return fmt.Errorf("failed to read choice: %w", err) - } - choice = strings.ToLower(choice) - - switch choice { - case "r", "run": - return executeGCPCommand(reader, displayCmd, program, args...) - case "s", "skip", "": - fmt.Printf("Skipping %s\n", name) - return nil - default: - fmt.Printf("Unknown option '%s', skipping\n", choice) - return nil - } -} - -// executeGCPCommand runs a gcloud command with explicit program and arguments. -// It is used only for the interactive "gcloud auth login" auth bootstrap. -// The caller's reader is threaded through to the retry prompt so all input -// is consumed from one consistent buffered stream (a fresh -// bufio.NewReader(os.Stdin) here would drop input already buffered by the -// caller's reader, breaking piped input after earlier prompts). -func executeGCPCommand(reader *bufio.Reader, displayCmd, program string, args ...string) error { - fmt.Println() - fmt.Printf("Executing: %s\n", displayCmd) - fmt.Println(strings.Repeat("-", 60)) - - // #nosec G204 -- interactive operator auth (gcloud auth login): program and args are hardcoded literals from the caller (runGCPSetupCommands passes "gcloud","auth","login"), no shell, not attacker-controlled - cmd := exec.Command(program, args...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Stdin = os.Stdin - - err := cmd.Run() - fmt.Println(strings.Repeat("-", 60)) - - if err != nil { - fmt.Printf("Command failed: %v\n", err) - fmt.Print("Continue anyway? [y/N]: ") - response, readErr := readTrimmedLine(reader) - if readErr != nil { - return fmt.Errorf("failed to read response: %w", readErr) - } - if !strings.EqualFold(response, "y") { - return fmt.Errorf("command failed: %w", err) - } - } - - return nil -} diff --git a/cmd/configure_gcp_exec.go b/cmd/configure_gcp_exec.go new file mode 100644 index 000000000..02d5c3a50 --- /dev/null +++ b/cmd/configure_gcp_exec.go @@ -0,0 +1,84 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "strings" +) + +// readRequiredInputLine prints prompt, reads a line, trims whitespace, and +// returns an error if the result is empty. +func readRequiredInputLine(reader *bufio.Reader, prompt, fieldName string) (string, error) { + fmt.Print(prompt) + value, err := readTrimmedLine(reader) + if err != nil { + return "", fmt.Errorf("failed to read %s: %w", fieldName, err) + } + if value == "" { + return "", fmt.Errorf("%s is required", fieldName) + } + return value, nil +} + +// promptAndRunGCPCommand shows a command and asks to run or skip. +// It is used only for the interactive "gcloud auth login" auth bootstrap +// (Step 1), which has no SDK equivalent that preserves the cached-credential UX. +func promptAndRunGCPCommand(reader *bufio.Reader, name, displayCmd, program string, args ...string) error { + fmt.Printf("Command: %s\n", displayCmd) + fmt.Println() + fmt.Printf("[r]un, [S]kip (default)? ") + + choice, err := readTrimmedLine(reader) + if err != nil { + return fmt.Errorf("failed to read choice: %w", err) + } + choice = strings.ToLower(choice) + + switch choice { + case "r", "run": + return executeGCPCommand(reader, displayCmd, program, args...) + case "s", "skip", "": + fmt.Printf("Skipping %s\n", name) + return nil + default: + fmt.Printf("Unknown option '%s', skipping\n", choice) + return nil + } +} + +// executeGCPCommand runs a gcloud command with explicit program and arguments. +// It is used only for the interactive "gcloud auth login" auth bootstrap. +// The caller's reader is threaded through to the retry prompt so all input +// is consumed from one consistent buffered stream (a fresh +// bufio.NewReader(os.Stdin) here would drop input already buffered by the +// caller's reader, breaking piped input after earlier prompts). +func executeGCPCommand(reader *bufio.Reader, displayCmd, program string, args ...string) error { + fmt.Println() + fmt.Printf("Executing: %s\n", displayCmd) + fmt.Println(strings.Repeat("-", 60)) + + // #nosec G204 -- interactive operator auth (gcloud auth login): program and args are hardcoded literals from the caller (runGCPSetupCommands passes "gcloud","auth","login"), no shell, not attacker-controlled + cmd := exec.Command(program, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + err := cmd.Run() + fmt.Println(strings.Repeat("-", 60)) + + if err != nil { + fmt.Printf("Command failed: %v\n", err) + fmt.Print("Continue anyway? [y/N]: ") + response, readErr := readTrimmedLine(reader) + if readErr != nil { + return fmt.Errorf("failed to read response: %w", readErr) + } + if !strings.EqualFold(response, "y") { + return fmt.Errorf("command failed: %w", err) + } + } + + return nil +} diff --git a/cmd/configure_gcp_grant.go b/cmd/configure_gcp_grant.go new file mode 100644 index 000000000..a9c9da000 --- /dev/null +++ b/cmd/configure_gcp_grant.go @@ -0,0 +1,89 @@ +package main + +import ( + "bufio" + "context" + "errors" + "fmt" + "strings" +) + +func gcpStepGrantRole(ctx context.Context, reader *bufio.Reader, projectID, saEmail string) error { + member := fmt.Sprintf("serviceAccount:%s", saEmail) + + fmt.Println() + fmt.Println("Step 4: Grant IAM Roles") + fmt.Println("-----------------------") + fmt.Println("Grant the required roles to the service account.") + fmt.Println() + fmt.Println("Prerequisite: enable recommender.googleapis.com in the service account's project (this step does not enable it).") + fmt.Printf("[r]un, [S]kip (default)? (creates or validates %s with %s and %s with %s, then grants both and roles/compute.viewer to %s on project %s via SDK) ", + gcpPurchaserRole.id, gcpPurchaserRole.permission, gcpRecommendationReaderRole.id, gcpRecommendationReaderRole.permission, saEmail, projectID) + + choice, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read grant-role choice: %w", err) + } + switch strings.ToLower(strings.TrimSpace(choice)) { + case "r", "run": + grants := []string{"roles/compute.viewer"} + for _, spec := range []gcpCustomRole{gcpPurchaserRole, gcpRecommendationReaderRole} { + role, roleErr := ensureGCPCustomRole(ctx, projectID, spec) + if roleErr != nil { + return roleErr + } + grants = append(grants, role) + } + for _, grant := range grants { + if grantErr := grantGCPIAMRole(ctx, projectID, member, grant); grantErr != nil { + return grantErr + } + fmt.Printf("Role %s granted to %s on project %s.\n", grant, saEmail, projectID) + } + case "s", "skip", "": + fmt.Println("Skipping Grant IAM Roles") + default: + fmt.Printf("Unknown option, skipping\n") + } + return nil +} + +// gcpStepCreateKey creates a JSON key file for the service account. It returns +// the written key-file path and the key resource name only when a key was +// actually created; on skip or an unknown choice it returns empty strings so +// the caller knows to prompt for an existing credentials file instead of +// assuming one was written. +func gcpStepCreateKey(reader *bufio.Reader, saEmail string, beginUpload func() context.Context) (keyFile, keyName string, err error) { + keyFile, err = newMintedGCPKeyPath() + if err != nil { + return "", "", err + } + + fmt.Println() + fmt.Println("Step 5: Create and Download Key") + fmt.Println("-------------------------------") + fmt.Println("Create a JSON key file for the service account.") + fmt.Println() + fmt.Printf("[r]un, [S]kip (default)? (creates key for %s via SDK; the local copy is removed after upload) ", saEmail) + + choice, err := reader.ReadString('\n') + if err != nil { + return "", "", errors.Join(fmt.Errorf("failed to read create-key choice: %w", err), removeMintedGCPKey(keyFile)) + } + switch strings.ToLower(strings.TrimSpace(choice)) { + case "r", "run": + keyName, err = createGCPServiceAccountKey(beginUpload(), saEmail, keyFile) + if err != nil { + return "", "", errors.Join(err, removeMintedGCPKey(keyFile)) + } + return keyFile, keyName, nil + case "s", "skip", "": + fmt.Println("Skipping Create Key") + default: + fmt.Printf("Unknown option, skipping\n") + } + + // No key file was written; the caller will prompt for an existing one. + fmt.Println() + return "", "", removeMintedGCPKey(keyFile) +} diff --git a/cmd/configure_gcp_iam.go b/cmd/configure_gcp_iam.go new file mode 100644 index 000000000..813635800 --- /dev/null +++ b/cmd/configure_gcp_iam.go @@ -0,0 +1,91 @@ +package main + +import ( + "context" + "fmt" + + "google.golang.org/api/cloudresourcemanager/v1" +) + +// grantGCPIAMRole grants an IAM role to a service account on a project via the +// Cloud Resource Manager API v1. This replaces +// "gcloud projects add-iam-policy-binding". +func grantGCPIAMRole(ctx context.Context, projectID, member, role string) error { + ctx, cancel := context.WithTimeout(ctx, gcpSDKCallTimeout) + defer cancel() + + opt, err := newGCPAPIOption(ctx) + if err != nil { + return err + } + + svc, err := cloudresourcemanager.NewService(ctx, opt) + if err != nil { + return fmt.Errorf("failed to create resource manager client: %w", err) + } + + // Request policy version 3 so conditional (IAM condition) bindings are + // returned and preserved on the read-modify-write round-trip; otherwise + // the SetIamPolicy below would silently drop them. + policy, err := svc.Projects.GetIamPolicy(projectID, &cloudresourcemanager.GetIamPolicyRequest{ + Options: &cloudresourcemanager.GetPolicyOptions{RequestedPolicyVersion: 3}, + }).Context(ctx).Do() + if err != nil { + return fmt.Errorf("failed to get IAM policy for project %s: %w", projectID, err) + } + + changed, err := addMemberToPolicyBinding(policy, member, role) + if err != nil { + return err + } + if !changed { + // Member already bound to the role; nothing to write. + return nil + } + + // Write the policy back at version 3 to retain any conditional bindings. + if policy.Version < 3 { + policy.Version = 3 + } + _, err = svc.Projects.SetIamPolicy(projectID, &cloudresourcemanager.SetIamPolicyRequest{ + Policy: policy, + }).Context(ctx).Do() + if err != nil { + return fmt.Errorf("failed to set IAM policy on project %s: %w", projectID, err) + } + return nil +} + +// Existing conditions must not be silently widened or treated as unconditional grants. +func addMemberToPolicyBinding(policy *cloudresourcemanager.Policy, member, role string) (bool, error) { + conditionalMember := false + var unconditional *cloudresourcemanager.Binding + for _, b := range policy.Bindings { + if b.Role != role { + continue + } + for _, m := range b.Members { + if m == member { + if b.Condition == nil { + return false, nil + } + conditionalMember = true + } + } + if b.Condition == nil { + unconditional = b + } + } + if conditionalMember { + return false, fmt.Errorf("%s has only conditional access to %s; review the existing IAM condition before granting unconditional access", member, role) + } + if unconditional != nil { + unconditional.Members = append(unconditional.Members, member) + return true, nil + } + policy.Bindings = append(policy.Bindings, &cloudresourcemanager.Binding{ + Role: role, + Members: []string{member}, + }) + return true, nil +} diff --git a/cmd/configure_gcp_sa.go b/cmd/configure_gcp_sa.go new file mode 100644 index 000000000..5f30a8682 --- /dev/null +++ b/cmd/configure_gcp_sa.go @@ -0,0 +1,109 @@ +package main + +import ( + "bufio" + "context" + "errors" + "fmt" + "slices" + "strings" + "unicode" + + "google.golang.org/api/googleapi" + iamv1 "google.golang.org/api/iam/v1" +) + +// gcpStepCreateServiceAccount creates the CUDly service account via the IAM +// 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" + fmt.Println() + fmt.Println("Step 3: Create Service Account") + fmt.Println("------------------------------") + fmt.Println("This creates a GCP Service Account for CUDly via the IAM API.") + fmt.Println() + fmt.Printf("[r]un, [S]kip (default)? (creates service account '%s' via SDK) ", saName) + + choice, err := reader.ReadString('\n') + if err != nil { + return "", fmt.Errorf("failed to read service-account choice: %w", err) + } + switch strings.ToLower(strings.TrimSpace(choice)) { + case "r", "run": + email, createErr := createGCPServiceAccount(ctx, projectID, saName) + if createErr != nil { + return "", createErr + } + 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 email, nil +} + +// gcpCustomRole is a single-permission custom role the wizard provisions. +type gcpCustomRole struct { + id string + title string + permission string +} + +var ( + gcpPurchaserRole = gcpCustomRole{id: "cudlyCommitmentPurchaser", title: "CUDly Commitment Purchaser", permission: "compute.commitments.create"} + // gcpRecommendationReaderRole covers the project-scoped + // google.compute.commitment.UsageCommitmentRecommender list call. + gcpRecommendationReaderRole = gcpCustomRole{id: "cudlyRecommendationReader", title: "CUDly Recommendation Reader", permission: "recommender.usageCommitmentRecommendations.list"} +) + +func ensureGCPCustomRole(ctx context.Context, projectID string, spec gcpCustomRole) (string, error) { + ctx, cancel := context.WithTimeout(ctx, gcpSDKCallTimeout) + defer cancel() + opt, err := newGCPAPIOption(ctx) + if err != nil { + return "", err + } + svc, err := iamv1.NewService(ctx, opt) + if err != nil { + return "", fmt.Errorf("failed to create IAM client: %w", err) + } + parent := "projects/" + projectID + name := parent + "/roles/" + spec.id + want := []string{spec.permission} + role, err := svc.Projects.Roles.Get(name).Context(ctx).Do() + var apiErr *googleapi.Error + if errors.As(err, &apiErr) && apiErr.Code == 404 { + role, err = svc.Projects.Roles.Create(parent, &iamv1.CreateRoleRequest{ + RoleId: spec.id, + Role: &iamv1.Role{Title: spec.title, Stage: "GA", IncludedPermissions: want}, + }).Context(ctx).Do() + } + if err != nil { + return "", fmt.Errorf("failed to provision custom role %s; review the role and setup permissions before retrying: %w", name, err) + } + if role.Name != name || role.Deleted || role.Stage == "DISABLED" || !slices.Equal(role.IncludedPermissions, want) { + return "", fmt.Errorf("unsafe custom role %s: expected an enabled role containing only %s; review it before retrying", name, spec.permission) + } + return name, nil +} diff --git a/cmd/configure_gcp_sdk.go b/cmd/configure_gcp_sdk.go new file mode 100644 index 000000000..ee18657e5 --- /dev/null +++ b/cmd/configure_gcp_sdk.go @@ -0,0 +1,104 @@ +package main + +import ( + "context" + "fmt" + "strings" + "time" + + "golang.org/x/oauth2/google" + "google.golang.org/api/cloudresourcemanager/v1" + iamv1 "google.golang.org/api/iam/v1" + "google.golang.org/api/option" +) + +// gcpSDKCallTimeout bounds each GCP SDK helper so an ADC lookup or API call +// cannot hang indefinitely (the calls inherit context.Background()). +const gcpSDKCallTimeout = 60 * time.Second + +// newGCPAPIOption returns an oauth2 token source option for the google.golang.org +// API client, using Application Default Credentials. ADC resolves credentials in +// priority order: GOOGLE_APPLICATION_CREDENTIALS env var, gcloud ADC cache +// (populated by "gcloud auth application-default login"), Workload Identity, +// Metadata Server. +// +// NOTE: "gcloud auth login" (wizard Step 1) updates the gcloud user session but +// does NOT populate the ADC cache; the wizard's Step 1b +// ("gcloud auth application-default login") does that. If ADC is still not +// available (e.g. the operator skipped Step 1b) these calls fail loud with a +// hint to run "gcloud auth application-default login". +func newGCPAPIOption(ctx context.Context) (option.ClientOption, error) { + ts, err := google.DefaultTokenSource(ctx, + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/iam", + ) + if err != nil { + return nil, fmt.Errorf("failed to obtain GCP Application Default Credentials: %w\n"+ + "Hint: run 'gcloud auth application-default login' first", err) + } + return option.WithTokenSource(ts), nil +} + +// listGCPProjects lists GCP projects accessible to the operator via the Cloud +// Resource Manager API v1 and prints them in a table. This replaces the +// "gcloud projects list" CLI call. +func listGCPProjects(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, gcpSDKCallTimeout) + defer cancel() + + opt, err := newGCPAPIOption(ctx) + if err != nil { + return err + } + + svc, err := cloudresourcemanager.NewService(ctx, opt) + if err != nil { + return fmt.Errorf("failed to create resource manager client: %w", err) + } + + fmt.Printf("%-30s %-25s %s\n", "NAME", "PROJECT_ID", "PROJECT_NUMBER") + fmt.Println(strings.Repeat("-", 80)) + + req := svc.Projects.List() + if err := req.Pages(ctx, func(page *cloudresourcemanager.ListProjectsResponse) error { + for _, p := range page.Projects { + fmt.Printf("%-30s %-25s %d\n", p.Name, p.ProjectId, p.ProjectNumber) + } + return nil + }); err != nil { + return fmt.Errorf("failed to list GCP projects: %w", err) + } + return nil +} + +// createGCPServiceAccount creates a GCP IAM service account via the IAM API v1. +// This replaces "gcloud iam service-accounts create". +func createGCPServiceAccount(ctx context.Context, projectID, saName string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, gcpSDKCallTimeout) + defer cancel() + + opt, err := newGCPAPIOption(ctx) + if err != nil { + return "", err + } + + svc, err := iamv1.NewService(ctx, opt) + if err != nil { + return "", fmt.Errorf("failed to create IAM client: %w", err) + } + + req := &iamv1.CreateServiceAccountRequest{ + AccountId: saName, + ServiceAccount: &iamv1.ServiceAccount{ + DisplayName: "CUDly Service Account", + Description: "Service account for CUDly commitment management", + }, + } + + sa, err := svc.Projects.ServiceAccounts.Create("projects/"+projectID, req).Context(ctx).Do() + if err != nil { + return "", fmt.Errorf("failed to create service account: %w", err) + } + + return sa.Email, nil +} diff --git a/cmd/configure_gcp_setup.go b/cmd/configure_gcp_setup.go new file mode 100644 index 000000000..9130f4d3b --- /dev/null +++ b/cmd/configure_gcp_setup.go @@ -0,0 +1,128 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "os" + "os/exec" +) + +// runGCPSetupCommands guides the operator through GCP setup. +// +// Step 1 (gcloud auth login) and Step 1b (gcloud auth application-default +// login): performed via the GCP CLI. Both are interactive browser-based OAuth +// flows that cannot be replicated through SDK calls on behalf of an operator +// who does not yet have a credential. Step 1 establishes the gcloud user +// session; Step 1b populates the Application Default Credentials (ADC) cache +// that every SDK-based step below authenticates through. Both are run in one +// pass so a fresh operator completes setup without re-running the wizard. +// +// Step 2 (list projects): performed via the Cloud Resource Manager SDK v1, +// using Application Default Credentials (ADC). Fails loud if ADC is not +// available (no CLI fallback). +// +// Step 3 (gcloud config set project): sets local gcloud config state. There is +// no cloud-API equivalent for writing to the local gcloud configuration file, +// so this step remains a CLI call. +// +// Steps 4-6 (create SA, grant role, create key): performed via GCP IAM and +// Cloud Resource Manager SDK v1 APIs using ADC. Fail loud on any SDK error +// (no CLI fallback). +func runGCPSetupCommands(ctx context.Context, reader *bufio.Reader, beginUpload func() context.Context) (keyFile, keyName string, err error) { + err = gcpStepLogin(reader) + if err != nil { + return "", "", err + } + + projectID, err := gcpStepSelectProject(ctx, reader) + if err != nil { + return "", "", err + } + + saEmail, err := gcpStepCreateServiceAccount(ctx, reader, projectID) + if err != nil { + return "", "", err + } + + err = gcpStepGrantRole(ctx, reader, projectID, saEmail) + if err != nil { + return "", "", err + } + + return gcpStepCreateKey(reader, saEmail, beginUpload) +} + +// gcpStepLogin runs the two interactive gcloud logins the wizard needs: +// "gcloud auth login" (user session) and "gcloud auth application-default +// login" (ADC cache). Both are browser-based OAuth flows with no SDK +// equivalent. The ADC login is required because the SDK-based steps below +// (list projects, create service account, grant role, create key) authenticate +// through Application Default Credentials, which "gcloud auth login" alone does +// NOT populate -- so without it a fresh operator would hard-abort at Step 2. +func gcpStepLogin(reader *bufio.Reader) error { + fmt.Println("Step 1: GCP Login") + fmt.Println("-----------------") + fmt.Println("This opens a browser window for GCP authentication (user session).") + fmt.Println() + if err := promptAndRunGCPCommand(reader, "GCP Login", "gcloud auth login", "gcloud", "auth", "login"); err != nil { + return err + } + + fmt.Println() + fmt.Println("Step 1b: GCP Application Default Credentials Login") + fmt.Println("-------------------------------------------------") + fmt.Println("This opens a browser window to populate the Application Default") + fmt.Println("Credentials (ADC) cache used by the SDK-based steps below") + fmt.Println("(list projects, create service account, grant role, create key).") + fmt.Println() + return promptAndRunGCPCommand(reader, "GCP ADC Login", + "gcloud auth application-default login", + "gcloud", "auth", "application-default", "login") +} + +// gcpStepSelectProject optionally lists projects and prompts for a project ID. +// The listing is behind a [R]un/[S]kip prompt so an operator who already knows +// their project ID can proceed even if the SDK listing would fail; when RUN it +// fails loud (no CLI fallback), instructing the operator to run +// "gcloud auth application-default login" first. +func gcpStepSelectProject(ctx context.Context, reader *bufio.Reader) (string, error) { + fmt.Println() + fmt.Println("Step 2: Select Project") + fmt.Println("----------------------") + + run, err := promptRunOrSkipListing(reader, "the GCP project listing (via SDK)") + if err != nil { + return "", err + } + if run { + fmt.Println("Listing your GCP projects via SDK (Application Default Credentials)...") + fmt.Println() + if err = listGCPProjects(ctx); err != nil { + return "", fmt.Errorf("failed to list GCP projects via SDK: %w\n"+ + "Ensure Application Default Credentials are set: run 'gcloud auth application-default login' first", err) + } + fmt.Println() + } + + projectID, err := readRequiredInputLine(reader, "Enter your Project ID: ", "project ID") + if err != nil { + return "", err + } + if err := validateGCPProjectID(projectID); err != nil { + return "", err + } + + // Set the project in the local gcloud config. This is a local operation + // (writes to ~/.config/gcloud/properties) with no cloud-API equivalent. + fmt.Println() + fmt.Println("Setting gcloud project context (local config)...") + // #nosec G204 G702 -- local gcloud config write: fixed argv ("gcloud config set project"), projectID pre-validated by validateGCPProjectID (strict regex) just above, passed as a discrete argv element with no shell, so it cannot inject + cmd := exec.Command("gcloud", "config", "set", "project", projectID) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("failed to set project: %w", err) + } + return projectID, nil +}