diff --git a/cmd/configure_gcp.go b/cmd/configure_gcp.go
index 47fa8fa50..f66f81f56 100644
--- a/cmd/configure_gcp.go
+++ b/cmd/configure_gcp.go
@@ -3,11 +3,9 @@ package main
import (
"bufio"
"context"
- "encoding/base64"
"encoding/json"
"errors"
"fmt"
- "io/fs"
"log"
"os"
"os/exec"
@@ -202,71 +200,6 @@ func runConfigureGCP(cmd *cobra.Command, args []string) error {
return nil
}
-// uploadGCPCredentialsFile stores credsFile in the secrets store. A non-empty
-// mintedKeyName means the file is a key this run created: the local copy is
-// removed on every path, and on failure the remote key is deleted via
-// deleteKey so it does not stay active. Cleanup failures are returned.
-func uploadGCPCredentialsFile(ctx context.Context, store SecretsStore, stackName, credsFile, mintedKeyName string, deleteKey func(context.Context, string) error) (creds GCPCredentials, err error) {
- done := false
- if mintedKeyName != "" {
- defer func() {
- // A panic or Goexit leaves the named err nil and done false; the
- // return value is lost, so report the outcome on stderr. The panic
- // keeps propagating.
- aborted := err == nil && !done
- if aborted {
- err = errors.New("upload aborted before completion")
- }
- // Remove the local copy first: a second Ctrl-C during the slow
- // remote delete kills the process and must not leave it on disk.
- rmErr := removeMintedGCPKey(credsFile)
- if err != nil {
- err = deleteMintedGCPKeyRemotely(err, mintedKeyName, deleteKey)
- }
- if rmErr != nil {
- err = errors.Join(err, rmErr)
- }
- if aborted {
- fmt.Fprintln(os.Stderr, err)
- }
- }()
- }
-
- creds, credsData, err := loadAndUpdateGCPCredentials(credsFile)
- if err != nil {
- return GCPCredentials{}, err
- }
-
- if err := storeGCPCredentials(ctx, store, stackName, string(credsData)); err != nil {
- return GCPCredentials{}, err
- }
-
- done = true
- return creds, nil
-}
-
-// deleteMintedGCPKeyRemotely deletes the minted key after cause aborted the
-// upload. It uses a fresh context so a canceled parent does not skip it.
-func deleteMintedGCPKeyRemotely(cause error, keyName string, deleteKey func(context.Context, string) error) error {
- ctx, cancel := context.WithTimeout(context.Background(), gcpSDKCallTimeout)
- defer cancel()
- fmt.Fprintf(os.Stderr, "Deleting minted key; if aborted, delete it with: %s\n", gcloudDeleteKeyCommand(keyName))
- if err := deleteKey(ctx, keyName); err != nil {
- return fmt.Errorf("%w; the minted key is still active in GCP and deleting it failed (%w); delete it with: %s", cause, err, gcloudDeleteKeyCommand(keyName))
- }
- return cause
-}
-
-// gcloudDeleteKeyCommand renders the manual delete command for an IAM key
-// resource name (projects/
/serviceAccounts//keys/).
-func gcloudDeleteKeyCommand(keyName string) string {
- parts := strings.Split(keyName, "/")
- if len(parts) == 6 && parts[2] == "serviceAccounts" && parts[4] == "keys" {
- return fmt.Sprintf("gcloud iam service-accounts keys delete %s --iam-account=%s", parts[5], parts[3])
- }
- return fmt.Sprintf("gcloud iam service-accounts keys delete ", keyName)
-}
-
// getGCPCredentialsFilePath determines the credentials file path from options
// or user input. mintedKeyName is the IAM key resource name when the setup
// wizard created the file, and empty otherwise.
@@ -536,144 +469,6 @@ func addMemberToPolicyBinding(policy *cloudresourcemanager.Policy, member, role
return true, nil
}
-// gcpKeyProvisioner abstracts the IAM service-account key operations used by
-// writeServiceAccountKey. It exists so the reserve / mint / decode / write /
-// rollback flow can be unit-tested with a mock without hitting GCP (mirrors
-// azureSPProvisioner in configure_azure_sp.go).
-type gcpKeyProvisioner interface {
- // CreateKey mints a new JSON key for saEmail and returns the key resource
- // name and the base64-encoded private key material.
- CreateKey(ctx context.Context, saEmail string) (keyName, privateKeyData string, err error)
- // DeleteKey deletes the key identified by keyName. It is the compensating
- // action used to avoid orphaning a freshly minted key on a local failure.
- DeleteKey(ctx context.Context, keyName string) error
-}
-
-// iamKeyProvisioner is the production gcpKeyProvisioner backed by the IAM API v1.
-type iamKeyProvisioner struct {
- svc *iamv1.Service
-}
-
-func (k *iamKeyProvisioner) CreateKey(ctx context.Context, saEmail string) (keyName, privateKeyData string, err error) {
- resource := fmt.Sprintf("projects/-/serviceAccounts/%s", saEmail)
- key, err := k.svc.Projects.ServiceAccounts.Keys.Create(resource, &iamv1.CreateServiceAccountKeyRequest{
- PrivateKeyType: "TYPE_GOOGLE_CREDENTIALS_FILE",
- }).Context(ctx).Do()
- if err != nil {
- return "", "", err
- }
- return key.Name, key.PrivateKeyData, nil
-}
-
-// createKeyError wraps a CreateKey failure. Only a 4xx googleapi error proves
-// the server rejected the request; anything else (cancellation, timeout, 5xx,
-// network error) may have followed a committed mint, so warn the operator.
-func createKeyError(saEmail string, err error) error {
- var gerr *googleapi.Error
- if errors.As(err, &gerr) && gerr.Code >= 400 && gerr.Code < 500 {
- return fmt.Errorf("failed to create service account key: %w", err)
- }
- return fmt.Errorf("failed to create service account key: %w; the key may have been created before the request ended: "+
- "list keys with `gcloud iam service-accounts keys list --iam-account=%s --managed-by=user` and delete any key created just now "+
- "with `gcloud iam service-accounts keys delete --iam-account=%s`", err, saEmail, saEmail)
-}
-
-func (k *iamKeyProvisioner) DeleteKey(ctx context.Context, keyName string) error {
- _, err := k.svc.Projects.ServiceAccounts.Keys.Delete(keyName).Context(ctx).Do()
- return err
-}
-
-// newIAMKeyProvisioner builds an iamKeyProvisioner authenticated via ADC.
-func newIAMKeyProvisioner(ctx context.Context) (*iamKeyProvisioner, error) {
- opt, err := newGCPAPIOption(ctx)
- if err != nil {
- return nil, err
- }
-
- svc, err := iamv1.NewService(ctx, opt)
- if err != nil {
- return nil, fmt.Errorf("failed to create IAM client: %w", err)
- }
- return &iamKeyProvisioner{svc: svc}, nil
-}
-
-// createGCPServiceAccountKey creates a JSON key for the given service account,
-// writes it to keyFile and returns the key resource name. This replaces
-// "gcloud iam service-accounts keys create --iam-account=".
-func createGCPServiceAccountKey(ctx context.Context, saEmail, keyFile string) (string, error) {
- ctx, cancel := context.WithTimeout(ctx, gcpSDKCallTimeout)
- defer cancel()
-
- p, err := newIAMKeyProvisioner(ctx)
- if err != nil {
- return "", err
- }
- return writeServiceAccountKey(ctx, p, saEmail, keyFile)
-}
-
-// deleteGCPServiceAccountKey deletes the IAM key keyName via ADC.
-func deleteGCPServiceAccountKey(ctx context.Context, keyName string) error {
- p, err := newIAMKeyProvisioner(ctx)
- if err != nil {
- return err
- }
- return p.DeleteKey(ctx, keyName)
-}
-
-// writeServiceAccountKey reserves keyFile with exclusive-create semantics
-// BEFORE minting the remote key (so it never mints a key it cannot persist
-// locally), then mints the key via p, decodes the base64 material and writes it
-// to keyFile, returning the key resource name. If decoding or writing fails after the remote key is minted it
-// deletes the remote key so it does not linger as an active, unused credential.
-// Extracted from createGCPServiceAccountKey so the reserve / mint / rollback
-// flow is unit-testable with a mock (no GCP credentials).
-func writeServiceAccountKey(ctx context.Context, p gcpKeyProvisioner, saEmail, keyFile string) (string, error) {
- // Reserve the destination file first (fails if it already exists), so we
- // never mint a remote key we cannot persist locally.
- // #nosec G304 -- keyFile is the sole caller's fixed filename inside a private os.MkdirTemp dir (newMintedGCPKeyPath); program-controlled and not attacker input
- f, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
- if err != nil {
- return "", fmt.Errorf("failed to reserve key file %s: %w", keyFile, err)
- }
- // Best-effort: remove the reserved file if we return before writing it.
- wrote := false
- defer func() {
- _ = f.Close()
- if !wrote {
- _ = os.Remove(keyFile)
- }
- }()
-
- keyName, privateKeyData, err := p.CreateKey(ctx, saEmail)
- if err != nil {
- return "", createKeyError(saEmail, err)
- }
-
- // From here on, any failure must delete the newly minted remote key so it
- // does not linger as an active, unused credential.
- deleteRemoteKey := func(cause error) error {
- // Use a fresh context: the parent may already be canceled/expired.
- delCtx, delCancel := context.WithTimeout(context.Background(), gcpSDKCallTimeout)
- defer delCancel()
- if delErr := p.DeleteKey(delCtx, keyName); delErr != nil {
- return fmt.Errorf("%w; additionally failed to delete the orphaned remote key %s: %w", cause, keyName, delErr)
- }
- return cause
- }
-
- // PrivateKeyData is base64-encoded JSON.
- decoded, err := base64.StdEncoding.DecodeString(privateKeyData)
- if err != nil {
- return "", deleteRemoteKey(fmt.Errorf("failed to decode key data: %w", err))
- }
-
- if _, err := f.Write(decoded); err != nil {
- return "", deleteRemoteKey(fmt.Errorf("failed to write key file %s: %w", keyFile, err))
- }
- wrote = true
- return keyName, nil
-}
-
// runGCPSetupCommands guides the operator through GCP setup.
//
// Step 1 (gcloud auth login) and Step 1b (gcloud auth application-default
@@ -968,31 +763,6 @@ func gcpStepCreateKey(reader *bufio.Reader, saEmail string, beginUpload func() c
return "", "", removeMintedGCPKey(keyFile)
}
-// mintedGCPKeyFilename is the key file's name inside its private temp dir.
-const mintedGCPKeyFilename = "key.json"
-
-// newMintedGCPKeyPath returns a key path inside a fresh 0700 temp dir, so the
-// minted key is never readable by other users or left in a synced home dir.
-func newMintedGCPKeyPath() (string, error) {
- dir, err := os.MkdirTemp("", "cudly-gcp-key-")
- if err != nil {
- return "", fmt.Errorf("failed to create private directory for the key: %w", err)
- }
- return filepath.Join(dir, mintedGCPKeyFilename), nil
-}
-
-// removeMintedGCPKey deletes a key path from newMintedGCPKeyPath and its dir.
-// It removes non-recursively so it can never delete anything else.
-func removeMintedGCPKey(keyFile string) error {
- if err := os.Remove(keyFile); err != nil && !errors.Is(err, fs.ErrNotExist) {
- return fmt.Errorf("failed to remove the minted key file %s; delete it manually: %w", keyFile, err)
- }
- if err := os.Remove(filepath.Dir(keyFile)); err != nil && !errors.Is(err, fs.ErrNotExist) {
- return fmt.Errorf("failed to remove the minted key directory %s: %w", filepath.Dir(keyFile), err)
- }
- return nil
-}
-
// 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) {
diff --git a/cmd/configure_gcp_keys.go b/cmd/configure_gcp_keys.go
new file mode 100644
index 000000000..f32423c42
--- /dev/null
+++ b/cmd/configure_gcp_keys.go
@@ -0,0 +1,243 @@
+package main
+
+import (
+ "context"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "google.golang.org/api/googleapi"
+ iamv1 "google.golang.org/api/iam/v1"
+)
+
+// uploadGCPCredentialsFile stores credsFile in the secrets store. A non-empty
+// mintedKeyName means the file is a key this run created: the local copy is
+// removed on every path, and on failure the remote key is deleted via
+// deleteKey so it does not stay active. Cleanup failures are returned.
+func uploadGCPCredentialsFile(ctx context.Context, store SecretsStore, stackName, credsFile, mintedKeyName string, deleteKey func(context.Context, string) error) (creds GCPCredentials, err error) {
+ done := false
+ if mintedKeyName != "" {
+ defer func() {
+ // A panic or Goexit leaves the named err nil and done false; the
+ // return value is lost, so report the outcome on stderr. The panic
+ // keeps propagating.
+ aborted := err == nil && !done
+ if aborted {
+ err = errors.New("upload aborted before completion")
+ }
+ // Remove the local copy first: a second Ctrl-C during the slow
+ // remote delete kills the process and must not leave it on disk.
+ rmErr := removeMintedGCPKey(credsFile)
+ if err != nil {
+ err = deleteMintedGCPKeyRemotely(err, mintedKeyName, deleteKey)
+ }
+ if rmErr != nil {
+ err = errors.Join(err, rmErr)
+ }
+ if aborted {
+ fmt.Fprintln(os.Stderr, err)
+ }
+ }()
+ }
+
+ creds, credsData, err := loadAndUpdateGCPCredentials(credsFile)
+ if err != nil {
+ return GCPCredentials{}, err
+ }
+
+ if err := storeGCPCredentials(ctx, store, stackName, string(credsData)); err != nil {
+ return GCPCredentials{}, err
+ }
+
+ done = true
+ return creds, nil
+}
+
+// deleteMintedGCPKeyRemotely deletes the minted key after cause aborted the
+// upload. It uses a fresh context so a canceled parent does not skip it.
+func deleteMintedGCPKeyRemotely(cause error, keyName string, deleteKey func(context.Context, string) error) error {
+ ctx, cancel := context.WithTimeout(context.Background(), gcpSDKCallTimeout)
+ defer cancel()
+ fmt.Fprintf(os.Stderr, "Deleting minted key; if aborted, delete it with: %s\n", gcloudDeleteKeyCommand(keyName))
+ if err := deleteKey(ctx, keyName); err != nil {
+ return fmt.Errorf("%w; the minted key is still active in GCP and deleting it failed (%w); delete it with: %s", cause, err, gcloudDeleteKeyCommand(keyName))
+ }
+ return cause
+}
+
+// gcloudDeleteKeyCommand renders the manual delete command for an IAM key
+// resource name (projects//serviceAccounts//keys/).
+func gcloudDeleteKeyCommand(keyName string) string {
+ parts := strings.Split(keyName, "/")
+ if len(parts) == 6 && parts[2] == "serviceAccounts" && parts[4] == "keys" {
+ return fmt.Sprintf("gcloud iam service-accounts keys delete %s --iam-account=%s", parts[5], parts[3])
+ }
+ return fmt.Sprintf("gcloud iam service-accounts keys delete ", keyName)
+}
+
+// gcpKeyProvisioner abstracts the IAM service-account key operations used by
+// writeServiceAccountKey. It exists so the reserve / mint / decode / write /
+// rollback flow can be unit-tested with a mock without hitting GCP (mirrors
+// azureSPProvisioner in configure_azure_sp.go).
+type gcpKeyProvisioner interface {
+ // CreateKey mints a new JSON key for saEmail and returns the key resource
+ // name and the base64-encoded private key material.
+ CreateKey(ctx context.Context, saEmail string) (keyName, privateKeyData string, err error)
+ // DeleteKey deletes the key identified by keyName. It is the compensating
+ // action used to avoid orphaning a freshly minted key on a local failure.
+ DeleteKey(ctx context.Context, keyName string) error
+}
+
+// iamKeyProvisioner is the production gcpKeyProvisioner backed by the IAM API v1.
+type iamKeyProvisioner struct {
+ svc *iamv1.Service
+}
+
+func (k *iamKeyProvisioner) CreateKey(ctx context.Context, saEmail string) (keyName, privateKeyData string, err error) {
+ resource := fmt.Sprintf("projects/-/serviceAccounts/%s", saEmail)
+ key, err := k.svc.Projects.ServiceAccounts.Keys.Create(resource, &iamv1.CreateServiceAccountKeyRequest{
+ PrivateKeyType: "TYPE_GOOGLE_CREDENTIALS_FILE",
+ }).Context(ctx).Do()
+ if err != nil {
+ return "", "", err
+ }
+ return key.Name, key.PrivateKeyData, nil
+}
+
+// createKeyError wraps a CreateKey failure. Only a 4xx googleapi error proves
+// the server rejected the request; anything else (cancellation, timeout, 5xx,
+// network error) may have followed a committed mint, so warn the operator.
+func createKeyError(saEmail string, err error) error {
+ var gerr *googleapi.Error
+ if errors.As(err, &gerr) && gerr.Code >= 400 && gerr.Code < 500 {
+ return fmt.Errorf("failed to create service account key: %w", err)
+ }
+ return fmt.Errorf("failed to create service account key: %w; the key may have been created before the request ended: "+
+ "list keys with `gcloud iam service-accounts keys list --iam-account=%s --managed-by=user` and delete any key created just now "+
+ "with `gcloud iam service-accounts keys delete --iam-account=%s`", err, saEmail, saEmail)
+}
+
+func (k *iamKeyProvisioner) DeleteKey(ctx context.Context, keyName string) error {
+ _, err := k.svc.Projects.ServiceAccounts.Keys.Delete(keyName).Context(ctx).Do()
+ return err
+}
+
+// newIAMKeyProvisioner builds an iamKeyProvisioner authenticated via ADC.
+func newIAMKeyProvisioner(ctx context.Context) (*iamKeyProvisioner, error) {
+ opt, err := newGCPAPIOption(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ svc, err := iamv1.NewService(ctx, opt)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create IAM client: %w", err)
+ }
+ return &iamKeyProvisioner{svc: svc}, nil
+}
+
+// createGCPServiceAccountKey creates a JSON key for the given service account,
+// writes it to keyFile and returns the key resource name. This replaces
+// "gcloud iam service-accounts keys create --iam-account=".
+func createGCPServiceAccountKey(ctx context.Context, saEmail, keyFile string) (string, error) {
+ ctx, cancel := context.WithTimeout(ctx, gcpSDKCallTimeout)
+ defer cancel()
+
+ p, err := newIAMKeyProvisioner(ctx)
+ if err != nil {
+ return "", err
+ }
+ return writeServiceAccountKey(ctx, p, saEmail, keyFile)
+}
+
+// deleteGCPServiceAccountKey deletes the IAM key keyName via ADC.
+func deleteGCPServiceAccountKey(ctx context.Context, keyName string) error {
+ p, err := newIAMKeyProvisioner(ctx)
+ if err != nil {
+ return err
+ }
+ return p.DeleteKey(ctx, keyName)
+}
+
+// writeServiceAccountKey reserves keyFile with exclusive-create semantics
+// BEFORE minting the remote key (so it never mints a key it cannot persist
+// locally), then mints the key via p, decodes the base64 material and writes it
+// to keyFile, returning the key resource name. If decoding or writing fails after the remote key is minted it
+// deletes the remote key so it does not linger as an active, unused credential.
+// Extracted from createGCPServiceAccountKey so the reserve / mint / rollback
+// flow is unit-testable with a mock (no GCP credentials).
+func writeServiceAccountKey(ctx context.Context, p gcpKeyProvisioner, saEmail, keyFile string) (string, error) {
+ // Reserve the destination file first (fails if it already exists), so we
+ // never mint a remote key we cannot persist locally.
+ // #nosec G304 -- keyFile is the sole caller's fixed filename inside a private os.MkdirTemp dir (newMintedGCPKeyPath); program-controlled and not attacker input
+ f, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
+ if err != nil {
+ return "", fmt.Errorf("failed to reserve key file %s: %w", keyFile, err)
+ }
+ // Best-effort: remove the reserved file if we return before writing it.
+ wrote := false
+ defer func() {
+ _ = f.Close()
+ if !wrote {
+ _ = os.Remove(keyFile)
+ }
+ }()
+
+ keyName, privateKeyData, err := p.CreateKey(ctx, saEmail)
+ if err != nil {
+ return "", createKeyError(saEmail, err)
+ }
+
+ // From here on, any failure must delete the newly minted remote key so it
+ // does not linger as an active, unused credential.
+ deleteRemoteKey := func(cause error) error {
+ // Use a fresh context: the parent may already be canceled/expired.
+ delCtx, delCancel := context.WithTimeout(context.Background(), gcpSDKCallTimeout)
+ defer delCancel()
+ if delErr := p.DeleteKey(delCtx, keyName); delErr != nil {
+ return fmt.Errorf("%w; additionally failed to delete the orphaned remote key %s: %w", cause, keyName, delErr)
+ }
+ return cause
+ }
+
+ // PrivateKeyData is base64-encoded JSON.
+ decoded, err := base64.StdEncoding.DecodeString(privateKeyData)
+ if err != nil {
+ return "", deleteRemoteKey(fmt.Errorf("failed to decode key data: %w", err))
+ }
+
+ if _, err := f.Write(decoded); err != nil {
+ return "", deleteRemoteKey(fmt.Errorf("failed to write key file %s: %w", keyFile, err))
+ }
+ wrote = true
+ return keyName, nil
+}
+
+// mintedGCPKeyFilename is the key file's name inside its private temp dir.
+const mintedGCPKeyFilename = "key.json"
+
+// newMintedGCPKeyPath returns a key path inside a fresh 0700 temp dir, so the
+// minted key is never readable by other users or left in a synced home dir.
+func newMintedGCPKeyPath() (string, error) {
+ dir, err := os.MkdirTemp("", "cudly-gcp-key-")
+ if err != nil {
+ return "", fmt.Errorf("failed to create private directory for the key: %w", err)
+ }
+ return filepath.Join(dir, mintedGCPKeyFilename), nil
+}
+
+// removeMintedGCPKey deletes a key path from newMintedGCPKeyPath and its dir.
+// It removes non-recursively so it can never delete anything else.
+func removeMintedGCPKey(keyFile string) error {
+ if err := os.Remove(keyFile); err != nil && !errors.Is(err, fs.ErrNotExist) {
+ return fmt.Errorf("failed to remove the minted key file %s; delete it manually: %w", keyFile, err)
+ }
+ if err := os.Remove(filepath.Dir(keyFile)); err != nil && !errors.Is(err, fs.ErrNotExist) {
+ return fmt.Errorf("failed to remove the minted key directory %s: %w", filepath.Dir(keyFile), err)
+ }
+ return nil
+}