diff --git a/cmd/configure_gcp.go b/cmd/configure_gcp.go index a22452348..05e1bbaba 100644 --- a/cmd/configure_gcp.go +++ b/cmd/configure_gcp.go @@ -5,13 +5,17 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" + "io/fs" "log" "os" "os/exec" + "os/signal" "path/filepath" "regexp" "strings" + "syscall" "time" "github.com/aws/aws-sdk-go-v2/aws" @@ -141,60 +145,113 @@ func runConfigureGCP(cmd *cobra.Command, args []string) error { fmt.Println("===================================================") fmt.Println() - credsFile, err := getGCPCredentialsFilePath(ctx, reader) + // Load the AWS config before the wizard can mint a key, so a config error + // never strands a freshly minted key. + cfg, err := loadAWSConfigForGCP(ctx) if err != nil { return err } + store := NewAWSSecretsStore(secretsmanager.NewFromConfig(cfg)) - cfg, err := loadAWSConfigForGCP(ctx) + credsFile, mintedKeyName, err := getGCPCredentialsFilePath(ctx, reader) if err != nil { return err } - creds, credsData, err := loadAndUpdateGCPCredentials(credsFile) + // Scoped to the upload (no stdin reads) so an interrupt cancels the + // Secrets Manager call and the minted-key cleanup still runs. + uploadCtx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + defer stop() + creds, err := uploadGCPCredentialsFile(uploadCtx, store, gcpOpts.StackName, credsFile, mintedKeyName, deleteGCPServiceAccountKey) if err != nil { return err } - smClient := secretsmanager.NewFromConfig(cfg) - store := NewAWSSecretsStore(smClient) + printGCPConfigurationSuccess(creds) + return nil +} - if err := storeGCPCredentials(ctx, store, gcpOpts.StackName, string(credsData)); err != nil { - return err +// 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) { + if mintedKeyName != "" { + defer func() { + if err != nil { + err = deleteMintedGCPKeyRemotely(err, mintedKeyName, deleteKey) + } + if rmErr := removeMintedGCPKey(credsFile); rmErr != nil { + err = errors.Join(err, rmErr) + } else if err == nil { + fmt.Println("Removed the local copy of the minted key.") + } + }() } - printGCPConfigurationSuccess(creds) - return nil + creds, credsData, err := loadAndUpdateGCPCredentials(credsFile) + if err != nil { + return GCPCredentials{}, err + } + + if err := storeGCPCredentials(ctx, store, stackName, string(credsData)); err != nil { + return GCPCredentials{}, err + } + + return creds, nil } -// getGCPCredentialsFilePath determines the credentials file path from options or user input. -func getGCPCredentialsFilePath(ctx context.Context, reader *bufio.Reader) (string, error) { - var credsFile string +// 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() + 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)) + } + fmt.Println("Deleted the minted key from GCP.") + 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. +func getGCPCredentialsFilePath(ctx context.Context, reader *bufio.Reader) (credsFile, mintedKeyName string, err error) { if gcpOpts.CredentialsFile != "" { credsFile = gcpOpts.CredentialsFile } else if !gcpOpts.SkipSetup { - var err error - credsFile, err = runGCPSetupCommands(ctx, reader) + credsFile, mintedKeyName, err = runGCPSetupCommands(ctx, reader) if err != nil { - return "", err + return "", "", err + } + if credsFile != "" { + return credsFile, mintedKeyName, nil } } if credsFile == "" { fmt.Print("Path to GCP service account JSON key file: ") - var readErr error - credsFile, readErr = readTrimmedLine(reader) - if readErr != nil { - return "", fmt.Errorf("failed to read credentials file path: %w", readErr) + credsFile, err = readTrimmedLine(reader) + if err != nil { + return "", "", fmt.Errorf("failed to read credentials file path: %w", err) } } if credsFile == "" { - return "", fmt.Errorf("credentials file is required") + return "", "", fmt.Errorf("credentials file is required") } - return credsFile, nil + return credsFile, "", nil } // loadAWSConfigForGCP loads AWS configuration with optional profile. @@ -454,40 +511,57 @@ func (k *iamKeyProvisioner) DeleteKey(ctx context.Context, keyName string) error return err } -// createGCPServiceAccountKey creates a JSON key for the given service account -// and writes it to keyFile. This replaces +// 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) error { +func createGCPServiceAccountKey(ctx context.Context, saEmail, keyFile string) (string, error) { ctx, cancel := context.WithTimeout(ctx, gcpSDKCallTimeout) defer cancel() - opt, err := newGCPAPIOption(ctx) + p, err := newIAMKeyProvisioner(ctx) if err != nil { - return err + return "", err } + return writeServiceAccountKey(ctx, p, saEmail, keyFile) +} - svc, err := iamv1.NewService(ctx, opt) +// deleteGCPServiceAccountKey deletes the IAM key keyName via ADC. +func deleteGCPServiceAccountKey(ctx context.Context, keyName string) error { + p, err := newIAMKeyProvisioner(ctx) if err != nil { - return fmt.Errorf("failed to create IAM client: %w", err) + return err } - - return writeServiceAccountKey(ctx, &iamKeyProvisioner{svc: svc}, saEmail, keyFile) + 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. If decoding or writing fails after the remote key is minted 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) error { +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 path filepath.Join(os.UserHomeDir(), "cudly-gcp-key.json"); a constant filename under the operator's own home dir, program-controlled and not attacker input + // #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) + 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 @@ -500,7 +574,7 @@ func writeServiceAccountKey(ctx context.Context, p gcpKeyProvisioner, saEmail, k keyName, privateKeyData, err := p.CreateKey(ctx, saEmail) if err != nil { - return fmt.Errorf("failed to create service account key: %w", err) + return "", fmt.Errorf("failed to create service account key: %w", err) } // From here on, any failure must delete the newly minted remote key so it @@ -518,14 +592,14 @@ func writeServiceAccountKey(ctx context.Context, p gcpKeyProvisioner, saEmail, k // 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)) + 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)) + return "", deleteRemoteKey(fmt.Errorf("failed to write key file %s: %w", keyFile, err)) } wrote = true - return nil + return keyName, nil } // runGCPSetupCommands guides the operator through GCP setup. @@ -549,23 +623,25 @@ func writeServiceAccountKey(ctx context.Context, p gcpKeyProvisioner, saEmail, k // 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) (string, error) { - if err := gcpStepLogin(reader); err != nil { - return "", err +func runGCPSetupCommands(ctx context.Context, reader *bufio.Reader) (keyFile, keyName string, err error) { + err = gcpStepLogin(reader) + if err != nil { + return "", "", err } projectID, err := gcpStepSelectProject(ctx, reader) if err != nil { - return "", err + return "", "", err } saEmail, err := gcpStepCreateServiceAccount(ctx, reader, projectID) if err != nil { - return "", err + return "", "", err } - if err := gcpStepGrantRole(ctx, reader, projectID, saEmail); err != nil { - return "", err + err = gcpStepGrantRole(ctx, reader, projectID, saEmail) + if err != nil { + return "", "", err } return gcpStepCreateKey(ctx, reader, saEmail) @@ -711,35 +787,36 @@ func gcpStepGrantRole(ctx context.Context, reader *bufio.Reader, projectID, saEm } // gcpStepCreateKey creates a JSON key file for the service account. It returns -// the written key-file path only when a key was actually created; on skip or -// an unknown choice it returns an empty string so the caller knows to prompt -// for an existing credentials file instead of assuming one was written. -func gcpStepCreateKey(ctx context.Context, reader *bufio.Reader, saEmail string) (string, error) { - home, err := os.UserHomeDir() +// 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(ctx context.Context, reader *bufio.Reader, saEmail string) (keyFile, keyName string, err error) { + keyFile, err = newMintedGCPKeyPath() if err != nil { - return "", fmt.Errorf("failed to get home directory: %w", err) + return "", "", err } - keyFile := filepath.Join(home, "cudly-gcp-key.json") 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? (creates key for %s, writes to %s via SDK) ", saEmail, keyFile) + fmt.Printf("[R]un, [S]kip? (creates key for %s via SDK; the local copy is removed after upload) ", saEmail) choice, err := reader.ReadString('\n') if err != nil { - return "", fmt.Errorf("failed to read create-key choice: %w", err) + return "", "", errors.Join(fmt.Errorf("failed to read create-key choice: %w", err), removeMintedGCPKey(keyFile)) } switch strings.ToLower(strings.TrimSpace(choice)) { case "r", "run", "": - if keyErr := createGCPServiceAccountKey(ctx, saEmail, keyFile); keyErr != nil { - return "", keyErr + keyName, err = createGCPServiceAccountKey(ctx, saEmail, keyFile) + if err != nil { + return "", "", errors.Join(err, removeMintedGCPKey(keyFile)) } - fmt.Printf("Key file written to: %s\n", keyFile) + fmt.Printf("Key written to temporary file: %s\n", keyFile) fmt.Println() - return keyFile, nil + return keyFile, keyName, nil case "s", "skip": fmt.Println("Skipping Create Key") default: @@ -748,7 +825,32 @@ func gcpStepCreateKey(ctx context.Context, reader *bufio.Reader, saEmail string) // No key file was written; the caller will prompt for an existing one. fmt.Println() - return "", nil + 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 diff --git a/cmd/configure_gcp_test.go b/cmd/configure_gcp_test.go index d3a606cdd..f50939fbe 100644 --- a/cmd/configure_gcp_test.go +++ b/cmd/configure_gcp_test.go @@ -152,7 +152,7 @@ func TestWriteServiceAccountKey_Success(t *testing.T) { } keyFile := filepath.Join(t.TempDir(), "cudly-gcp-key.json") - err := writeServiceAccountKey(context.Background(), m, "sa@proj.iam.gserviceaccount.com", keyFile) + _, err := writeServiceAccountKey(context.Background(), m, "sa@proj.iam.gserviceaccount.com", keyFile) require.NoError(t, err) assert.True(t, m.createCalled) @@ -176,7 +176,7 @@ func TestWriteServiceAccountKey_DecodeFailureRollsBack(t *testing.T) { } keyFile := filepath.Join(t.TempDir(), "cudly-gcp-key.json") - err := writeServiceAccountKey(context.Background(), m, "sa@proj.iam.gserviceaccount.com", keyFile) + _, err := writeServiceAccountKey(context.Background(), m, "sa@proj.iam.gserviceaccount.com", keyFile) require.Error(t, err) assert.Contains(t, err.Error(), "failed to decode key data") @@ -200,7 +200,7 @@ func TestWriteServiceAccountKey_RollbackFailureSurfaced(t *testing.T) { } keyFile := filepath.Join(t.TempDir(), "cudly-gcp-key.json") - err := writeServiceAccountKey(context.Background(), m, "sa@proj.iam.gserviceaccount.com", keyFile) + _, err := writeServiceAccountKey(context.Background(), m, "sa@proj.iam.gserviceaccount.com", keyFile) require.Error(t, err) assert.True(t, m.deleteCalled) assert.Contains(t, err.Error(), "failed to decode key data", "the original cause must be surfaced") @@ -217,7 +217,7 @@ func TestWriteServiceAccountKey_CreateFailureNoOrphan(t *testing.T) { } keyFile := filepath.Join(t.TempDir(), "cudly-gcp-key.json") - err := writeServiceAccountKey(context.Background(), m, "sa@proj.iam.gserviceaccount.com", keyFile) + _, err := writeServiceAccountKey(context.Background(), m, "sa@proj.iam.gserviceaccount.com", keyFile) require.Error(t, err) assert.Contains(t, err.Error(), "failed to create service account key") assert.False(t, m.deleteCalled, "no remote key was minted, so DeleteKey must not be called") @@ -238,7 +238,7 @@ func TestWriteServiceAccountKey_ReserveFailureNoMint(t *testing.T) { privateKeyData: base64.StdEncoding.EncodeToString([]byte("{}")), } - err := writeServiceAccountKey(context.Background(), m, "sa@proj.iam.gserviceaccount.com", keyFile) + _, err := writeServiceAccountKey(context.Background(), m, "sa@proj.iam.gserviceaccount.com", keyFile) require.Error(t, err) assert.Contains(t, err.Error(), "failed to reserve key file") assert.False(t, m.createCalled, "the remote key must not be minted when the file cannot be reserved") @@ -248,3 +248,149 @@ func TestWriteServiceAccountKey_ReserveFailureNoMint(t *testing.T) { require.NoError(t, readErr) assert.Equal(t, []byte("pre-existing"), got) } + +// --- uploadGCPCredentialsFile (minted key cleanup, #1947) -------------------- + +const testMintedKeyName = "projects/proj/serviceAccounts/sa@proj.iam.gserviceaccount.com/keys/abc123" + +// mintTestGCPKey mints a key into a newMintedGCPKeyPath location through the +// real writeServiceAccountKey flow, asserts it was created with 0600, and +// returns the file and the key resource name. +func mintTestGCPKey(t *testing.T) (string, string) { + t.Helper() + keyMaterial := []byte(`{"type":"service_account","project_id":"proj","client_email":"sa@proj.iam.gserviceaccount.com","private_key":"-----BEGIN PRIVATE KEY-----\nx\n-----END PRIVATE KEY-----\n"}`) + m := &mockGCPKeyProvisioner{ + keyName: testMintedKeyName, + privateKeyData: base64.StdEncoding.EncodeToString(keyMaterial), + } + keyFile, err := newMintedGCPKeyPath() + require.NoError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(filepath.Dir(keyFile)) }) + + keyName, err := writeServiceAccountKey(context.Background(), m, "sa@proj.iam.gserviceaccount.com", keyFile) + require.NoError(t, err) + require.Equal(t, testMintedKeyName, keyName) + info, err := os.Stat(keyFile) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0600), info.Mode().Perm(), "minted key must be 0600") + dirInfo, err := os.Stat(filepath.Dir(keyFile)) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0700), dirInfo.Mode().Perm(), "minted key dir must be 0700") + return keyFile, keyName +} + +func assertMintedKeyGone(t *testing.T, keyFile string) { + t.Helper() + _, err := os.Stat(keyFile) + assert.ErrorIs(t, err, os.ErrNotExist, "minted key file must be removed") + _, err = os.Stat(filepath.Dir(keyFile)) + assert.ErrorIs(t, err, os.ErrNotExist, "minted key dir must be removed") +} + +func TestUploadGCPCredentialsFile_MintedKeyRemovedAfterSuccess(t *testing.T) { + keyFile, keyName := mintTestGCPKey(t) + store := NewMockSecretsStore() + m := &mockGCPKeyProvisioner{} + + creds, err := uploadGCPCredentialsFile(context.Background(), store, "stack", keyFile, keyName, m.DeleteKey) + require.NoError(t, err) + assert.Equal(t, "sa@proj.iam.gserviceaccount.com", creds.ClientEmail) + assert.Contains(t, store.updatedSecrets, "stack-GCPCredentials") + assert.False(t, m.deleteCalled, "a successfully uploaded key must stay active in GCP") + assertMintedKeyGone(t, keyFile) +} + +func TestUploadGCPCredentialsFile_UploadFailureDeletesRemoteKey(t *testing.T) { + keyFile, keyName := mintTestGCPKey(t) + store := NewMockSecretsStore() + store.updateSecretFunc = func(context.Context, string, string) error { return errors.New("access denied") } + m := &mockGCPKeyProvisioner{} + + _, err := uploadGCPCredentialsFile(context.Background(), store, "stack", keyFile, keyName, m.DeleteKey) + require.Error(t, err) + assert.Contains(t, err.Error(), "access denied") + assert.True(t, m.deleteCalled, "the minted key must be deleted from GCP when the upload fails") + assert.Equal(t, testMintedKeyName, m.deletedKeyName) + assertMintedKeyGone(t, keyFile) +} + +func TestUploadGCPCredentialsFile_RemoteDeleteFailureNamesGcloudCommand(t *testing.T) { + keyFile, keyName := mintTestGCPKey(t) + store := NewMockSecretsStore() + store.updateSecretFunc = func(context.Context, string, string) error { return errors.New("access denied") } + m := &mockGCPKeyProvisioner{deleteErr: errors.New("permission denied")} + + _, err := uploadGCPCredentialsFile(context.Background(), store, "stack", keyFile, keyName, m.DeleteKey) + require.Error(t, err) + assert.Contains(t, err.Error(), "access denied") + assert.Contains(t, err.Error(), "permission denied") + assert.Contains(t, err.Error(), "gcloud iam service-accounts keys delete abc123 --iam-account=sa@proj.iam.gserviceaccount.com") + assert.NotContains(t, err.Error(), "PRIVATE KEY", "the error must never carry key material") + assertMintedKeyGone(t, keyFile) +} + +// TestUploadGCPCredentialsFile_CanceledContextStillCleansUp models an +// interrupt during the Secrets Manager call: the remote delete must run on a +// live context even though the upload context is canceled. +func TestUploadGCPCredentialsFile_CanceledContextStillCleansUp(t *testing.T) { + keyFile, keyName := mintTestGCPKey(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + store := NewMockSecretsStore() + store.updateSecretFunc = func(ctx context.Context, _, _ string) error { return ctx.Err() } + var deleteCtxErr error + deleted := false + deleteKey := func(ctx context.Context, name string) error { + deleted = true + deleteCtxErr = ctx.Err() + return nil + } + + _, err := uploadGCPCredentialsFile(ctx, store, "stack", keyFile, keyName, deleteKey) + require.ErrorIs(t, err, context.Canceled) + assert.True(t, deleted, "the minted key must be deleted from GCP after an interrupt") + assert.NoError(t, deleteCtxErr, "the remote delete must not inherit the canceled context") + assertMintedKeyGone(t, keyFile) +} + +func TestUploadGCPCredentialsFile_ParseFailureDeletesRemoteKey(t *testing.T) { + keyFile, err := newMintedGCPKeyPath() + require.NoError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(filepath.Dir(keyFile)) }) + require.NoError(t, os.WriteFile(keyFile, []byte("not json"), 0600)) + m := &mockGCPKeyProvisioner{} + + _, err = uploadGCPCredentialsFile(context.Background(), NewMockSecretsStore(), "stack", keyFile, testMintedKeyName, m.DeleteKey) + require.Error(t, err) + assert.Equal(t, testMintedKeyName, m.deletedKeyName, "a minted key that cannot be parsed must still be deleted from GCP") + assertMintedKeyGone(t, keyFile) +} + +func TestUploadGCPCredentialsFile_RemovalFailureReported(t *testing.T) { + keyFile, keyName := mintTestGCPKey(t) + // A read-only parent dir makes the unlink fail. + require.NoError(t, os.Chmod(filepath.Dir(keyFile), 0500)) + t.Cleanup(func() { _ = os.Chmod(filepath.Dir(keyFile), 0700) }) + m := &mockGCPKeyProvisioner{} + + _, err := uploadGCPCredentialsFile(context.Background(), NewMockSecretsStore(), "stack", keyFile, keyName, m.DeleteKey) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to remove the minted key file") +} + +func TestUploadGCPCredentialsFile_OperatorFileKept(t *testing.T) { + keyFile, _ := mintTestGCPKey(t) + m := &mockGCPKeyProvisioner{} + + _, err := uploadGCPCredentialsFile(context.Background(), NewMockSecretsStore(), "stack", keyFile, "", m.DeleteKey) + require.NoError(t, err) + _, err = os.Stat(keyFile) + assert.NoError(t, err, "an operator-supplied credentials file must not be deleted") + assert.False(t, m.deleteCalled, "an operator-supplied key must never be deleted from GCP") +} + +func TestGcloudDeleteKeyCommand(t *testing.T) { + assert.Equal(t, + "gcloud iam service-accounts keys delete abc123 --iam-account=sa@proj.iam.gserviceaccount.com", + gcloudDeleteKeyCommand(testMintedKeyName)) +}