From 8b91b0393c7f379e1f3fe627c98d56df3899d028 Mon Sep 17 00:00:00 2001 From: fullsend-code <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 06:11:46 +0000 Subject: [PATCH 1/3] feat(#2680): add mint delete command to tear down mint infrastructure Add `fullsend mint delete` as the inverse of `mint deploy`, supporting all three deployment shapes: GCP (--platform=gcp): deletes Cloud Function, PEM secrets in Secret Manager, the mint service account, and the WIF pool with all providers. Cloudflare durable (--platform=cloudflare): deletes the Worker script and all associated bindings/secrets via wrangler delete. Cloudflare preview (--platform=cloudflare --preview=): abandons the preview alias without affecting the durable Worker script. Supports --dry-run to preview what would be deleted, and --yolo to skip the confirmation prompt (consistent with mint unenroll). Implementation: - Add DeleteFunction, DeleteServiceAccount, DeleteWIFPool to GCFClient interface with LiveGCFClient implementations using GCP REST APIs - Add DeleteMintFunction, DeleteMintServiceAccount, DeleteMintWIFPool provisioner wrapper methods - Extend CF Provisioner.Teardown to support durable Worker deletion (previously rejected durable mode; now calls wrangler.Delete) - Register newMintDeleteCmd in the mint command group - GCP delete order: function first (stops serving), then PEM secrets, service account, and WIF pool. Partial failures on non-critical resources (SA, WIF) are reported as warnings, not hard errors. Closes #2680 --- internal/cli/mint.go | 1 + internal/cli/mint_delete.go | 339 +++++++++++++++++++++++ internal/cli/mint_test.go | 204 ++++++++++++++ internal/cli/repos_gitlab_test.go | 9 + internal/dispatch/cf/provisioner.go | 31 +-- internal/dispatch/cf/provisioner_test.go | 14 +- internal/dispatch/gcf/fakeclient.go | 9 + internal/dispatch/gcf/gcp.go | 83 ++++++ internal/dispatch/gcf/provisioner.go | 20 ++ 9 files changed, 688 insertions(+), 22 deletions(-) create mode 100644 internal/cli/mint_delete.go diff --git a/internal/cli/mint.go b/internal/cli/mint.go index 8408a8014a..ac9cb4f8f1 100644 --- a/internal/cli/mint.go +++ b/internal/cli/mint.go @@ -375,6 +375,7 @@ Infrastructure subcommands (deploy, enroll, unenroll, status, add-role, remove-r platform-specific access. The 'token' subcommand requires only GitHub Actions OIDC.`, } cmd.AddCommand(newMintDeployCmd()) + cmd.AddCommand(newMintDeleteCmd()) cmd.AddCommand(newMintEnrollCmd()) cmd.AddCommand(newMintUnenrollCmd()) cmd.AddCommand(newMintStatusCmd()) diff --git a/internal/cli/mint_delete.go b/internal/cli/mint_delete.go new file mode 100644 index 0000000000..c303a6dcad --- /dev/null +++ b/internal/cli/mint_delete.go @@ -0,0 +1,339 @@ +package cli + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "golang.org/x/term" + + "github.com/fullsend-ai/fullsend/internal/dispatch/cf" + "github.com/fullsend-ai/fullsend/internal/dispatch/gcf" + "github.com/fullsend-ai/fullsend/internal/mintcore" + "github.com/fullsend-ai/fullsend/internal/ui" +) + +// cfResolveAuth resolves Cloudflare auth for mint delete. Overridden in tests. +var cfResolveAuth = cf.ResolveCloudflareAuth + +func newMintDeleteCmd() *cobra.Command { + var platform string + var project string + var region string + var dryRun bool + var yolo bool + + // Cloudflare-specific flags. + var workerName string + var preview string + + cmd := &cobra.Command{ + Use: "delete", + Short: "Tear down mint infrastructure", + Long: `Tears down the token mint on GCP (Cloud Function) or Cloudflare (Worker). +This is the inverse of 'fullsend mint deploy'. + +Use --platform to select the target (default: gcp). + +GCP mode (--platform=gcp): + Tears down all GCP mint infrastructure: + - Cloud Function (fullsend-mint) + - PEM secrets in Secret Manager + - Mint service account + - WIF pool and all providers + + Required flags: --project + Optional: --region + + Required IAM roles on the target project: + - roles/cloudfunctions.developer + - roles/secretmanager.admin + - roles/iam.serviceAccountAdmin + - roles/iam.workloadIdentityPoolAdmin + +Cloudflare durable mode (--platform=cloudflare): + Deletes the durable Worker script and all associated bindings/secrets. + + Required flags: none (Worker name defaults to "fullsend-mint") + Optional: --worker-name + +Cloudflare preview mode (--platform=cloudflare --preview=): + Abandons the preview alias. The durable Worker script is not affected. + +Requires confirmation (type "delete" to confirm) unless --dry-run or --yolo.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + switch platform { + case "gcp": + return runMintDeleteGCP(cmd.Context(), project, region, dryRun, yolo, os.Stdin) + case "cloudflare": + return runMintDeleteCloudflare(cmd.Context(), workerName, preview, dryRun, yolo, os.Stdin) + default: + return fmt.Errorf("unsupported platform %q: must be \"gcp\" or \"cloudflare\"", platform) + } + }, + } + + // Common flags. + cmd.Flags().StringVar(&platform, "platform", "gcp", "target platform: gcp or cloudflare") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview changes without making them") + cmd.Flags().BoolVar(&yolo, "yolo", false, "skip confirmation prompt") + + // GCP-specific flags. + cmd.Flags().StringVar(&project, "project", "", "GCP project ID (required for --platform=gcp)") + cmd.Flags().StringVar(®ion, "region", "us-central1", "GCP region for the Cloud Function") + + // Cloudflare-specific flags. + cmd.Flags().StringVar(&workerName, "worker-name", "", "Cloudflare Worker script name (default: fullsend-mint)") + cmd.Flags().StringVar(&preview, "preview", "", "tear down a preview mint identified by this alias (Cloudflare only)") + + return cmd +} + +// confirmDelete prompts the user to type "delete" to confirm teardown. +func confirmDelete(printer *ui.Printer, target string, reader *bufio.Reader, isTerminal bool) error { + if !isTerminal { + return fmt.Errorf("stdin is not a terminal; use --yolo to skip confirmation") + } + + printer.StepWarn(fmt.Sprintf("This will permanently delete %s.", target)) + printer.StepInfo("Type 'delete' to confirm:") + + line, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("reading confirmation: %w", err) + } + if strings.TrimSpace(line) != "delete" { + return fmt.Errorf("confirmation did not match; aborting delete") + } + return nil +} + +func runMintDeleteGCP(ctx context.Context, project, region string, dryRun, yolo bool, stdin *os.File) error { + if project == "" { + return fmt.Errorf("--project is required") + } + if !gcf.ValidateProjectID(project) { + return fmt.Errorf("invalid GCP project ID: %q", project) + } + if !gcf.ValidateRegion(region) { + return fmt.Errorf("invalid GCP region: %q", region) + } + + printer := ui.New(os.Stdout) + + printer.Banner(Version()) + printer.Blank() + printer.Header("Deleting token mint (GCP)") + printer.Blank() + + gcpClient := mintGCFClientFactory(project) + provisioner := gcf.NewProvisioner(gcf.Config{ + ProjectID: project, + Region: region, + }, gcpClient) + + // Discover mint to confirm it exists and find PEM secrets. + printer.StepStart("Discovering mint infrastructure") + discovery, err := provisioner.DiscoverMint(ctx) + if err != nil { + if errors.Is(err, gcf.ErrFunctionNotFound) { + printer.StepFail("Mint not installed") + printer.Blank() + printer.Summary("Delete", []string{ + "Status: not-installed", + fmt.Sprintf("Project: %s", project), + fmt.Sprintf("Region: %s", region), + "Nothing to delete.", + }) + return nil + } + printer.StepFail("Mint discovery failed") + return fmt.Errorf("discovering mint: %w", err) + } + printer.StepDone(fmt.Sprintf("Mint discovered at %s", discovery.URL)) + + // Enumerate PEM secrets for deletion. + roleKeys := rolesFromAppIDs(discovery.RoleAppIDs) + pemRoles := pemSecretRoles(roleKeys) + + if dryRun { + printer.Blank() + printer.StepInfo("Dry run -- no changes will be made") + printer.Blank() + printer.StepInfo(fmt.Sprintf(" Would delete Cloud Function: %s", "fullsend-mint")) + for _, role := range pemRoles { + printer.StepInfo(fmt.Sprintf(" Would delete PEM secret: fullsend-%s-app-pem", mintcore.PemSecretRole(role))) + } + printer.StepInfo(fmt.Sprintf(" Would delete service account: %s", gcf.MintServiceAccountEmail(project))) + printer.StepInfo(" Would delete WIF pool: fullsend-pool (and all providers)") + return nil + } + + // Confirmation. + if !yolo { + reader := bufio.NewReader(stdin) + isTerminal := term.IsTerminal(int(stdin.Fd())) + if err := confirmDelete(printer, fmt.Sprintf("mint infrastructure in project %s", project), reader, isTerminal); err != nil { + return err + } + printer.Blank() + } + + // Step 1: Delete Cloud Function. + printer.StepStart("Deleting Cloud Function") + if err := provisioner.DeleteMintFunction(ctx); err != nil { + printer.StepFail("Failed to delete Cloud Function") + return fmt.Errorf("deleting Cloud Function: %w", err) + } + printer.StepDone("Cloud Function deleted") + + // Step 2: Delete PEM secrets. + if len(pemRoles) > 0 { + printer.StepStart(fmt.Sprintf("Deleting %d PEM secret(s)", len(pemRoles))) + var pemErrors []string + for _, role := range pemRoles { + if err := provisioner.DeleteAgentPEM(ctx, role); err != nil { + pemErrors = append(pemErrors, fmt.Sprintf("%s: %v", role, err)) + } + } + if len(pemErrors) > 0 { + printer.StepWarn(fmt.Sprintf("Some PEM secrets could not be deleted: %s", strings.Join(pemErrors, "; "))) + } else { + printer.StepDone(fmt.Sprintf("Deleted %d PEM secret(s)", len(pemRoles))) + } + } + + // Step 3: Delete service account. + printer.StepStart("Deleting service account") + if err := provisioner.DeleteMintServiceAccount(ctx); err != nil { + printer.StepWarn(fmt.Sprintf("Failed to delete service account: %v", err)) + } else { + printer.StepDone("Service account deleted") + } + + // Step 4: Delete WIF pool (includes all providers). + printer.StepStart("Deleting WIF pool") + if err := provisioner.DeleteMintWIFPool(ctx); err != nil { + printer.StepWarn(fmt.Sprintf("Failed to delete WIF pool: %v", err)) + } else { + printer.StepDone("WIF pool deleted") + } + + printer.Blank() + printer.Summary("Delete complete", []string{ + fmt.Sprintf("Project: %s", project), + fmt.Sprintf("Region: %s", region), + "All mint infrastructure has been removed.", + }) + + return nil +} + +func runMintDeleteCloudflare(ctx context.Context, workerName, previewAlias string, dryRun, yolo bool, stdin *os.File) error { + accountID, err := cfResolveAuth(ctx) + if err != nil { + return err + } + + if workerName != "" && !cf.ValidateWorkerName(workerName) { + return fmt.Errorf("invalid --worker-name %q: must be 2-63 lowercase alphanumeric characters or hyphens", workerName) + } + + if previewAlias != "" && !cf.ValidatePreviewAlias(previewAlias) { + return fmt.Errorf("invalid --preview alias %q: must be 2-63 lowercase alphanumeric characters or hyphens", previewAlias) + } + + printer := ui.New(os.Stdout) + + printer.Banner(Version()) + printer.Blank() + printer.Header("Deleting token mint (Cloudflare)") + printer.Blank() + + deployMode := cf.DeployDurable + if previewAlias != "" { + deployMode = cf.DeployPreview + } + + effectiveName := workerName + if effectiveName == "" { + effectiveName = "fullsend-mint" + } + + if dryRun { + printer.StepInfo("Dry run -- no changes will be made") + printer.Blank() + if previewAlias != "" { + printer.StepInfo(fmt.Sprintf(" Would abandon preview alias: %s", previewAlias)) + printer.StepInfo(fmt.Sprintf(" Worker script %s is not affected", effectiveName)) + } else { + printer.StepInfo(fmt.Sprintf(" Would delete Worker: %s", effectiveName)) + printer.StepInfo(" All Worker bindings, secrets, and vars will be removed") + } + return nil + } + + // Confirmation. + if !yolo { + target := fmt.Sprintf("Worker %s", effectiveName) + if previewAlias != "" { + target = fmt.Sprintf("preview alias %s on Worker %s", previewAlias, effectiveName) + } + reader := bufio.NewReader(stdin) + isTerminal := term.IsTerminal(int(stdin.Fd())) + if err := confirmDelete(printer, target, reader, isTerminal); err != nil { + return err + } + printer.Blank() + } + + cfg := cf.Config{ + AccountID: accountID, + WorkerName: workerName, + DeployMode: deployMode, + PreviewAlias: previewAlias, + } + + wrangler := mintCFWranglerFactory(accountID) + provisioner := cf.NewProvisioner(cfg, wrangler) + + if previewAlias != "" { + printer.StepStart(fmt.Sprintf("Abandoning preview alias %s", previewAlias)) + if err := provisioner.Teardown(ctx); err != nil { + printer.StepFail("Failed to abandon preview alias") + return fmt.Errorf("abandoning preview: %w", err) + } + printer.StepDone("Preview alias abandoned") + } else { + printer.StepStart(fmt.Sprintf("Deleting Worker %s", effectiveName)) + if err := provisioner.Teardown(ctx); err != nil { + printer.StepFail("Failed to delete Worker") + return fmt.Errorf("deleting Worker: %w", err) + } + printer.StepDone("Worker deleted") + } + + printer.Blank() + + summaryLines := []string{ + fmt.Sprintf("Worker: %s", effectiveName), + } + if previewAlias != "" { + summaryLines = append(summaryLines, + fmt.Sprintf("Preview alias %s abandoned", previewAlias), + "Worker script is preserved.", + ) + } else { + summaryLines = append(summaryLines, + "Worker and all bindings removed.", + ) + } + printer.Summary("Delete complete", summaryLines) + + return nil +} diff --git a/internal/cli/mint_test.go b/internal/cli/mint_test.go index ba36d62250..b668311d2c 100644 --- a/internal/cli/mint_test.go +++ b/internal/cli/mint_test.go @@ -106,6 +106,7 @@ func TestMintCommand_HasSubcommands(t *testing.T) { names[sub.Use] = true } assert.True(t, names["deploy"], "expected deploy subcommand") + assert.True(t, names["delete"], "expected delete subcommand") assert.True(t, names["enroll "], "expected enroll subcommand") assert.True(t, names["unenroll "], "expected unenroll subcommand") assert.True(t, names["status [org]"], "expected status subcommand") @@ -2625,6 +2626,209 @@ func TestMintEnrollCmd_InvalidProject(t *testing.T) { assert.Contains(t, err.Error(), "invalid GCP project ID") } +// --- delete command tests --- + +func TestMintDeleteCmd_Flags(t *testing.T) { + cmd := newMintDeleteCmd() + + platformFlag := cmd.Flags().Lookup("platform") + require.NotNil(t, platformFlag, "expected --platform flag") + assert.Equal(t, "gcp", platformFlag.DefValue) + + projectFlag := cmd.Flags().Lookup("project") + require.NotNil(t, projectFlag, "expected --project flag") + + regionFlag := cmd.Flags().Lookup("region") + require.NotNil(t, regionFlag, "expected --region flag") + assert.Equal(t, "us-central1", regionFlag.DefValue) + + dryRunFlag := cmd.Flags().Lookup("dry-run") + require.NotNil(t, dryRunFlag, "expected --dry-run flag") + + yoloFlag := cmd.Flags().Lookup("yolo") + require.NotNil(t, yoloFlag, "expected --yolo flag") + + workerNameFlag := cmd.Flags().Lookup("worker-name") + require.NotNil(t, workerNameFlag, "expected --worker-name flag") + + previewFlag := cmd.Flags().Lookup("preview") + require.NotNil(t, previewFlag, "expected --preview flag") +} + +func TestMintDeleteGCP_RequiresProject(t *testing.T) { + err := runMintDeleteGCP(context.Background(), "", "us-central1", false, false, os.Stdin) + require.Error(t, err) + assert.Contains(t, err.Error(), "--project is required") +} + +func TestMintDeleteGCP_InvalidProject(t *testing.T) { + err := runMintDeleteGCP(context.Background(), "INVALID", "us-central1", false, false, os.Stdin) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid GCP project ID") +} + +func TestMintDeleteGCP_DryRun(t *testing.T) { + client := gcf.NewFakeGCFClient( + gcf.WithFakeFunctionInfo(&gcf.FunctionInfo{ + Name: "projects/test-proj/locations/us-central1/functions/fullsend-mint", + State: "ACTIVE", + URI: "https://fullsend-mint-abc123.a.run.app", + EnvVars: map[string]string{ + "ROLE_APP_IDS": `{"coder":"123","triage":"456"}`, + "ALLOWED_ORGS": "acme", + "ALLOWED_ROLES": "coder,triage", + }, + }), + gcf.WithFakeTrafficEnvVars(map[string]string{ + "ROLE_APP_IDS": `{"coder":"123","triage":"456"}`, + "ALLOWED_ORGS": "acme", + "ALLOWED_ROLES": "coder,triage", + }), + ) + + withMintGCFClient(t, client) + + err := runMintDeleteGCP(context.Background(), "test-project1", "us-central1", true, false, os.Stdin) + require.NoError(t, err) + + // Dry run should NOT delete any secrets. + assert.Empty(t, gcf.DeletedSecretIDs(client), "dry run should not delete any secrets") +} + +func TestMintDeleteGCP_FullTeardown(t *testing.T) { + client := gcf.NewFakeGCFClient( + gcf.WithFakeFunctionInfo(&gcf.FunctionInfo{ + Name: "projects/test-proj/locations/us-central1/functions/fullsend-mint", + State: "ACTIVE", + URI: "https://fullsend-mint-abc123.a.run.app", + EnvVars: map[string]string{ + "ROLE_APP_IDS": `{"coder":"123","triage":"456"}`, + "ALLOWED_ORGS": "acme", + "ALLOWED_ROLES": "coder,triage", + }, + }), + gcf.WithFakeTrafficEnvVars(map[string]string{ + "ROLE_APP_IDS": `{"coder":"123","triage":"456"}`, + "ALLOWED_ORGS": "acme", + "ALLOWED_ROLES": "coder,triage", + }), + ) + + withMintGCFClient(t, client) + + err := runMintDeleteGCP(context.Background(), "test-project1", "us-central1", false, true, os.Stdin) + require.NoError(t, err) + + // Verify PEM secrets were deleted. + deletedSecrets := gcf.DeletedSecretIDs(client) + assert.NotEmpty(t, deletedSecrets, "expected PEM secrets to be deleted") +} + +func TestMintDeleteGCP_MintNotFound(t *testing.T) { + client := gcf.NewFakeGCFClient() + // Default fake: no functionInfo → DiscoverMint finds no function. + + withMintGCFClient(t, client) + + err := runMintDeleteGCP(context.Background(), "test-project1", "us-central1", false, true, os.Stdin) + // Should succeed gracefully — nothing to delete. + require.NoError(t, err) +} + +func TestMintDeleteCloudflare_DryRunDurable(t *testing.T) { + // Dry run should not call any wrangler methods. + origFactory := mintCFWranglerFactory + fakeCF := &fakeCFWranglerRunner{} + mintCFWranglerFactory = func(string) cf.WranglerRunner { return fakeCF } + defer func() { mintCFWranglerFactory = origFactory }() + + origResolve := cfResolveAuth + cfResolveAuth = func(context.Context) (string, error) { return "test-account", nil } + defer func() { cfResolveAuth = origResolve }() + + err := runMintDeleteCloudflare(context.Background(), "test-mint", "", true, false, os.Stdin) + require.NoError(t, err) + assert.Empty(t, fakeCF.deployCalls, "dry run should not deploy") +} + +func TestMintDeleteCloudflare_DurableTeardown(t *testing.T) { + origFactory := mintCFWranglerFactory + fakeCF := &fakeCFWranglerRunner{} + mintCFWranglerFactory = func(string) cf.WranglerRunner { return fakeCF } + defer func() { mintCFWranglerFactory = origFactory }() + + origResolve := cfResolveAuth + cfResolveAuth = func(context.Context) (string, error) { return "test-account", nil } + defer func() { cfResolveAuth = origResolve }() + + err := runMintDeleteCloudflare(context.Background(), "test-mint", "", false, true, os.Stdin) + require.NoError(t, err) + assert.Empty(t, fakeCF.deployCalls, "durable delete should not deploy") +} + +func TestMintDeleteCloudflare_PreviewTeardown(t *testing.T) { + origFactory := mintCFWranglerFactory + fakeCF := &fakeCFWranglerRunner{} + mintCFWranglerFactory = func(string) cf.WranglerRunner { return fakeCF } + defer func() { mintCFWranglerFactory = origFactory }() + + origResolve := cfResolveAuth + cfResolveAuth = func(context.Context) (string, error) { return "test-account", nil } + defer func() { cfResolveAuth = origResolve }() + + err := runMintDeleteCloudflare(context.Background(), "test-mint", "bt-run-42", false, true, os.Stdin) + require.NoError(t, err) + assert.Empty(t, fakeCF.deployCalls, "preview teardown should not deploy") +} + +func TestMintDeleteGCP_ConfirmationRequired(t *testing.T) { + client := gcf.NewFakeGCFClient( + gcf.WithFakeFunctionInfo(&gcf.FunctionInfo{ + Name: "projects/test-proj/locations/us-central1/functions/fullsend-mint", + State: "ACTIVE", + URI: "https://fullsend-mint-abc123.a.run.app", + EnvVars: map[string]string{ + "ROLE_APP_IDS": `{"coder":"123"}`, + "ALLOWED_ORGS": "acme", + "ALLOWED_ROLES": "coder", + }, + }), + gcf.WithFakeTrafficEnvVars(map[string]string{ + "ROLE_APP_IDS": `{"coder":"123"}`, + "ALLOWED_ORGS": "acme", + "ALLOWED_ROLES": "coder", + }), + ) + + withMintGCFClient(t, client) + + // stdin is not a terminal → should fail without --yolo. + err := runMintDeleteGCP(context.Background(), "test-project1", "us-central1", false, false, os.Stdin) + require.Error(t, err) + assert.Contains(t, err.Error(), "stdin is not a terminal") +} + +func TestConfirmDelete(t *testing.T) { + printer := ui.New(io.Discard) + + // Matching input. + reader := bufio.NewReader(strings.NewReader("delete\n")) + err := confirmDelete(printer, "mint infrastructure", reader, true) + require.NoError(t, err) + + // Mismatched input. + reader = bufio.NewReader(strings.NewReader("nope\n")) + err = confirmDelete(printer, "mint infrastructure", reader, true) + require.Error(t, err) + assert.Contains(t, err.Error(), "confirmation did not match") + + // Not a terminal. + reader = bufio.NewReader(strings.NewReader("delete\n")) + err = confirmDelete(printer, "mint infrastructure", reader, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "stdin is not a terminal") +} + // --- unenroll command tests --- func TestMintUnenrollCmd_Flags(t *testing.T) { diff --git a/internal/cli/repos_gitlab_test.go b/internal/cli/repos_gitlab_test.go index 5ffd413734..0b680bb968 100644 --- a/internal/cli/repos_gitlab_test.go +++ b/internal/cli/repos_gitlab_test.go @@ -87,9 +87,15 @@ func (f *fakeSecretManagerClient) ReplaceSecretIAMBinding(_ context.Context, res func (f *fakeSecretManagerClient) CreateServiceAccount(context.Context, string, string, string) error { return nil } +func (f *fakeSecretManagerClient) DeleteServiceAccount(context.Context, string, string) error { + return nil +} func (f *fakeSecretManagerClient) CreateWIFPool(context.Context, string, string, string) error { return nil } +func (f *fakeSecretManagerClient) DeleteWIFPool(context.Context, string, string) error { + return nil +} func (f *fakeSecretManagerClient) CreateWIFProvider(context.Context, string, string, string, gcf.OIDCProviderConfig) error { return nil } @@ -132,6 +138,9 @@ func (f *fakeSecretManagerClient) SetProjectIAMBinding(context.Context, string, func (f *fakeSecretManagerClient) SetCloudRunInvoker(context.Context, string, string, string) error { return nil } +func (f *fakeSecretManagerClient) DeleteFunction(context.Context, string, string, string) error { + return nil +} func (f *fakeSecretManagerClient) GetFunction(context.Context, string, string, string) (*gcf.FunctionInfo, error) { return nil, nil } diff --git a/internal/dispatch/cf/provisioner.go b/internal/dispatch/cf/provisioner.go index 65e8429443..2e4a76d0d5 100644 --- a/internal/dispatch/cf/provisioner.go +++ b/internal/dispatch/cf/provisioner.go @@ -265,26 +265,25 @@ func (p *Provisioner) StoreAgentPEM(ctx context.Context, role string, pemData [] return nil } -// Teardown cleans up a preview Worker deployment. Only valid when -// DeployMode is DeployPreview. +// Teardown cleans up a Worker deployment. // -// Preview-alias deploys use `wrangler versions upload`, which creates -// a version routed via the alias. The durable Worker script is shared -// with production, so teardown abandons the preview version without -// deleting the Worker script. The alias is simply left unrouted — it -// will be overwritten on the next preview deploy or can be cleaned up -// manually via `wrangler versions list`. +// For preview deploys (DeployPreview): abandons the preview alias +// without deleting the durable Worker script, which is shared with +// production. The alias is simply left unrouted. // -// Note: validate() enforces that DeployPreview always has a non-empty -// PreviewAlias, so the bare-preview (delete Worker) path is no longer -// reachable through normal Provisioner lifecycle. +// For durable deploys (DeployDurable): deletes the Worker script and +// all associated bindings/secrets via `wrangler delete`. func (p *Provisioner) Teardown(ctx context.Context) error { - if p.cfg.DeployMode != DeployPreview { - return fmt.Errorf("teardown is only supported for preview Workers") + switch p.cfg.DeployMode { + case DeployPreview: + // Preview-alias teardown: abandon the alias without deleting the + // durable Worker script, which is shared with production. + return nil + case DeployDurable: + return p.wrangler.Delete(ctx, p.cfg.WorkerName) + default: + return fmt.Errorf("unknown deploy mode for teardown") } - // Preview-alias teardown: abandon the alias without deleting the - // durable Worker script, which is shared with production. - return nil } // validate checks that the Config has all required fields. diff --git a/internal/dispatch/cf/provisioner_test.go b/internal/dispatch/cf/provisioner_test.go index 68b83f3f59..4b6eb18783 100644 --- a/internal/dispatch/cf/provisioner_test.go +++ b/internal/dispatch/cf/provisioner_test.go @@ -496,7 +496,7 @@ func TestProvisioner_Provision_DurableWithSecretsRejected(t *testing.T) { assert.Contains(t, err.Error(), "Config.Secrets must be empty for durable deploys") } -func TestProvisioner_Teardown_DurableRejectsCleanup(t *testing.T) { +func TestProvisioner_Teardown_DurableDeletesWorker(t *testing.T) { fake := &fakeWranglerRunner{} p := NewProvisioner(Config{ AccountID: "abc123", @@ -505,8 +505,9 @@ func TestProvisioner_Teardown_DurableRejectsCleanup(t *testing.T) { }, fake) err := p.Teardown(context.Background()) - require.Error(t, err) - assert.Contains(t, err.Error(), "only supported for preview") + require.NoError(t, err) + require.Len(t, fake.deleteCalls, 1, "durable teardown must call Delete") + assert.Equal(t, "test-mint", fake.deleteCalls[0]) } // --- WASM auto-staging tests --- @@ -1531,7 +1532,7 @@ func TestProvisioner_Provision_PreviewWithoutAlias(t *testing.T) { // --- Provisioner.Teardown durable is rejected --- -func TestProvisioner_Teardown_DurableRejectsCleanup_Default(t *testing.T) { +func TestProvisioner_Teardown_DurableDeletesWorker_Default(t *testing.T) { // Same as existing test but with default deploy mode. fake := &fakeWranglerRunner{} p := &Provisioner{ @@ -1544,8 +1545,9 @@ func TestProvisioner_Teardown_DurableRejectsCleanup_Default(t *testing.T) { } err := p.Teardown(context.Background()) - require.Error(t, err) - assert.Contains(t, err.Error(), "only supported for preview") + require.NoError(t, err) + require.Len(t, fake.deleteCalls, 1, "durable teardown must call Delete") + assert.Equal(t, "test-mint", fake.deleteCalls[0]) } // --- fileExistsAndNonEmpty tests --- diff --git a/internal/dispatch/gcf/fakeclient.go b/internal/dispatch/gcf/fakeclient.go index 06fa6c1fcc..93521c4b7f 100644 --- a/internal/dispatch/gcf/fakeclient.go +++ b/internal/dispatch/gcf/fakeclient.go @@ -81,9 +81,15 @@ func (f *fakeGCFClient) record(method string) error { func (f *fakeGCFClient) CreateServiceAccount(_ context.Context, _, _, _ string) error { return f.record("CreateServiceAccount") } +func (f *fakeGCFClient) DeleteServiceAccount(_ context.Context, _, _ string) error { + return f.record("DeleteServiceAccount") +} func (f *fakeGCFClient) CreateWIFPool(_ context.Context, _, _, _ string) error { return f.record("CreateWIFPool") } +func (f *fakeGCFClient) DeleteWIFPool(_ context.Context, _, _ string) error { + return f.record("DeleteWIFPool") +} func (f *fakeGCFClient) CreateWIFProvider(_ context.Context, _, _, providerID string, cfg OIDCProviderConfig) error { f.lastWIFProviderConfig = cfg f.lastWIFProviderID = providerID @@ -172,6 +178,9 @@ func (f *fakeGCFClient) SetProjectIAMBinding(_ context.Context, projectID, membe func (f *fakeGCFClient) SetCloudRunInvoker(_ context.Context, _, _, _ string) error { return f.record("SetCloudRunInvoker") } +func (f *fakeGCFClient) DeleteFunction(_ context.Context, _, _, _ string) error { + return f.record("DeleteFunction") +} func (f *fakeGCFClient) GetFunction(_ context.Context, _, _, _ string) (*FunctionInfo, error) { f.calls = append(f.calls, "GetFunction") f.getFunctionCalls++ diff --git a/internal/dispatch/gcf/gcp.go b/internal/dispatch/gcf/gcp.go index 2d35ba5067..dee93b56ed 100644 --- a/internal/dispatch/gcf/gcp.go +++ b/internal/dispatch/gcf/gcp.go @@ -98,9 +98,11 @@ type FunctionConfig struct { type GCFClient interface { // Service account operations CreateServiceAccount(ctx context.Context, projectID, saName, displayName string) error + DeleteServiceAccount(ctx context.Context, projectID, saEmail string) error // WIF operations CreateWIFPool(ctx context.Context, projectNumber, poolID, displayName string) error + DeleteWIFPool(ctx context.Context, projectNumber, poolID string) error CreateWIFProvider(ctx context.Context, projectNumber, poolID, providerID string, cfg OIDCProviderConfig) error GetWIFProvider(ctx context.Context, projectNumber, poolID, providerID string) (*WIFProviderInfo, error) UpdateWIFProvider(ctx context.Context, projectNumber, poolID, providerID string, cfg OIDCProviderConfig) error @@ -134,6 +136,7 @@ type GCFClient interface { SetCloudRunInvoker(ctx context.Context, projectID, region, serviceName string) error // Cloud Functions v2 + DeleteFunction(ctx context.Context, projectID, region, functionName string) error GetFunction(ctx context.Context, projectID, region, functionName string) (*FunctionInfo, error) // GetCloudRunServiceURI returns the public URI of the Cloud Run service // backing a Gen2 Cloud Function (same name as the function). @@ -220,6 +223,27 @@ func (c *LiveGCFClient) CreateServiceAccount(ctx context.Context, projectID, saN return nil } +// DeleteServiceAccount permanently deletes a service account. +func (c *LiveGCFClient) DeleteServiceAccount(ctx context.Context, projectID, saEmail string) error { + reqURL := fmt.Sprintf("https://iam.googleapis.com/v1/projects/%s/serviceAccounts/%s", + url.PathEscape(projectID), url.PathEscape(saEmail)) + + resp, err := c.Client.DoRequest(ctx, http.MethodDelete, reqURL, "") + if err != nil { + return fmt.Errorf("deleting service account: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil // already deleted + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + return fmt.Errorf("unexpected status %d deleting service account: %s", resp.StatusCode, gcp.ExtractErrorMessage(body)) + } + return nil +} + // CreateWIFPool creates a new WIF pool. func (c *LiveGCFClient) CreateWIFPool(ctx context.Context, projectNumber, poolID, displayName string) error { reqURL := fmt.Sprintf("https://iam.googleapis.com/v1/projects/%s/locations/global/workloadIdentityPools?workloadIdentityPoolId=%s", @@ -250,6 +274,31 @@ func (c *LiveGCFClient) CreateWIFPool(ctx context.Context, projectNumber, poolID return nil } +// DeleteWIFPool permanently deletes a WIF pool and all its providers. +func (c *LiveGCFClient) DeleteWIFPool(ctx context.Context, projectNumber, poolID string) error { + reqURL := fmt.Sprintf("https://iam.googleapis.com/v1/projects/%s/locations/global/workloadIdentityPools/%s", + url.PathEscape(projectNumber), url.PathEscape(poolID)) + + resp, err := c.Client.DoRequest(ctx, http.MethodDelete, reqURL, "") + if err != nil { + return fmt.Errorf("deleting WIF pool: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil // already deleted + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + return fmt.Errorf("unexpected status %d deleting WIF pool: %s", resp.StatusCode, gcp.ExtractErrorMessage(body)) + } + + if err := c.waitForIAMOperation(ctx, resp.Body); err != nil { + return fmt.Errorf("waiting for WIF pool deletion: %w", err) + } + return nil +} + // CreateWIFProvider creates a WIF OIDC provider. func (c *LiveGCFClient) CreateWIFProvider(ctx context.Context, projectNumber, poolID, providerID string, cfg OIDCProviderConfig) error { reqURL := fmt.Sprintf("https://iam.googleapis.com/v1/projects/%s/locations/global/workloadIdentityPools/%s/providers?workloadIdentityPoolProviderId=%s", @@ -997,6 +1046,40 @@ func (c *LiveGCFClient) trySetCloudRunInvoker(ctx context.Context, baseURL strin return true, nil } +// DeleteFunction permanently deletes a Cloud Function v2. +func (c *LiveGCFClient) DeleteFunction(ctx context.Context, projectID, region, functionName string) error { + reqURL := fmt.Sprintf("https://cloudfunctions.googleapis.com/v2/projects/%s/locations/%s/functions/%s", + url.PathEscape(projectID), url.PathEscape(region), url.PathEscape(functionName)) + + resp, err := c.Client.DoRequest(ctx, http.MethodDelete, reqURL, "") + if err != nil { + return fmt.Errorf("deleting function: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil // already deleted + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + return fmt.Errorf("unexpected status %d deleting function: %s", resp.StatusCode, gcp.ExtractErrorMessage(body)) + } + + // The response is a long-running operation. + var result struct { + Name string `json:"name"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("decoding delete function response: %w", err) + } + if result.Name != "" { + if err := c.WaitForOperation(ctx, result.Name); err != nil { + return fmt.Errorf("waiting for function deletion: %w", err) + } + } + return nil +} + // GetFunction checks if a Cloud Function exists and returns its info. func (c *LiveGCFClient) GetFunction(ctx context.Context, projectID, region, functionName string) (*FunctionInfo, error) { reqURL := fmt.Sprintf("https://cloudfunctions.googleapis.com/v2/projects/%s/locations/%s/functions/%s", diff --git a/internal/dispatch/gcf/provisioner.go b/internal/dispatch/gcf/provisioner.go index 0fa37b18cf..81448ef511 100644 --- a/internal/dispatch/gcf/provisioner.go +++ b/internal/dispatch/gcf/provisioner.go @@ -1797,6 +1797,26 @@ func (p *Provisioner) DisableWIFProvider(ctx context.Context, providerID string) return p.gcpAPI.DisableWIFProvider(ctx, projectNumber, p.cfg.WIFPoolName, providerID) } +// DeleteMintFunction permanently deletes the mint Cloud Function. +func (p *Provisioner) DeleteMintFunction(ctx context.Context) error { + return p.gcpAPI.DeleteFunction(ctx, p.cfg.ProjectID, p.cfg.Region, functionName) +} + +// DeleteMintServiceAccount permanently deletes the mint service account. +func (p *Provisioner) DeleteMintServiceAccount(ctx context.Context) error { + saEmail := MintServiceAccountEmail(p.cfg.ProjectID) + return p.gcpAPI.DeleteServiceAccount(ctx, p.cfg.ProjectID, saEmail) +} + +// DeleteMintWIFPool permanently deletes the WIF pool and all its providers. +func (p *Provisioner) DeleteMintWIFPool(ctx context.Context) error { + projectNumber, err := p.gcpAPI.GetProjectNumber(ctx, p.cfg.ProjectID) + if err != nil { + return fmt.Errorf("getting project number: %w", err) + } + return p.gcpAPI.DeleteWIFPool(ctx, projectNumber, p.cfg.WIFPoolName) +} + // DeleteWIFProvider permanently deletes a WIF provider. func (p *Provisioner) DeleteWIFProvider(ctx context.Context, providerID string) error { projectNumber, err := p.gcpAPI.GetProjectNumber(ctx, p.cfg.ProjectID) From 2bdfa17238e5cdb2778102222e6623daa91dfd11 Mon Sep 17 00:00:00 2001 From: fullsend-fix <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 08:27:51 +0000 Subject: [PATCH 2/3] fix: address review feedback on PR #6022 - Add assertions for DeleteFunction, DeleteServiceAccount, DeleteWIFPool calls in TestMintDeleteGCP_FullTeardown (test-inadequate) - Add deleteCalls tracking to fakeCFWranglerRunner and assert Delete was called in TestMintDeleteCloudflare_DurableTeardown (test-inadequate) - Track warnings and adjust summary message when non-critical resource deletions fail (error-handling) - Replace -- with em-dash in both dry-run messages to match codebase convention (naming-convention) - Add validate() call at top of CF Teardown() for defense-in-depth parity with Provision() (missing-validation) - Add mint delete to docs/cli/mint.md commands table and dedicated section, cross-reference in preview teardown description (missing-doc, stale-doc) - Add mint delete to docs/guides/infrastructure/mint-administration.md command summary and IAM role tables (missing-doc) - Add delete to mint section of CLI command tree in docs/guides/dev/cli-internals.md (missing-doc) - Add mint delete to standalone commands and IAM role breakdown tables in docs/guides/getting-started/operations.md (missing-doc) - Add fullsend mint delete to Token Mint managed-by annotation in docs/guides/infrastructure/infrastructure-reference.md (missing-doc) Addresses review feedback on #6022 --- docs/cli/mint.md | 54 ++++++++++++++++++- docs/guides/dev/cli-internals.md | 1 + docs/guides/getting-started/operations.md | 23 ++++---- .../infrastructure-reference.md | 2 +- .../infrastructure/mint-administration.md | 21 ++++---- internal/cli/mint_delete.go | 16 ++++-- internal/cli/mint_test.go | 12 ++++- internal/dispatch/cf/provisioner.go | 4 ++ internal/dispatch/gcf/fakeclient.go | 10 ++++ 9 files changed, 116 insertions(+), 27 deletions(-) diff --git a/docs/cli/mint.md b/docs/cli/mint.md index cd498c7f4f..d5fd4d8d91 100644 --- a/docs/cli/mint.md +++ b/docs/cli/mint.md @@ -11,6 +11,7 @@ Deploy and manage the OIDC token mint service. The mint exchanges GitHub Actions | Command | Description | |---------|-------------| | `fullsend mint deploy` | Deploy or update the token mint (GCP or Cloudflare) | +| `fullsend mint delete` | Tear down mint infrastructure (GCP or Cloudflare) | | `fullsend mint add-role ` | Register a role PEM and app ID on the mint | | `fullsend mint remove-role ` | Remove a role from the mint | | `fullsend mint enroll ` | Register an org or repo in the mint | @@ -59,7 +60,7 @@ fullsend mint deploy \ --platform cloudflare ``` -Use `--preview=` for ephemeral preview deploys. This runs `wrangler versions upload --preview-alias=` instead of `wrangler deploy`, so the durable Worker script is not affected. The preview mint URL is deterministic: `https://-.workers.dev`. Preview teardown abandons the alias without deleting the Worker script. +Use `--preview=` for ephemeral preview deploys. This runs `wrangler versions upload --preview-alias=` instead of `wrangler deploy`, so the durable Worker script is not affected. The preview mint URL is deterministic: `https://-.workers.dev`. Preview teardown via `mint delete --platform=cloudflare --preview=` abandons the alias without deleting the Worker script. If the target Worker script does not yet exist (first-time preview on a new `--worker-name`), the CLI automatically creates it with a one-time durable deploy before proceeding with the preview upload. Subsequent preview deploys skip this bootstrap step. When `--pem-dir` is set, the bootstrap deploy includes PEM secrets so the Worker is immediately usable. @@ -126,6 +127,57 @@ gcloud services enable \ --project="$GCP_PROJECT" ``` +## `mint delete` + +Tears down mint infrastructure. This is the inverse of `mint deploy`. Use `--platform` to select the target platform (default: `gcp`). + +### GCP mode (`--platform=gcp`) + +Deletes all GCP mint infrastructure in order: Cloud Function, PEM secrets, service account, and WIF pool (with all providers). Non-critical resource failures (service account, WIF pool) are reported as warnings rather than hard errors. + +```bash +fullsend mint delete \ + --project "" \ + --region "us-central1" +``` + +### Cloudflare durable mode (`--platform=cloudflare`) + +Deletes the durable Worker script and all associated bindings/secrets via `wrangler delete`. + +```bash +fullsend mint delete --platform cloudflare +``` + +### Cloudflare preview mode (`--platform=cloudflare --preview=`) + +Abandons the preview alias without deleting the durable Worker script. This is the explicit teardown for preview mints deployed with `mint deploy --preview=`. + +```bash +fullsend mint delete --platform cloudflare --preview bt-run-42 +``` + +### Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--platform` | `gcp` | Target platform: `gcp` or `cloudflare` | +| `--project` | | GCP project ID (GCP only, required) | +| `--region` | `us-central1` | GCP region for the Cloud Function (GCP only) | +| `--worker-name` | `fullsend-mint` | Cloudflare Worker script name (Cloudflare only) | +| `--preview` | | Tear down a preview mint identified by this alias (Cloudflare only) | +| `--dry-run` | `false` | Preview changes without making them | +| `--yolo` | `false` | Skip confirmation prompt | + +### Required IAM roles (GCP) + +| Role | Description | +|------|-------------| +| `roles/cloudfunctions.developer` | Delete the Cloud Function | +| `roles/secretmanager.admin` | Delete PEM secrets | +| `roles/iam.serviceAccountAdmin` | Delete the mint service account | +| `roles/iam.workloadIdentityPoolAdmin` | Delete the WIF pool and providers | + ## `mint add-role` Registers a GitHub App role on the mint by uploading its PEM key and recording the app ID. diff --git a/docs/guides/dev/cli-internals.md b/docs/guides/dev/cli-internals.md index 36e609533e..ddae5dcca7 100644 --- a/docs/guides/dev/cli-internals.md +++ b/docs/guides/dev/cli-internals.md @@ -16,6 +16,7 @@ fullsend │ └── repos [repo...] # Disable agent on repos ├── mint # Token mint management │ ├── deploy # Deploy/update mint Cloud Function +│ ├── delete # Tear down mint infrastructure │ ├── add-role # Register role PEM + ROLE_APP_IDS entry │ ├── remove-role # Remove role from mint │ ├── enroll # Register org/repo in mint diff --git a/docs/guides/getting-started/operations.md b/docs/guides/getting-started/operations.md index e4d66dd264..e4d58e96af 100644 --- a/docs/guides/getting-started/operations.md +++ b/docs/guides/getting-started/operations.md @@ -103,6 +103,7 @@ For organizations that separate GCP and GitHub responsibilities across teams, fu | GitHub Maintainer | `fullsend github sync-scaffold ` | Update workflow templates to current CLI version | | GitHub Maintainer | `fullsend github uninstall ` | Remove GitHub configuration (org-level only) | | GCP Admin (Mint) | `fullsend mint deploy` | Deploy the token mint Cloud Function | +| GCP Admin (Mint) | `fullsend mint delete` | Tear down mint infrastructure (inverse of deploy) | | GCP Admin (Mint) | `fullsend mint add-role ` | Register a role PEM and app ID on the mint | | GCP Admin (Mint) | `fullsend mint remove-role ` | Remove a role from the mint (deletes PEM secret by default) | | GCP Admin (Mint) | `fullsend mint enroll ` | Register an org or repo in the mint (does not grant Agent Platform access — use `inference provision`) | @@ -126,17 +127,17 @@ The typical handoff: a GCP admin runs `mint deploy` + `mint enroll` + `inference When using the split-responsibility workflow, each standalone command requires a subset of IAM roles. Use this table to request only what you need. -| IAM Role | `inference provision` | `inference deprovision` | `inference status` | `mint deploy` | `mint add-role` | `mint remove-role` | `mint enroll` | `mint unenroll` | `mint status` | -|----------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:| -| `roles/iam.workloadIdentityPoolAdmin` | x | x | | x | | | x | x | | -| `roles/resourcemanager.projectIamAdmin` | x | | | \* | | | | | | -| `roles/iam.serviceAccountAdmin` | | | | x | | | | | | -| `roles/secretmanager.admin` | | | | \* | \*\* | \*\*\* | | | | -| `roles/cloudfunctions.developer` | | | | x | | | | | | -| `roles/cloudfunctions.viewer` | | | | | x | x | x | x | x | -| `roles/run.admin` | | | | x | x | x | x | x | | -| `roles/iam.workloadIdentityPoolViewer` | | | x† | | | | | | | -| `roles/secretmanager.viewer` | | | | | § | | | | x | +| IAM Role | `inference provision` | `inference deprovision` | `inference status` | `mint deploy` | `mint delete` | `mint add-role` | `mint remove-role` | `mint enroll` | `mint unenroll` | `mint status` | +|----------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:| +| `roles/iam.workloadIdentityPoolAdmin` | x | x | | x | x | | | x | x | | +| `roles/resourcemanager.projectIamAdmin` | x | | | \* | | | | | | | +| `roles/iam.serviceAccountAdmin` | | | | x | x | | | | | | +| `roles/secretmanager.admin` | | | | \* | x | \*\* | \*\*\* | | | | +| `roles/cloudfunctions.developer` | | | | x | x | | | | | | +| `roles/cloudfunctions.viewer` | | | | | | x | x | x | x | x | +| `roles/run.admin` | | | | x | | x | x | x | x | | +| `roles/iam.workloadIdentityPoolViewer` | | | x† | | | | | | | | +| `roles/secretmanager.viewer` | | | | | | § | | | | x | \* `roles/resourcemanager.projectIamAdmin` and `roles/secretmanager.admin` are required for `mint deploy` only when using `--pem-dir` (first-time bootstrap). Standard deploys without `--pem-dir` do not need these roles. diff --git a/docs/guides/infrastructure/infrastructure-reference.md b/docs/guides/infrastructure/infrastructure-reference.md index b11d2476b1..53d3d64e0d 100644 --- a/docs/guides/infrastructure/infrastructure-reference.md +++ b/docs/guides/infrastructure/infrastructure-reference.md @@ -4,7 +4,7 @@ This guide provides implementation details for fullsend's infrastructure compone ## Token Mint (OIDC) -> Managed by: `fullsend mint deploy`, `fullsend mint enroll`, `fullsend mint unenroll`, `fullsend mint status`, `fullsend mint add-role`, `fullsend mint remove-role`, `fullsend mint workflow-host`, `fullsend mint token` +> Managed by: `fullsend mint deploy`, `fullsend mint delete`, `fullsend mint enroll`, `fullsend mint unenroll`, `fullsend mint status`, `fullsend mint add-role`, `fullsend mint remove-role`, `fullsend mint workflow-host`, `fullsend mint token` The mint exchanges GitHub OIDC tokens for scoped GitHub App installation tokens. This eliminates long-lived PATs from the system. The mint can be deployed on GCP (Cloud Function) or Cloudflare (Worker) — see `fullsend mint deploy --platform`. diff --git a/docs/guides/infrastructure/mint-administration.md b/docs/guides/infrastructure/mint-administration.md index 849d957fa0..35a1f2edc6 100644 --- a/docs/guides/infrastructure/mint-administration.md +++ b/docs/guides/infrastructure/mint-administration.md @@ -5,6 +5,7 @@ This guide covers deploying and managing the fullsend token mint. The mint is th | Command | Description | |---------|-------------| | `mint deploy` | Deploy or update the token mint (GCP Cloud Function or Cloudflare Worker) | +| `mint delete` | Tear down mint infrastructure (Cloud Function, secrets, SA, WIF pool or Worker) | | `mint add-role` | Add an agent role (PEM secret + `ROLE_APP_IDS` entry) | | `mint remove-role` | Remove an agent role from the mint (deletes PEM secret by default) | | `mint enroll` | Register an org or repo in `ALLOWED_ORGS` and configure WIF | @@ -52,16 +53,16 @@ Pass this URL as `--mint-url` when running `fullsend github setup`, or set the ` - **GCP IAM roles** — the user running mint commands authenticates via ADC (`gcloud auth application-default login`). The required roles depend on the command: - | IAM Role | `mint deploy` | `mint add-role` | `mint remove-role` | `mint enroll` | `mint unenroll` | `mint status` | - |----------|:---:|:---:|:---:|:---:|:---:|:---:| - | `roles/iam.serviceAccountAdmin` | x | | | | | | - | `roles/iam.workloadIdentityPoolAdmin` | x | | | x | x | | - | `roles/resourcemanager.projectIamAdmin` | \* | | | | | | - | `roles/secretmanager.admin` | \* | \*\* | \*\*\* | | | | - | `roles/cloudfunctions.developer` | x | | | | | | - | `roles/cloudfunctions.viewer` | | x | x | x | x | x | - | `roles/run.admin` | x | x | x | x | x | | - | `roles/secretmanager.viewer` | | § | | | | x | + | IAM Role | `mint deploy` | `mint delete` | `mint add-role` | `mint remove-role` | `mint enroll` | `mint unenroll` | `mint status` | + |----------|:---:|:---:|:---:|:---:|:---:|:---:|:---:| + | `roles/iam.serviceAccountAdmin` | x | x | | | | | | + | `roles/iam.workloadIdentityPoolAdmin` | x | x | | | x | x | | + | `roles/resourcemanager.projectIamAdmin` | \* | | | | | | | + | `roles/secretmanager.admin` | \* | x | \*\* | \*\*\* | | | | + | `roles/cloudfunctions.developer` | x | x | | | | | | + | `roles/cloudfunctions.viewer` | | | x | x | x | x | x | + | `roles/run.admin` | x | | x | x | x | x | | + | `roles/secretmanager.viewer` | | | § | | | | x | \* `roles/resourcemanager.projectIamAdmin` and `roles/secretmanager.admin` are required for `mint deploy` only when using `--pem-dir` (first-time bootstrap). Standard deploys without `--pem-dir` do not need these roles. diff --git a/internal/cli/mint_delete.go b/internal/cli/mint_delete.go index c303a6dcad..4ddc2b8be9 100644 --- a/internal/cli/mint_delete.go +++ b/internal/cli/mint_delete.go @@ -163,7 +163,7 @@ func runMintDeleteGCP(ctx context.Context, project, region string, dryRun, yolo if dryRun { printer.Blank() - printer.StepInfo("Dry run -- no changes will be made") + printer.StepInfo("Dry run — no changes will be made") printer.Blank() printer.StepInfo(fmt.Sprintf(" Would delete Cloud Function: %s", "fullsend-mint")) for _, role := range pemRoles { @@ -192,6 +192,9 @@ func runMintDeleteGCP(ctx context.Context, project, region string, dryRun, yolo } printer.StepDone("Cloud Function deleted") + // Track whether any non-critical resources failed to delete. + var hadWarnings bool + // Step 2: Delete PEM secrets. if len(pemRoles) > 0 { printer.StepStart(fmt.Sprintf("Deleting %d PEM secret(s)", len(pemRoles))) @@ -202,6 +205,7 @@ func runMintDeleteGCP(ctx context.Context, project, region string, dryRun, yolo } } if len(pemErrors) > 0 { + hadWarnings = true printer.StepWarn(fmt.Sprintf("Some PEM secrets could not be deleted: %s", strings.Join(pemErrors, "; "))) } else { printer.StepDone(fmt.Sprintf("Deleted %d PEM secret(s)", len(pemRoles))) @@ -211,6 +215,7 @@ func runMintDeleteGCP(ctx context.Context, project, region string, dryRun, yolo // Step 3: Delete service account. printer.StepStart("Deleting service account") if err := provisioner.DeleteMintServiceAccount(ctx); err != nil { + hadWarnings = true printer.StepWarn(fmt.Sprintf("Failed to delete service account: %v", err)) } else { printer.StepDone("Service account deleted") @@ -219,16 +224,21 @@ func runMintDeleteGCP(ctx context.Context, project, region string, dryRun, yolo // Step 4: Delete WIF pool (includes all providers). printer.StepStart("Deleting WIF pool") if err := provisioner.DeleteMintWIFPool(ctx); err != nil { + hadWarnings = true printer.StepWarn(fmt.Sprintf("Failed to delete WIF pool: %v", err)) } else { printer.StepDone("WIF pool deleted") } printer.Blank() + summaryMsg := "All mint infrastructure has been removed." + if hadWarnings { + summaryMsg = "Mint function deleted. Some resources could not be removed — see warnings above." + } printer.Summary("Delete complete", []string{ fmt.Sprintf("Project: %s", project), fmt.Sprintf("Region: %s", region), - "All mint infrastructure has been removed.", + summaryMsg, }) return nil @@ -266,7 +276,7 @@ func runMintDeleteCloudflare(ctx context.Context, workerName, previewAlias strin } if dryRun { - printer.StepInfo("Dry run -- no changes will be made") + printer.StepInfo("Dry run — no changes will be made") printer.Blank() if previewAlias != "" { printer.StepInfo(fmt.Sprintf(" Would abandon preview alias: %s", previewAlias)) diff --git a/internal/cli/mint_test.go b/internal/cli/mint_test.go index b668311d2c..14fe283e65 100644 --- a/internal/cli/mint_test.go +++ b/internal/cli/mint_test.go @@ -48,6 +48,7 @@ type fakeCFWranglerRunner struct { deployURL string deployCalls []fakeCFDeployCall secretCalls []fakeCFSecretCall + deleteCalls []string secretPutErr error // workerExists controls the return value of WorkerExists. // Defaults to true (Worker exists). @@ -88,7 +89,8 @@ func (f *fakeCFWranglerRunner) PutSecret(_ context.Context, workerName, secretNa return f.secretPutErr } -func (f *fakeCFWranglerRunner) Delete(context.Context, string) error { +func (f *fakeCFWranglerRunner) Delete(_ context.Context, workerName string) error { + f.deleteCalls = append(f.deleteCalls, workerName) return nil } @@ -2722,6 +2724,12 @@ func TestMintDeleteGCP_FullTeardown(t *testing.T) { // Verify PEM secrets were deleted. deletedSecrets := gcf.DeletedSecretIDs(client) assert.NotEmpty(t, deletedSecrets, "expected PEM secrets to be deleted") + + // Verify delete operations were called. + calls := gcf.RecordedCalls(client) + assert.Contains(t, calls, "DeleteFunction", "expected DeleteFunction to be called") + assert.Contains(t, calls, "DeleteServiceAccount", "expected DeleteServiceAccount to be called") + assert.Contains(t, calls, "DeleteWIFPool", "expected DeleteWIFPool to be called") } func TestMintDeleteGCP_MintNotFound(t *testing.T) { @@ -2764,6 +2772,8 @@ func TestMintDeleteCloudflare_DurableTeardown(t *testing.T) { err := runMintDeleteCloudflare(context.Background(), "test-mint", "", false, true, os.Stdin) require.NoError(t, err) assert.Empty(t, fakeCF.deployCalls, "durable delete should not deploy") + assert.Len(t, fakeCF.deleteCalls, 1, "expected exactly one Delete call") + assert.Equal(t, "test-mint", fakeCF.deleteCalls[0], "expected Delete called with worker name") } func TestMintDeleteCloudflare_PreviewTeardown(t *testing.T) { diff --git a/internal/dispatch/cf/provisioner.go b/internal/dispatch/cf/provisioner.go index 2e4a76d0d5..3c388a6ca1 100644 --- a/internal/dispatch/cf/provisioner.go +++ b/internal/dispatch/cf/provisioner.go @@ -274,6 +274,10 @@ func (p *Provisioner) StoreAgentPEM(ctx context.Context, role string, pemData [] // For durable deploys (DeployDurable): deletes the Worker script and // all associated bindings/secrets via `wrangler delete`. func (p *Provisioner) Teardown(ctx context.Context) error { + if err := p.validate(); err != nil { + return err + } + switch p.cfg.DeployMode { case DeployPreview: // Preview-alias teardown: abandon the alias without deleting the diff --git a/internal/dispatch/gcf/fakeclient.go b/internal/dispatch/gcf/fakeclient.go index 93521c4b7f..030224a498 100644 --- a/internal/dispatch/gcf/fakeclient.go +++ b/internal/dispatch/gcf/fakeclient.go @@ -352,3 +352,13 @@ func DeletedSecretIDs(client GCFClient) []string { } return f.deletedSecretIDs } + +// RecordedCalls returns the method names recorded on a fake client, for +// cross-package test assertions. Returns nil if client isn't a fake. +func RecordedCalls(client GCFClient) []string { + f, ok := client.(*fakeGCFClient) + if !ok { + return nil + } + return f.calls +} From 664cb5aa912720886f64e6bab9eac2d6e38a8bf5 Mon Sep 17 00:00:00 2001 From: fullsend-fix <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 09:12:43 +0000 Subject: [PATCH 3/3] fix: address review feedback on PR #6022 - Update stale validate() comment in cf/provisioner.go to reflect that DeployDurable now triggers full Worker deletion via Teardown - Add "delete" to the mint command Long description enumeration - Remove cfResolveAuth closure override pattern from mint_delete.go; call cf.ResolveCloudflareAuth(ctx) directly to match deploy command's env-var-based testability pattern - Add comprehensive tests for uncovered paths: GCP delete wrapper methods, LiveGCFClient delete REST calls (httptest), CF Teardown edge cases (validation failure, delete error), CLI error/validation paths (invalid region, invalid worker name, invalid preview alias, auth failure, dry-run preview, default worker name, unsupported platform), and fake client delete operations Addresses review feedback on #6022 --- internal/cli/mint.go | 2 +- internal/cli/mint_delete.go | 5 +- internal/cli/mint_test.go | 236 +++++++++++++++++++++- internal/dispatch/cf/provisioner.go | 5 +- internal/dispatch/cf/provisioner_test.go | 25 +++ internal/dispatch/gcf/fakeclient_test.go | 62 ++++++ internal/dispatch/gcf/gcp_test.go | 136 +++++++++++++ internal/dispatch/gcf/provisioner_test.go | 90 +++++++++ 8 files changed, 545 insertions(+), 16 deletions(-) diff --git a/internal/cli/mint.go b/internal/cli/mint.go index ac9cb4f8f1..49b46cf27d 100644 --- a/internal/cli/mint.go +++ b/internal/cli/mint.go @@ -371,7 +371,7 @@ and mint short-lived tokens via OIDC. The mint can be deployed on GCP (Cloud Function) or Cloudflare (Worker). Use 'fullsend mint deploy --platform' to select the target platform. -Infrastructure subcommands (deploy, enroll, unenroll, status, add-role, remove-role) require +Infrastructure subcommands (deploy, delete, enroll, unenroll, status, add-role, remove-role) require platform-specific access. The 'token' subcommand requires only GitHub Actions OIDC.`, } cmd.AddCommand(newMintDeployCmd()) diff --git a/internal/cli/mint_delete.go b/internal/cli/mint_delete.go index 4ddc2b8be9..ca7f950574 100644 --- a/internal/cli/mint_delete.go +++ b/internal/cli/mint_delete.go @@ -17,9 +17,6 @@ import ( "github.com/fullsend-ai/fullsend/internal/ui" ) -// cfResolveAuth resolves Cloudflare auth for mint delete. Overridden in tests. -var cfResolveAuth = cf.ResolveCloudflareAuth - func newMintDeleteCmd() *cobra.Command { var platform string var project string @@ -245,7 +242,7 @@ func runMintDeleteGCP(ctx context.Context, project, region string, dryRun, yolo } func runMintDeleteCloudflare(ctx context.Context, workerName, previewAlias string, dryRun, yolo bool, stdin *os.File) error { - accountID, err := cfResolveAuth(ctx) + accountID, err := cf.ResolveCloudflareAuth(ctx) if err != nil { return err } diff --git a/internal/cli/mint_test.go b/internal/cli/mint_test.go index 14fe283e65..ca91c557fc 100644 --- a/internal/cli/mint_test.go +++ b/internal/cli/mint_test.go @@ -2750,9 +2750,14 @@ func TestMintDeleteCloudflare_DryRunDurable(t *testing.T) { mintCFWranglerFactory = func(string) cf.WranglerRunner { return fakeCF } defer func() { mintCFWranglerFactory = origFactory }() - origResolve := cfResolveAuth - cfResolveAuth = func(context.Context) (string, error) { return "test-account", nil } - defer func() { cfResolveAuth = origResolve }() + origAccount := os.Getenv("CLOUDFLARE_ACCOUNT_ID") + origToken := os.Getenv("CLOUDFLARE_API_TOKEN") + defer func() { + os.Setenv("CLOUDFLARE_ACCOUNT_ID", origAccount) + os.Setenv("CLOUDFLARE_API_TOKEN", origToken) + }() + os.Setenv("CLOUDFLARE_ACCOUNT_ID", "test-account") + os.Setenv("CLOUDFLARE_API_TOKEN", "test-token") err := runMintDeleteCloudflare(context.Background(), "test-mint", "", true, false, os.Stdin) require.NoError(t, err) @@ -2765,9 +2770,14 @@ func TestMintDeleteCloudflare_DurableTeardown(t *testing.T) { mintCFWranglerFactory = func(string) cf.WranglerRunner { return fakeCF } defer func() { mintCFWranglerFactory = origFactory }() - origResolve := cfResolveAuth - cfResolveAuth = func(context.Context) (string, error) { return "test-account", nil } - defer func() { cfResolveAuth = origResolve }() + origAccount := os.Getenv("CLOUDFLARE_ACCOUNT_ID") + origToken := os.Getenv("CLOUDFLARE_API_TOKEN") + defer func() { + os.Setenv("CLOUDFLARE_ACCOUNT_ID", origAccount) + os.Setenv("CLOUDFLARE_API_TOKEN", origToken) + }() + os.Setenv("CLOUDFLARE_ACCOUNT_ID", "test-account") + os.Setenv("CLOUDFLARE_API_TOKEN", "test-token") err := runMintDeleteCloudflare(context.Background(), "test-mint", "", false, true, os.Stdin) require.NoError(t, err) @@ -2782,9 +2792,14 @@ func TestMintDeleteCloudflare_PreviewTeardown(t *testing.T) { mintCFWranglerFactory = func(string) cf.WranglerRunner { return fakeCF } defer func() { mintCFWranglerFactory = origFactory }() - origResolve := cfResolveAuth - cfResolveAuth = func(context.Context) (string, error) { return "test-account", nil } - defer func() { cfResolveAuth = origResolve }() + origAccount := os.Getenv("CLOUDFLARE_ACCOUNT_ID") + origToken := os.Getenv("CLOUDFLARE_API_TOKEN") + defer func() { + os.Setenv("CLOUDFLARE_ACCOUNT_ID", origAccount) + os.Setenv("CLOUDFLARE_API_TOKEN", origToken) + }() + os.Setenv("CLOUDFLARE_ACCOUNT_ID", "test-account") + os.Setenv("CLOUDFLARE_API_TOKEN", "test-token") err := runMintDeleteCloudflare(context.Background(), "test-mint", "bt-run-42", false, true, os.Stdin) require.NoError(t, err) @@ -2818,6 +2833,209 @@ func TestMintDeleteGCP_ConfirmationRequired(t *testing.T) { assert.Contains(t, err.Error(), "stdin is not a terminal") } +func TestMintDeleteGCP_InvalidRegion(t *testing.T) { + err := runMintDeleteGCP(context.Background(), "test-project1", "INVALID!", false, false, os.Stdin) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid GCP region") +} + +func TestMintDeleteGCP_DiscoveryFails(t *testing.T) { + client := gcf.NewFakeGCFClient( + gcf.WithFakeErrors(map[string]error{ + "GetFunction": fmt.Errorf("API unavailable"), + }), + ) + withMintGCFClient(t, client) + + err := runMintDeleteGCP(context.Background(), "test-project1", "us-central1", false, true, os.Stdin) + require.Error(t, err) + assert.Contains(t, err.Error(), "discovering mint") +} + +func TestMintDeleteGCP_DeleteFunctionFails(t *testing.T) { + client := gcf.NewFakeGCFClient( + gcf.WithFakeFunctionInfo(&gcf.FunctionInfo{ + Name: "projects/test-proj/locations/us-central1/functions/fullsend-mint", + State: "ACTIVE", + URI: "https://fullsend-mint-abc123.a.run.app", + EnvVars: map[string]string{ + "ROLE_APP_IDS": `{"coder":"123"}`, + "ALLOWED_ORGS": "acme", + "ALLOWED_ROLES": "coder", + }, + }), + gcf.WithFakeTrafficEnvVars(map[string]string{ + "ROLE_APP_IDS": `{"coder":"123"}`, + "ALLOWED_ORGS": "acme", + "ALLOWED_ROLES": "coder", + }), + gcf.WithFakeErrors(map[string]error{ + "DeleteFunction": fmt.Errorf("permission denied"), + }), + ) + withMintGCFClient(t, client) + + err := runMintDeleteGCP(context.Background(), "test-project1", "us-central1", false, true, os.Stdin) + require.Error(t, err) + assert.Contains(t, err.Error(), "deleting Cloud Function") +} + +func TestMintDeleteGCP_WarningsOnSAAndWIFFailure(t *testing.T) { + client := gcf.NewFakeGCFClient( + gcf.WithFakeFunctionInfo(&gcf.FunctionInfo{ + Name: "projects/test-proj/locations/us-central1/functions/fullsend-mint", + State: "ACTIVE", + URI: "https://fullsend-mint-abc123.a.run.app", + EnvVars: map[string]string{ + "ROLE_APP_IDS": `{"coder":"123"}`, + "ALLOWED_ORGS": "acme", + "ALLOWED_ROLES": "coder", + }, + }), + gcf.WithFakeTrafficEnvVars(map[string]string{ + "ROLE_APP_IDS": `{"coder":"123"}`, + "ALLOWED_ORGS": "acme", + "ALLOWED_ROLES": "coder", + }), + gcf.WithFakeErrors(map[string]error{ + "DeleteServiceAccount": fmt.Errorf("SA delete failed"), + "GetProjectNumber": fmt.Errorf("project number lookup failed"), + }), + ) + withMintGCFClient(t, client) + + // Should succeed despite SA and WIF failures (they're warnings, not hard errors). + err := runMintDeleteGCP(context.Background(), "test-project1", "us-central1", false, true, os.Stdin) + require.NoError(t, err) + + // Verify DeleteFunction was still called. + calls := gcf.RecordedCalls(client) + assert.Contains(t, calls, "DeleteFunction", "expected DeleteFunction to be called") +} + +func TestMintDeleteCloudflare_InvalidWorkerName(t *testing.T) { + origAccount := os.Getenv("CLOUDFLARE_ACCOUNT_ID") + origToken := os.Getenv("CLOUDFLARE_API_TOKEN") + defer func() { + os.Setenv("CLOUDFLARE_ACCOUNT_ID", origAccount) + os.Setenv("CLOUDFLARE_API_TOKEN", origToken) + }() + os.Setenv("CLOUDFLARE_ACCOUNT_ID", "test-account") + os.Setenv("CLOUDFLARE_API_TOKEN", "test-token") + + err := runMintDeleteCloudflare(context.Background(), "INVALID_NAME!", "", false, true, os.Stdin) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid --worker-name") +} + +func TestMintDeleteCloudflare_InvalidPreviewAlias(t *testing.T) { + origAccount := os.Getenv("CLOUDFLARE_ACCOUNT_ID") + origToken := os.Getenv("CLOUDFLARE_API_TOKEN") + defer func() { + os.Setenv("CLOUDFLARE_ACCOUNT_ID", origAccount) + os.Setenv("CLOUDFLARE_API_TOKEN", origToken) + }() + os.Setenv("CLOUDFLARE_ACCOUNT_ID", "test-account") + os.Setenv("CLOUDFLARE_API_TOKEN", "test-token") + + err := runMintDeleteCloudflare(context.Background(), "test-mint", "INVALID!", false, true, os.Stdin) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid --preview alias") +} + +func TestMintDeleteCloudflare_AuthFailure(t *testing.T) { + origAccount := os.Getenv("CLOUDFLARE_ACCOUNT_ID") + origToken := os.Getenv("CLOUDFLARE_API_TOKEN") + defer func() { + os.Setenv("CLOUDFLARE_ACCOUNT_ID", origAccount) + os.Setenv("CLOUDFLARE_API_TOKEN", origToken) + }() + os.Unsetenv("CLOUDFLARE_ACCOUNT_ID") + os.Unsetenv("CLOUDFLARE_API_TOKEN") + + oldWhoami := cf.WranglerWhoamiFn + cf.WranglerWhoamiFn = func(ctx context.Context) (string, error) { + return "", fmt.Errorf("not logged in") + } + t.Cleanup(func() { cf.WranglerWhoamiFn = oldWhoami }) + + err := runMintDeleteCloudflare(context.Background(), "test-mint", "", false, true, os.Stdin) + require.Error(t, err) + assert.Contains(t, err.Error(), "Cloudflare credentials") +} + +func TestMintDeleteCloudflare_DryRunPreview(t *testing.T) { + origFactory := mintCFWranglerFactory + fakeCF := &fakeCFWranglerRunner{} + mintCFWranglerFactory = func(string) cf.WranglerRunner { return fakeCF } + defer func() { mintCFWranglerFactory = origFactory }() + + origAccount := os.Getenv("CLOUDFLARE_ACCOUNT_ID") + origToken := os.Getenv("CLOUDFLARE_API_TOKEN") + defer func() { + os.Setenv("CLOUDFLARE_ACCOUNT_ID", origAccount) + os.Setenv("CLOUDFLARE_API_TOKEN", origToken) + }() + os.Setenv("CLOUDFLARE_ACCOUNT_ID", "test-account") + os.Setenv("CLOUDFLARE_API_TOKEN", "test-token") + + err := runMintDeleteCloudflare(context.Background(), "test-mint", "bt-run-42", true, false, os.Stdin) + require.NoError(t, err) + assert.Empty(t, fakeCF.deployCalls, "dry run should not deploy") + assert.Empty(t, fakeCF.deleteCalls, "dry run should not delete") +} + +func TestMintDeleteCloudflare_DefaultWorkerName(t *testing.T) { + origFactory := mintCFWranglerFactory + fakeCF := &fakeCFWranglerRunner{} + mintCFWranglerFactory = func(string) cf.WranglerRunner { return fakeCF } + defer func() { mintCFWranglerFactory = origFactory }() + + origAccount := os.Getenv("CLOUDFLARE_ACCOUNT_ID") + origToken := os.Getenv("CLOUDFLARE_API_TOKEN") + defer func() { + os.Setenv("CLOUDFLARE_ACCOUNT_ID", origAccount) + os.Setenv("CLOUDFLARE_API_TOKEN", origToken) + }() + os.Setenv("CLOUDFLARE_ACCOUNT_ID", "test-account") + os.Setenv("CLOUDFLARE_API_TOKEN", "test-token") + + // Empty worker name should use default "fullsend-mint". + err := runMintDeleteCloudflare(context.Background(), "", "", false, true, os.Stdin) + require.NoError(t, err) + assert.Len(t, fakeCF.deleteCalls, 1, "expected exactly one Delete call") + assert.Equal(t, "fullsend-mint", fakeCF.deleteCalls[0], "expected Delete called with default worker name") +} + +func TestMintDeleteCloudflare_ConfirmationRequired(t *testing.T) { + origFactory := mintCFWranglerFactory + fakeCF := &fakeCFWranglerRunner{} + mintCFWranglerFactory = func(string) cf.WranglerRunner { return fakeCF } + defer func() { mintCFWranglerFactory = origFactory }() + + origAccount := os.Getenv("CLOUDFLARE_ACCOUNT_ID") + origToken := os.Getenv("CLOUDFLARE_API_TOKEN") + defer func() { + os.Setenv("CLOUDFLARE_ACCOUNT_ID", origAccount) + os.Setenv("CLOUDFLARE_API_TOKEN", origToken) + }() + os.Setenv("CLOUDFLARE_ACCOUNT_ID", "test-account") + os.Setenv("CLOUDFLARE_API_TOKEN", "test-token") + + // stdin is not a terminal → should fail without --yolo. + err := runMintDeleteCloudflare(context.Background(), "test-mint", "", false, false, os.Stdin) + require.Error(t, err) + assert.Contains(t, err.Error(), "stdin is not a terminal") +} + +func TestMintDeleteCloudflare_UnsupportedPlatform(t *testing.T) { + cmd := newMintDeleteCmd() + cmd.SetArgs([]string{"--platform=azure"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported platform") +} + func TestConfirmDelete(t *testing.T) { printer := ui.New(io.Discard) diff --git a/internal/dispatch/cf/provisioner.go b/internal/dispatch/cf/provisioner.go index 3c388a6ca1..2b7628be56 100644 --- a/internal/dispatch/cf/provisioner.go +++ b/internal/dispatch/cf/provisioner.go @@ -307,8 +307,9 @@ func (p *Provisioner) validate() error { } // Guard against the inverse: DeployDurable with a non-empty alias. // Provision routes on PreviewAlias (non-empty → preview deploy) while - // Teardown routes on DeployMode (DeployDurable → rejected). This - // mismatch would cause a preview deploy that cannot be torn down. + // Teardown routes on DeployMode (DeployDurable → full Worker deletion). + // This mismatch would cause a preview deploy followed by a destructive + // full-Worker deletion. if p.cfg.DeployMode != DeployPreview && p.cfg.PreviewAlias != "" { return fmt.Errorf("PreviewAlias %q requires DeployMode=DeployPreview", p.cfg.PreviewAlias) } diff --git a/internal/dispatch/cf/provisioner_test.go b/internal/dispatch/cf/provisioner_test.go index 4b6eb18783..2e6d95273e 100644 --- a/internal/dispatch/cf/provisioner_test.go +++ b/internal/dispatch/cf/provisioner_test.go @@ -1550,6 +1550,31 @@ func TestProvisioner_Teardown_DurableDeletesWorker_Default(t *testing.T) { assert.Equal(t, "test-mint", fake.deleteCalls[0]) } +func TestProvisioner_Teardown_ValidationFails(t *testing.T) { + // Empty AccountID should fail validation. + p := NewProvisioner(Config{ + WorkerName: "test-mint", + DeployMode: DeployDurable, + }, &fakeWranglerRunner{}) + + err := p.Teardown(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "CLOUDFLARE_ACCOUNT_ID is required") +} + +func TestProvisioner_Teardown_DeleteError(t *testing.T) { + fake := &fakeWranglerRunner{deleteErr: fmt.Errorf("wrangler delete failed")} + p := NewProvisioner(Config{ + AccountID: "abc123", + WorkerName: "test-mint", + DeployMode: DeployDurable, + }, fake) + + err := p.Teardown(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "wrangler delete failed") +} + // --- fileExistsAndNonEmpty tests --- func TestFileExistsAndNonEmpty_EmptyFile(t *testing.T) { diff --git a/internal/dispatch/gcf/fakeclient_test.go b/internal/dispatch/gcf/fakeclient_test.go index 9c212cb2a7..d172c5e3dc 100644 --- a/internal/dispatch/gcf/fakeclient_test.go +++ b/internal/dispatch/gcf/fakeclient_test.go @@ -119,3 +119,65 @@ func TestNewFakeGCFClient_AccessSecretVersionNotFound(t *testing.T) { require.Error(t, err) assert.ErrorIs(t, err, ErrSecretNotFound) } + +func TestNewFakeGCFClient_DeleteOperations(t *testing.T) { + t.Parallel() + ctx := context.Background() + client := NewFakeGCFClient() + + // Test DeleteFunction records the call. + require.NoError(t, client.DeleteFunction(ctx, "proj", "us-central1", "fullsend-mint")) + + // Test DeleteServiceAccount records the call. + require.NoError(t, client.DeleteServiceAccount(ctx, "proj", "sa@proj.iam.gserviceaccount.com")) + + // Test DeleteWIFPool records the call. + require.NoError(t, client.DeleteWIFPool(ctx, "123456789", "fullsend-pool")) + + // Verify all calls were recorded. + calls := RecordedCalls(client) + assert.Contains(t, calls, "DeleteFunction") + assert.Contains(t, calls, "DeleteServiceAccount") + assert.Contains(t, calls, "DeleteWIFPool") +} + +func TestNewFakeGCFClient_DeleteOperationErrors(t *testing.T) { + t.Parallel() + ctx := context.Background() + client := NewFakeGCFClient(WithFakeErrors(map[string]error{ + "DeleteFunction": errors.New("fn delete err"), + "DeleteServiceAccount": errors.New("sa delete err"), + "DeleteWIFPool": errors.New("pool delete err"), + })) + + err := client.DeleteFunction(ctx, "p", "r", "fn") + require.Error(t, err) + assert.Contains(t, err.Error(), "fn delete err") + + err = client.DeleteServiceAccount(ctx, "p", "sa") + require.Error(t, err) + assert.Contains(t, err.Error(), "sa delete err") + + err = client.DeleteWIFPool(ctx, "p", "pool") + require.Error(t, err) + assert.Contains(t, err.Error(), "pool delete err") +} + +func TestRecordedCalls_NonFake(t *testing.T) { + t.Parallel() + // RecordedCalls should return nil for non-fake clients. + result := RecordedCalls(nil) + assert.Nil(t, result) +} + +func TestProjectIAMBindingCount_NonFake(t *testing.T) { + t.Parallel() + result := ProjectIAMBindingCount(nil) + assert.Equal(t, 0, result) +} + +func TestDeletedSecretIDs_NonFake(t *testing.T) { + t.Parallel() + result := DeletedSecretIDs(nil) + assert.Nil(t, result) +} diff --git a/internal/dispatch/gcf/gcp_test.go b/internal/dispatch/gcf/gcp_test.go index 52c7ecec86..33d153785f 100644 --- a/internal/dispatch/gcf/gcp_test.go +++ b/internal/dispatch/gcf/gcp_test.go @@ -2607,6 +2607,142 @@ func TestIAMAudience(t *testing.T) { assert.Equal(t, "https://iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/fullsend-pool/providers/github-oidc", got) } +// --- DeleteFunction --- + +func TestLiveGCFClient_DeleteFunction(t *testing.T) { + t.Run("success with operation", func(t *testing.T) { + callCount := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + if callCount == 1 { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Contains(t, r.URL.Path, "functions/fullsend-mint") + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, `{"name":"operations/delete-fn-op","done":true}`) + } else { + // WaitForOperation poll + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, `{"name":"operations/delete-fn-op","done":true}`) + } + })) + defer srv.Close() + + err := newTestClient(srv).DeleteFunction(context.Background(), "proj", "us-central1", "fullsend-mint") + require.NoError(t, err) + }) + + t.Run("success without operation name", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, `{}`) + })) + defer srv.Close() + + err := newTestClient(srv).DeleteFunction(context.Background(), "proj", "us-central1", "fullsend-mint") + require.NoError(t, err) + }) + + t.Run("not found is idempotent", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + err := newTestClient(srv).DeleteFunction(context.Background(), "proj", "us-central1", "missing") + require.NoError(t, err) + }) + + t.Run("error", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + fmt.Fprintln(w, `{"error":{"message":"permission denied"}}`) + })) + defer srv.Close() + + err := newTestClient(srv).DeleteFunction(context.Background(), "proj", "us-central1", "fn") + require.Error(t, err) + assert.Contains(t, err.Error(), "unexpected status 403") + }) +} + +// --- DeleteServiceAccount --- + +func TestLiveGCFClient_DeleteServiceAccount(t *testing.T) { + t.Run("success", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Contains(t, r.URL.Path, "serviceAccounts/") + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + err := newTestClient(srv).DeleteServiceAccount(context.Background(), "proj", "sa@proj.iam.gserviceaccount.com") + require.NoError(t, err) + }) + + t.Run("not found is idempotent", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + err := newTestClient(srv).DeleteServiceAccount(context.Background(), "proj", "missing@proj.iam.gserviceaccount.com") + require.NoError(t, err) + }) + + t.Run("error", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + fmt.Fprintln(w, `{"error":{"message":"permission denied"}}`) + })) + defer srv.Close() + + err := newTestClient(srv).DeleteServiceAccount(context.Background(), "proj", "sa@proj.iam.gserviceaccount.com") + require.Error(t, err) + assert.Contains(t, err.Error(), "unexpected status 403") + }) +} + +// --- DeleteWIFPool --- + +func TestLiveGCFClient_DeleteWIFPool(t *testing.T) { + t.Run("success", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Contains(t, r.URL.Path, "workloadIdentityPools/fullsend-pool") + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, `{"name":"operations/delete-pool-op","done":true}`) + })) + defer srv.Close() + + err := newTestClient(srv).DeleteWIFPool(context.Background(), "123456789", "fullsend-pool") + require.NoError(t, err) + }) + + t.Run("not found is idempotent", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + err := newTestClient(srv).DeleteWIFPool(context.Background(), "123456789", "missing-pool") + require.NoError(t, err) + }) + + t.Run("error", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + fmt.Fprintln(w, `{"error":{"message":"permission denied"}}`) + })) + defer srv.Close() + + err := newTestClient(srv).DeleteWIFPool(context.Background(), "123456789", "pool") + require.Error(t, err) + assert.Contains(t, err.Error(), "unexpected status 403") + }) +} + // --- encodeBase64 --- func TestEncodeBase64(t *testing.T) { diff --git a/internal/dispatch/gcf/provisioner_test.go b/internal/dispatch/gcf/provisioner_test.go index a9c7a628ae..92f17de96c 100644 --- a/internal/dispatch/gcf/provisioner_test.go +++ b/internal/dispatch/gcf/provisioner_test.go @@ -4175,3 +4175,93 @@ func TestRemoveWorkflowHostRepo_EmptyExistingList(t *testing.T) { // Should not call UpdateServiceEnvVars since repo is not in empty list. assert.NotContains(t, fake.calls, "UpdateServiceEnvVars") } + +// --- Delete wrapper method tests --- + +func TestDeleteMintFunction(t *testing.T) { + fake := newFakeGCFClient() + p := NewProvisioner(Config{ProjectID: "my-test-proj1", Region: "us-central1"}, fake) + + err := p.DeleteMintFunction(context.Background()) + require.NoError(t, err) + assert.Contains(t, fake.calls, "DeleteFunction") +} + +func TestDeleteMintFunction_Error(t *testing.T) { + fake := newFakeGCFClient() + fake.errs["DeleteFunction"] = fmt.Errorf("permission denied") + p := NewProvisioner(Config{ProjectID: "my-test-proj1", Region: "us-central1"}, fake) + + err := p.DeleteMintFunction(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "permission denied") +} + +func TestDeleteMintServiceAccount(t *testing.T) { + fake := newFakeGCFClient() + p := NewProvisioner(Config{ProjectID: "my-test-proj1", Region: "us-central1"}, fake) + + err := p.DeleteMintServiceAccount(context.Background()) + require.NoError(t, err) + assert.Contains(t, fake.calls, "DeleteServiceAccount") +} + +func TestDeleteMintServiceAccount_Error(t *testing.T) { + fake := newFakeGCFClient() + fake.errs["DeleteServiceAccount"] = fmt.Errorf("SA not found") + p := NewProvisioner(Config{ProjectID: "my-test-proj1", Region: "us-central1"}, fake) + + err := p.DeleteMintServiceAccount(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "SA not found") +} + +func TestDeleteMintWIFPool(t *testing.T) { + fake := newFakeGCFClient() + p := NewProvisioner(Config{ProjectID: "my-test-proj1", Region: "us-central1"}, fake) + + err := p.DeleteMintWIFPool(context.Background()) + require.NoError(t, err) + assert.Contains(t, fake.calls, "GetProjectNumber") + assert.Contains(t, fake.calls, "DeleteWIFPool") +} + +func TestDeleteMintWIFPool_ProjectNumberError(t *testing.T) { + fake := newFakeGCFClient() + fake.errs["GetProjectNumber"] = fmt.Errorf("project not found") + p := NewProvisioner(Config{ProjectID: "my-test-proj1", Region: "us-central1"}, fake) + + err := p.DeleteMintWIFPool(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "getting project number") +} + +func TestDeleteMintWIFPool_DeleteError(t *testing.T) { + fake := newFakeGCFClient() + fake.errs["DeleteWIFPool"] = fmt.Errorf("pool delete failed") + p := NewProvisioner(Config{ProjectID: "my-test-proj1", Region: "us-central1"}, fake) + + err := p.DeleteMintWIFPool(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "pool delete failed") +} + +func TestDeleteWIFProvider(t *testing.T) { + fake := newFakeGCFClient() + p := NewProvisioner(Config{ProjectID: "my-test-proj1", Region: "us-central1"}, fake) + + err := p.DeleteWIFProvider(context.Background(), "github-oidc") + require.NoError(t, err) + assert.Contains(t, fake.calls, "GetProjectNumber") + assert.Contains(t, fake.calls, "DeleteWIFProvider") +} + +func TestDeleteWIFProvider_ProjectNumberError(t *testing.T) { + fake := newFakeGCFClient() + fake.errs["GetProjectNumber"] = fmt.Errorf("project not found") + p := NewProvisioner(Config{ProjectID: "my-test-proj1", Region: "us-central1"}, fake) + + err := p.DeleteWIFProvider(context.Background(), "github-oidc") + require.Error(t, err) + assert.Contains(t, err.Error(), "getting project number") +}