From 1d0e0b06a406ce333cd9398389f1e8b54ec3b623 Mon Sep 17 00:00:00 2001 From: fullsend-fix <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:30:47 +0000 Subject: [PATCH 1/3] feat(#5633): add --platform=cloudflare to mint enroll/unenroll Adds --platform=cloudflare support to mint enroll and mint unenroll commands, enabling org and per-repo enrollment against durable Cloudflare Workers. Mirrors the existing platform flag on mint deploy and mint delete. Changes: - WranglerRunner interface: Added GetVars, HasPreviewVersions, UpdateVars - CF Provisioner: Added EnsureOrgInWorker, RemoveOrgFromWorker, RegisterRepoInWorker, RemoveRepoFromWorker methods - CLI enroll/unenroll: Added --platform, --worker-name, --preview flags with platform routing - Help text documents CF credentials and mutable-vs-preview model - Uses PER_REPO_WIF_REPOS for public-mode detection on CF paths Closes #5633 --- docs/cli/mint.md | 61 +- docs/guides/getting-started/operations.md | 4 +- docs/guides/getting-started/org-mode.md | 2 +- .../guides/getting-started/repo-management.md | 2 +- .../infrastructure/mint-administration.md | 58 +- internal/cli/mint.go | 603 +++++++- internal/cli/mint_test.go | 236 +++ internal/dispatch/cf/provisioner.go | 712 ++++++++- internal/dispatch/cf/provisioner_test.go | 1360 +++++++++++++++++ 9 files changed, 2954 insertions(+), 84 deletions(-) diff --git a/docs/cli/mint.md b/docs/cli/mint.md index 945919ae5f..c4602d5b41 100644 --- a/docs/cli/mint.md +++ b/docs/cli/mint.md @@ -226,7 +226,9 @@ Pass `--keep-pem` to preserve the PEM secret in Secret Manager. ## `mint enroll` -Registers a GitHub organization or repository in the mint's allowed list, enabling it to request tokens. +Registers a GitHub organization or repository in the mint's allowed list, enabling it to request tokens. Use `--platform` to select the target (default: `gcp`). + +### GCP mode ```bash fullsend mint enroll \ @@ -244,9 +246,41 @@ fullsend mint enroll \ Enrollment creates the WIF provider needed for OIDC verification only — it does not grant any IAM roles. Vertex AI access is provisioned separately via `fullsend inference provision`. +### Cloudflare mode + +```bash +fullsend mint enroll \ + --platform cloudflare \ + --worker-name "fullsend-mint" +``` + +Per-repo mode: + +```bash +fullsend mint enroll \ + --platform cloudflare +``` + +Updates the durable Worker's `ALLOWED_ORGS` (org mode) or `PER_REPO_WIF_REPOS` (per-repo mode) via the Cloudflare Versions API — no local Worker sources or WASM build artifacts required. Per-repo enrollment does not modify `ALLOWED_ORGS`. Requires `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID` (or a Wrangler OAuth session via `wrangler login`). + +`--preview` is rejected — preview Workers are configured exclusively via `mint deploy`. + +### Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--platform` | `gcp` | Target platform: `gcp` or `cloudflare` | +| `--project` | | GCP project ID (required for `--platform=gcp`) | +| `--region` | `us-central1` | GCP region | +| `--worker-name` | `fullsend-mint` | Cloudflare Worker script name | +| `--preview` | | Rejected for enroll — use `mint deploy` for preview Workers | +| `--dry-run` | `false` | Preview changes without making them | + ## `mint unenroll` -Removes an organization or repository from the mint's allowed list. +Removes an organization or repository from the mint's allowed list. Use `--platform` to select the target (default: `gcp`). + +### GCP mode ```bash fullsend mint unenroll \ @@ -254,6 +288,29 @@ fullsend mint unenroll \ --region "us-central1" ``` +### Cloudflare mode + +```bash +fullsend mint unenroll \ + --platform cloudflare \ + --worker-name "fullsend-mint" +``` + +Removes the org/repo from the durable Worker's env vars via the Cloudflare Versions API — no local Worker sources or WASM build artifacts required. + +### Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--platform` | `gcp` | Target platform: `gcp` or `cloudflare` | +| `--project` | | GCP project ID (required for `--platform=gcp`) | +| `--region` | `us-central1` | GCP region | +| `--delete-provider` | `false` | Permanently delete WIF provider (GCP repo unenroll only) | +| `--worker-name` | `fullsend-mint` | Cloudflare Worker script name | +| `--preview` | | Rejected for unenroll — use `mint deploy` for preview Workers | +| `--dry-run` | `false` | Preview changes without making them | +| `--yolo` | `false` | Skip interactive confirmation | + ## `mint workflow-host` Manages the `WORKFLOW_HOST_REPOS` allow-list that controls which repositories may host workflows calling the mint for per-repo callers. Per-org callers are not affected. diff --git a/docs/guides/getting-started/operations.md b/docs/guides/getting-started/operations.md index 9d98b5dcda..6f41bb2cd9 100644 --- a/docs/guides/getting-started/operations.md +++ b/docs/guides/getting-started/operations.md @@ -106,8 +106,8 @@ For organizations that separate GCP and GitHub responsibilities across teams, fu | 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`) | -| GCP Admin (Mint) | `fullsend mint unenroll ` | Remove an org or repo from the mint | +| Mint Admin | `fullsend mint enroll ` | Register an org or repo in the mint — supports `--platform=gcp` (default) and `--platform=cloudflare` | +| Mint Admin | `fullsend mint unenroll ` | Remove an org or repo from the mint — supports `--platform=gcp` (default) and `--platform=cloudflare` | | GCP Admin (Mint) | `fullsend mint status` | Inspect mint state and PEM health | | Fleet Admin | `fullsend repos migrate --project ` | Migrate an org from per-org to per-repo install, generating a `repos.yaml` manifest | diff --git a/docs/guides/getting-started/org-mode.md b/docs/guides/getting-started/org-mode.md index deaac79674..e2c813aaba 100644 --- a/docs/guides/getting-started/org-mode.md +++ b/docs/guides/getting-started/org-mode.md @@ -198,7 +198,7 @@ To tear down the entire fullsend installation (GitHub + GCP), coordinate between |------|------|---------| | 1 | GitHub Maintainer | `fullsend github uninstall "$ORG_NAME"` | | 2 | GCP Admin (Inference) | `fullsend inference deprovision "$ORG_NAME"` | -| 3 | GCP Admin (Mint) | `fullsend mint unenroll "$ORG_NAME"` | +| 3 | Mint Admin | `fullsend mint unenroll "$ORG_NAME"` | Each command prompts for confirmation. Add `--yolo` to skip prompts. See the [standalone commands](operations.md#standalone-commands) table for details on each command. diff --git a/docs/guides/getting-started/repo-management.md b/docs/guides/getting-started/repo-management.md index 5e1720d106..452060ed5f 100644 --- a/docs/guides/getting-started/repo-management.md +++ b/docs/guides/getting-started/repo-management.md @@ -423,7 +423,7 @@ infrastructure, coordinate between roles: |------|------|---------| | 1 | Platform Admin | `fullsend repos uninstall "org/*" --yes` (forge-side cleanup + manifest removal) | | 2 | GCP Admin (Inference) | `fullsend inference deprovision ` (WIF cleanup) | -| 3 | GCP Admin (Mint) | `fullsend mint unenroll ` | +| 3 | Mint Admin | `fullsend mint unenroll ` | Each `fullsend` command that prompts for confirmation accepts a skip flag: `--yes` for `repos` commands, `--yolo` for `github` and `mint` diff --git a/docs/guides/infrastructure/mint-administration.md b/docs/guides/infrastructure/mint-administration.md index e04e6201b3..af429e978b 100644 --- a/docs/guides/infrastructure/mint-administration.md +++ b/docs/guides/infrastructure/mint-administration.md @@ -258,7 +258,9 @@ This command does not uninstall GitHub Apps from organizations or update org `.f ## Enrolling organizations and repositories -`fullsend mint enroll` registers an organization or repository in the mint and configures WIF to accept OIDC tokens from the target. +`fullsend mint enroll` registers an organization or repository in the mint. Use `--platform` to select the target (default: `gcp`). + +### GCP enrollment ```bash # Enroll an organization @@ -268,14 +270,35 @@ fullsend mint enroll acme-corp --project="$GCP_PROJECT" fullsend mint enroll acme-corp/my-repo --project="$GCP_PROJECT" ``` -Enrollment does **not** grant Agent Platform (inference) access — use `fullsend inference provision` separately after enrollment. See [Getting Started](../getting-started/) for the end-user inference setup path. +GCP enrollment configures WIF to accept OIDC tokens from the target. Enrollment does **not** grant Agent Platform (inference) access — use `fullsend inference provision` separately after enrollment. See [Getting Started](../getting-started/) for the end-user inference setup path. + +### Cloudflare enrollment + +```bash +# Enroll an organization +fullsend mint enroll acme-corp --platform=cloudflare + +# Enroll a specific repository +fullsend mint enroll acme-corp/my-repo --platform=cloudflare --worker-name="my-mint" +``` + +Cloudflare enrollment updates the durable Worker's env vars via the Cloudflare Versions API — no local Worker sources or WASM build artifacts required. The command clones the currently deployed version's modules, updates the target plain-text bindings, and deploys the new version to 100% traffic. Org enrollment updates `ALLOWED_ORGS`; per-repo enrollment updates `PER_REPO_WIF_REPOS` only (it does not modify `ALLOWED_ORGS`). There is no WIF step — the CF mint handler authorizes callers directly via env var lists. + +Required credentials: `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID`, or a Wrangler OAuth session (`wrangler login`). + +`--preview` is rejected for enroll — preview Workers are configured exclusively via `mint deploy` with the full desired config. + +> **Enroll serially.** The Cloudflare enroll/unenroll path uses a read-modify-write cycle against the Versions API — the CLI reads the current env vars, merges the change, and deploys a new version. Concurrent enroll or unenroll commands against the same Worker will race, and one change may be lost. Run enrollment and unenrollment commands one at a time, just like GCP enrollment (see [Enrollment ordering](#enrollment-ordering)). ### Flags | Flag | Default | Description | |------|---------|-------------| -| `--project` | | GCP project ID (required) | +| `--platform` | `gcp` | Target platform: `gcp` or `cloudflare` | +| `--project` | | GCP project ID (required for `--platform=gcp`) | | `--region` | `us-central1` | Cloud region for the mint service | +| `--worker-name` | `fullsend-mint` | Cloudflare Worker script name | +| `--preview` | | Rejected for enroll — use `mint deploy` for preview Workers | | `--dry-run` | `false` | Preview changes without making them | ### Migration from per-org app ID flags @@ -315,11 +338,13 @@ This prevents a class of bugs where the service template is updated but traffic ### Enrollment ordering -Enroll organizations serially — do not run concurrent enrollment commands against the same mint. The CLI reads the current env vars, merges the new org's entries, and writes the result back. Two concurrent enrollments will race, and one org's entries may be lost. +Enroll organizations serially — do not run concurrent enrollment or unenrollment commands against the same mint. On both GCP and Cloudflare, the CLI reads the current env vars, merges the new org's entries, and writes the result back. Two concurrent commands will race, and one change may be lost. This is an accepted CLI limitation (no ETag-based concurrency control); always run enroll and unenroll commands one at a time. ## Unenrolling organizations and repositories -`fullsend mint unenroll` removes an organization or repository from the mint. +`fullsend mint unenroll` removes an organization or repository from the mint. Use `--platform` to select the target (default: `gcp`). + +### GCP unenrollment ```bash # Unenroll an organization @@ -331,13 +356,32 @@ fullsend mint unenroll acme-corp/my-repo --project="$GCP_PROJECT" Org-scoped unenroll removes the org from mint env vars and the shared WIF provider's attribute condition. Role PEM secrets are shared across orgs and are not modified. Repo-scoped unenroll only disables the repo-specific WIF provider (or permanently deletes it with `--delete-provider`) — it does not touch PEM secrets. +### Cloudflare unenrollment + +```bash +# Unenroll an organization +fullsend mint unenroll acme-corp --platform=cloudflare + +# Unenroll a specific repository +fullsend mint unenroll acme-corp/my-repo --platform=cloudflare --worker-name="my-mint" +``` + +Removes the org/repo from the durable Worker's env vars (`ALLOWED_ORGS` or `PER_REPO_WIF_REPOS`) via the Cloudflare Versions API — no local Worker sources or WASM build artifacts required. If the entity is not currently enrolled, the command succeeds without creating a new version. + +`--preview` is rejected — preview Workers are configured exclusively via `mint deploy`. + +> **Unenroll serially.** Like enroll, the Cloudflare unenroll path uses a read-modify-write cycle. Do not run concurrent unenroll commands against the same Worker — see [Enrollment ordering](#enrollment-ordering). + ### Flags | Flag | Default | Description | |------|---------|-------------| -| `--project` | | GCP project ID (required) | +| `--platform` | `gcp` | Target platform: `gcp` or `cloudflare` | +| `--project` | | GCP project ID (required for `--platform=gcp`) | | `--region` | `us-central1` | Cloud region for the mint service | -| `--delete-provider` | `false` | Permanently delete WIF provider (repo-scoped only) | +| `--delete-provider` | `false` | Permanently delete WIF provider (GCP repo-scoped only) | +| `--worker-name` | `fullsend-mint` | Cloudflare Worker script name | +| `--preview` | | Rejected for unenroll — use `mint deploy` for preview Workers | | `--dry-run` | `false` | Preview changes without making them | | `--yolo` | `false` | Skip interactive confirmation (for automation) | diff --git a/internal/cli/mint.go b/internal/cli/mint.go index afab56d3c4..9e2f2f35bc 100644 --- a/internal/cli/mint.go +++ b/internal/cli/mint.go @@ -619,6 +619,29 @@ func warnIrrelevantFlags(cmd *cobra.Command, platform string) { } } +// warnIrrelevantEnrollFlags prints a warning for each flag that was explicitly +// set but belongs to a different platform than the one being used. This is the +// enroll/unenroll counterpart to warnIrrelevantFlags (used by deploy/delete). +func warnIrrelevantEnrollFlags(cmd *cobra.Command, platform string) { + irrelevant := map[string][]struct{ flag, owner string }{ + "gcp": { + {"worker-name", "Cloudflare"}, + {"preview", "Cloudflare"}, + }, + "cloudflare": { + {"project", "GCP"}, + {"region", "GCP"}, + {"delete-provider", "GCP"}, + }, + } + + for _, entry := range irrelevant[platform] { + if cmd.Flags().Changed(entry.flag) { + fmt.Fprintf(os.Stderr, "WARNING: --%s is a %s flag and has no effect with --platform=%s\n", entry.flag, entry.owner, platform) + } + } +} + func runMintDeployGCP(ctx context.Context, project, region, sourceDir string, skipDeploy, dryRun bool, pemDir, appSet string, roles []string, public bool) error { if appSet == "" { appSet = appsetup.DefaultAppSet @@ -1054,65 +1077,115 @@ func runMintDeployCloudflare(ctx context.Context, workerName, sourceDir, preview } func newMintEnrollCmd() *cobra.Command { + var platform string var project string var region string var dryRun bool + // Cloudflare-specific flags. + var workerName string + var preview string + cmd := &cobra.Command{ Use: "enroll ", Short: "Enroll an org or repo in the token mint", Long: `Performs full enrollment of an organization or per-repo into an existing mint. -Per-org enrollment (fullsend mint enroll acme): - - Registers the org in ALLOWED_ORGS - - Updates the WIF provider condition - - Requires role PEM secrets to already exist (fullsend-{role}-app-pem) - - Requires shared role app IDs to already be configured on the mint +Use --platform to select the target (default: gcp). + +GCP mode (--platform=gcp): -Per-repo enrollment (fullsend mint enroll acme/widget): - - Adds repo to PER_REPO_WIF_REPOS - - Creates a dedicated WIF provider for the repo - - Does NOT add the owner to ALLOWED_ORGS (per-repo callers are - authorized independently of ALLOWED_ORGS) - - Does NOT grant any IAM roles; Vertex AI access is provisioned - separately via 'fullsend inference provision' + Per-org enrollment (fullsend mint enroll acme): + - Registers the org in ALLOWED_ORGS + - Updates the WIF provider condition + - Requires role PEM secrets to already exist (fullsend-{role}-app-pem) + - Requires shared role app IDs to already be configured on the mint -Requires the same GCP APIs as 'mint deploy' (see 'fullsend mint deploy --help'). + Per-repo enrollment (fullsend mint enroll acme/widget): + - Adds repo to PER_REPO_WIF_REPOS + - Creates a dedicated WIF provider for the repo + - Does NOT add the owner to ALLOWED_ORGS (per-repo callers are + authorized independently of ALLOWED_ORGS) + - Does NOT grant any IAM roles; Vertex AI access is provisioned + separately via 'fullsend inference provision' -Required IAM roles on the mint project: - - roles/cloudfunctions.viewer (read Cloud Function metadata) - - roles/run.admin (update Cloud Run service env vars) - - roles/iam.workloadIdentityPoolAdmin (update WIF provider condition; create repo-scoped providers)`, + Required flags: --project + Required IAM roles on the mint project: + - roles/cloudfunctions.viewer (read Cloud Function metadata) + - roles/run.admin (update Cloud Run service env vars) + - roles/iam.workloadIdentityPoolAdmin (update WIF provider condition; create repo-scoped providers) + +Cloudflare mode (--platform=cloudflare): + + Updates the durable Worker's env vars via the Cloudflare Versions API. + Does not require local Worker sources or WASM build artifacts — the + currently deployed version's modules are cloned from the API. + Org enrollment updates ALLOWED_ORGS; per-repo enrollment updates + PER_REPO_WIF_REPOS only (ALLOWED_ORGS is not modified). + + --preview is rejected: preview Workers are configured exclusively via + 'mint deploy' with the full desired config. Use a dedicated durable + Worker for enroll/unenroll. + + Enroll serially — the CLI reads, merges, and redeploys Worker vars in + a read-modify-write cycle without ETag concurrency control. Concurrent + enroll commands against the same Worker will race. + + Required env vars: CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID + (or a Wrangler OAuth session via 'wrangler login') + Optional: --worker-name (default: fullsend-mint)`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) 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) - } + warnIrrelevantEnrollFlags(cmd, platform) - arg := args[0] - printer := ui.New(os.Stdout) - ctx := cmd.Context() + switch platform { + case "gcp": + 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.Banner(Version()) - printer.Blank() + arg := args[0] + printer := ui.New(os.Stdout) + ctx := cmd.Context() + + printer.Banner(Version()) + printer.Blank() - if strings.Contains(arg, "/") { - return runMintEnrollRepo(ctx, printer, arg, project, region, dryRun) + if strings.Contains(arg, "/") { + return runMintEnrollRepo(ctx, printer, arg, project, region, dryRun) + } + return runMintEnrollOrg(ctx, printer, arg, project, region, dryRun) + + case "cloudflare": + if preview != "" { + return fmt.Errorf("--preview is not supported for enroll; preview Workers are configured via 'mint deploy' with the full desired config") + } + return runMintEnrollCloudflare(cmd.Context(), args[0], workerName, dryRun) + + default: + return fmt.Errorf("unsupported platform %q: must be \"gcp\" or \"cloudflare\"", platform) } - return runMintEnrollOrg(ctx, printer, arg, project, region, dryRun) }, } - cmd.Flags().StringVar(&project, "project", "", "GCP project ID (required)") - cmd.Flags().StringVar(®ion, "region", "us-central1", "GCP region") + // 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") + // GCP-specific flags. + cmd.Flags().StringVar(&project, "project", "", "GCP project ID (required for --platform=gcp)") + cmd.Flags().StringVar(®ion, "region", "us-central1", "GCP region") + + // Cloudflare-specific flags. + cmd.Flags().StringVar(&workerName, "worker-name", "", "Cloudflare Worker script name (default: fullsend-mint)") + cmd.Flags().StringVar(&preview, "preview", "", "rejected: preview Workers use 'mint deploy' instead") + return cmd } @@ -1377,68 +1450,118 @@ func runMintEnrollRepo(ctx context.Context, printer *ui.Printer, repoFullName, p } func newMintUnenrollCmd() *cobra.Command { + var platform string var project string var region string var deleteProvider bool var dryRun bool var yolo bool + // Cloudflare-specific flags. + var workerName string + var preview string + cmd := &cobra.Command{ Use: "unenroll ", Short: "Remove an org or repo from the token mint", Long: `Reverses enrollment by removing the org/repo from mint env vars. -Org unenroll removes the org from ALLOWED_ORGS and the WIF provider condition. -Role PEM secrets and shared role app IDs are not modified during unenroll. +Use --platform to select the target (default: gcp). -Repo unenroll removes the repo from PER_REPO_WIF_REPOS. By default, the -repo's WIF provider is disabled (not deleted). Use --delete-provider for -permanent removal. +GCP mode (--platform=gcp): -Requires typing the org/repo name to confirm (unless --dry-run or --yolo). + Org unenroll removes the org from ALLOWED_ORGS and the WIF provider condition. + Role PEM secrets and shared role app IDs are not modified during unenroll. -Required IAM roles on the mint project: - - roles/cloudfunctions.viewer (read Cloud Function metadata) - - roles/run.admin (update Cloud Run service env vars) - - roles/iam.workloadIdentityPoolAdmin (update, disable, or delete WIF providers)`, + Repo unenroll removes the repo from PER_REPO_WIF_REPOS. By default, the + repo's WIF provider is disabled (not deleted). Use --delete-provider for + permanent removal. + + Requires typing the org/repo name to confirm (unless --dry-run or --yolo). + + Required flags: --project + Required IAM roles on the mint project: + - roles/cloudfunctions.viewer (read Cloud Function metadata) + - roles/run.admin (update Cloud Run service env vars) + - roles/iam.workloadIdentityPoolAdmin (update, disable, or delete WIF providers) + +Cloudflare mode (--platform=cloudflare): + + Removes the org/repo from the durable Worker's env vars (ALLOWED_ORGS, + PER_REPO_WIF_REPOS) via the Cloudflare Versions API. Does not require + local Worker sources or WASM build artifacts. + + --preview is rejected: preview Workers are configured exclusively via + 'mint deploy' with the full desired config. Use a dedicated durable + Worker for enroll/unenroll. + + Unenroll serially — the CLI reads, merges, and redeploys Worker vars in + a read-modify-write cycle without ETag concurrency control. Concurrent + unenroll commands against the same Worker will race. + + Required env vars: CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID + (or a Wrangler OAuth session via 'wrangler login') + Optional: --worker-name (default: fullsend-mint)`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) 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) - } + warnIrrelevantEnrollFlags(cmd, platform) - arg := args[0] - isRepo := strings.Contains(arg, "/") + switch platform { + case "gcp": + 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) + } - if !isRepo && deleteProvider { - return fmt.Errorf("--delete-provider applies to repo unenroll, not org unenroll") - } + arg := args[0] + isRepo := strings.Contains(arg, "/") - printer := ui.New(os.Stdout) - ctx := cmd.Context() + if !isRepo && deleteProvider { + return fmt.Errorf("--delete-provider applies to repo unenroll, not org unenroll") + } - printer.Banner(Version()) - printer.Blank() + printer := ui.New(os.Stdout) + ctx := cmd.Context() + + printer.Banner(Version()) + printer.Blank() - if isRepo { - return runMintUnenrollRepo(ctx, printer, arg, project, region, deleteProvider, dryRun, yolo, os.Stdin) + if isRepo { + return runMintUnenrollRepo(ctx, printer, arg, project, region, deleteProvider, dryRun, yolo, os.Stdin) + } + return runMintUnenrollOrg(ctx, printer, arg, project, region, dryRun, yolo, os.Stdin) + + case "cloudflare": + if preview != "" { + return fmt.Errorf("--preview is not supported for unenroll; preview Workers are configured via 'mint deploy' with the full desired config") + } + return runMintUnenrollCloudflare(cmd.Context(), args[0], workerName, dryRun, yolo, os.Stdin) + + default: + return fmt.Errorf("unsupported platform %q: must be \"gcp\" or \"cloudflare\"", platform) } - return runMintUnenrollOrg(ctx, printer, arg, project, region, dryRun, yolo, os.Stdin) }, } - cmd.Flags().StringVar(&project, "project", "", "GCP project ID (required)") - cmd.Flags().StringVar(®ion, "region", "us-central1", "GCP region") - cmd.Flags().BoolVar(&deleteProvider, "delete-provider", false, "permanently delete WIF provider (default: disable only)") + // 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") + cmd.Flags().BoolVar(&deleteProvider, "delete-provider", false, "permanently delete WIF provider (GCP repo unenroll; default: disable only)") + + // Cloudflare-specific flags. + cmd.Flags().StringVar(&workerName, "worker-name", "", "Cloudflare Worker script name (default: fullsend-mint)") + cmd.Flags().StringVar(&preview, "preview", "", "rejected: preview Workers use 'mint deploy' instead") + return cmd } @@ -1664,6 +1787,346 @@ func runMintUnenrollRepo(ctx context.Context, printer *ui.Printer, repoFullName, return nil } +func runMintEnrollCloudflare(ctx context.Context, arg, workerName string, dryRun bool) error { + accountID, err := cf.ResolveCloudflareAuth(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) + } + + printer := ui.New(os.Stdout) + + printer.Banner(Version()) + printer.Blank() + + if strings.Contains(arg, "/") { + return runMintEnrollRepoCloudflare(ctx, printer, arg, workerName, accountID, dryRun) + } + return runMintEnrollOrgCloudflare(ctx, printer, arg, workerName, accountID, dryRun) +} + +func runMintEnrollOrgCloudflare(ctx context.Context, printer *ui.Printer, org, workerName, accountID string, dryRun bool) error { + org = strings.ToLower(org) + if err := validateOrgName(org); err != nil { + return err + } + + printer.Header("Enrolling org " + org + " in mint (Cloudflare)") + printer.Blank() + + effectiveName := workerName + if effectiveName == "" { + effectiveName = "fullsend-mint" + } + + wrangler := mintCFWranglerFactory(accountID) + provisioner := cf.NewProvisioner(cf.Config{ + AccountID: accountID, + WorkerName: effectiveName, + }, wrangler) + + // Verify Worker exists. + printer.StepStart("Verifying Worker exists") + exists, err := wrangler.WorkerExists(ctx, effectiveName) + if err != nil { + printer.StepFail("Worker check failed") + return fmt.Errorf("checking worker: %w", err) + } + if !exists { + printer.StepFail("Worker not found") + return fmt.Errorf("Worker %s not found — deploy with 'mint deploy --platform=cloudflare' first", effectiveName) + } + printer.StepDone(fmt.Sprintf("Worker %s found", effectiveName)) + + // Warn about preview versions. + hasPreviews, previewErr := provisioner.CheckPreviewVersions(ctx) + if previewErr != nil { + printer.StepWarn(fmt.Sprintf("Could not check for preview versions: %v", previewErr)) + } else if hasPreviews { + printer.StepWarn("Preview versions exist on this Worker — durable config changes may diverge from previews") + printer.StepInfo("Previews use their own config from 'mint deploy --preview'. This enroll updates the durable Worker only.") + } + + if dryRun { + printer.Blank() + printer.StepInfo("Dry run — no changes will be made") + printer.Blank() + printer.StepInfo(fmt.Sprintf(" Would add %s to ALLOWED_ORGS on Worker %s", org, effectiveName)) + return nil + } + + printer.StepStart("Registering org in Worker env vars") + if err := provisioner.EnsureOrgInWorker(ctx, org); err != nil { + printer.StepFail("Failed to register org") + return fmt.Errorf("registering org: %w", err) + } + printer.StepDone("Org registered in Worker") + + printer.Blank() + printer.Summary("Enrollment complete", []string{ + fmt.Sprintf("Organization: %s", org), + fmt.Sprintf("Worker: %s", effectiveName), + "ALLOWED_ORGS updated on durable Worker", + }) + + return nil +} + +func runMintEnrollRepoCloudflare(ctx context.Context, printer *ui.Printer, repoFullName, workerName, accountID string, dryRun bool) error { + repoFullName = strings.ToLower(repoFullName) + parts := strings.SplitN(repoFullName, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return fmt.Errorf("repo must be in owner/repo format, got %q", repoFullName) + } + owner := parts[0] + if err := validateOrgName(owner); err != nil { + return fmt.Errorf("invalid owner: %w", err) + } + + printer.Header("Enrolling repo " + repoFullName + " in mint (Cloudflare)") + printer.Blank() + + effectiveName := workerName + if effectiveName == "" { + effectiveName = "fullsend-mint" + } + + wrangler := mintCFWranglerFactory(accountID) + provisioner := cf.NewProvisioner(cf.Config{ + AccountID: accountID, + WorkerName: effectiveName, + }, wrangler) + + // Verify Worker exists. + printer.StepStart("Verifying Worker exists") + exists, err := wrangler.WorkerExists(ctx, effectiveName) + if err != nil { + printer.StepFail("Worker check failed") + return fmt.Errorf("checking worker: %w", err) + } + if !exists { + printer.StepFail("Worker not found") + return fmt.Errorf("Worker %s not found — deploy with 'mint deploy --platform=cloudflare' first", effectiveName) + } + printer.StepDone(fmt.Sprintf("Worker %s found", effectiveName)) + + // Warn about preview versions. + hasPreviews, previewErr := provisioner.CheckPreviewVersions(ctx) + if previewErr != nil { + printer.StepWarn(fmt.Sprintf("Could not check for preview versions: %v", previewErr)) + } else if hasPreviews { + printer.StepWarn("Preview versions exist on this Worker — durable config changes may diverge from previews") + printer.StepInfo("Previews use their own config from 'mint deploy --preview'. This enroll updates the durable Worker only.") + } + + if dryRun { + printer.Blank() + printer.StepInfo("Dry run — no changes will be made") + printer.Blank() + printer.StepInfo(fmt.Sprintf(" Would add %s to PER_REPO_WIF_REPOS on Worker %s", repoFullName, effectiveName)) + return nil + } + + printer.StepStart("Registering repo in Worker env vars") + if err := provisioner.RegisterRepoInWorker(ctx, repoFullName); err != nil { + printer.StepFail("Failed to register repo") + return fmt.Errorf("registering repo: %w", err) + } + printer.StepDone("Repo registered in Worker") + + printer.Blank() + printer.Summary("Enrollment complete", []string{ + fmt.Sprintf("Repository: %s", repoFullName), + fmt.Sprintf("Worker: %s", effectiveName), + "PER_REPO_WIF_REPOS updated on durable Worker", + }) + + return nil +} + +func runMintUnenrollCloudflare(ctx context.Context, arg, workerName string, dryRun, yolo bool, stdin *os.File) error { + accountID, err := cf.ResolveCloudflareAuth(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) + } + + printer := ui.New(os.Stdout) + + printer.Banner(Version()) + printer.Blank() + + if strings.Contains(arg, "/") { + return runMintUnenrollRepoCloudflare(ctx, printer, arg, workerName, accountID, dryRun, yolo, stdin) + } + return runMintUnenrollOrgCloudflare(ctx, printer, arg, workerName, accountID, dryRun, yolo, stdin) +} + +func runMintUnenrollOrgCloudflare(ctx context.Context, printer *ui.Printer, org, workerName, accountID string, dryRun, yolo bool, stdin *os.File) error { + org = strings.ToLower(org) + if err := validateOrgName(org); err != nil { + return err + } + + printer.Header("Unenrolling org " + org + " from mint (Cloudflare)") + printer.Blank() + + effectiveName := workerName + if effectiveName == "" { + effectiveName = "fullsend-mint" + } + + wrangler := mintCFWranglerFactory(accountID) + provisioner := cf.NewProvisioner(cf.Config{ + AccountID: accountID, + WorkerName: effectiveName, + }, wrangler) + + // Verify Worker exists. + printer.StepStart("Verifying Worker exists") + exists, err := wrangler.WorkerExists(ctx, effectiveName) + if err != nil { + printer.StepFail("Worker check failed") + return fmt.Errorf("checking worker: %w", err) + } + if !exists { + printer.StepFail("Worker not found") + return fmt.Errorf("Worker %s not found — nothing to unenroll", effectiveName) + } + printer.StepDone(fmt.Sprintf("Worker %s found", effectiveName)) + + // Warn about preview versions. + hasPreviews, previewErr := provisioner.CheckPreviewVersions(ctx) + if previewErr != nil { + printer.StepWarn(fmt.Sprintf("Could not check for preview versions: %v", previewErr)) + } else if hasPreviews { + printer.StepWarn("Preview versions exist on this Worker — durable config changes may diverge from previews") + printer.StepInfo("Previews use their own config from 'mint deploy --preview'. This unenroll updates the durable Worker only.") + } + + if dryRun { + printer.Blank() + printer.StepInfo("Dry run — no changes will be made") + printer.Blank() + printer.StepInfo(fmt.Sprintf(" Would remove %s from ALLOWED_ORGS on Worker %s", org, effectiveName)) + return nil + } + + // Confirmation. + if !yolo { + reader := bufio.NewReader(stdin) + isTerminal := term.IsTerminal(int(stdin.Fd())) + if err := confirmUnenroll(printer, org, reader, isTerminal); err != nil { + return err + } + printer.Blank() + } + + printer.StepStart("Removing org from Worker env vars") + if err := provisioner.RemoveOrgFromWorker(ctx, org); err != nil { + printer.StepFail("Failed to remove org") + return fmt.Errorf("removing org: %w", err) + } + printer.StepDone("Org removed from Worker") + + printer.Blank() + printer.Summary("Unenrollment complete", []string{ + fmt.Sprintf("Organization: %s", org), + fmt.Sprintf("Worker: %s", effectiveName), + "ALLOWED_ORGS updated on durable Worker", + }) + + return nil +} + +func runMintUnenrollRepoCloudflare(ctx context.Context, printer *ui.Printer, repoFullName, workerName, accountID string, dryRun, yolo bool, stdin *os.File) error { + repoFullName = strings.ToLower(repoFullName) + parts := strings.SplitN(repoFullName, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return fmt.Errorf("repo must be in owner/repo format, got %q", repoFullName) + } + owner := parts[0] + if err := validateOrgName(owner); err != nil { + return fmt.Errorf("invalid owner: %w", err) + } + + printer.Header("Unenrolling repo " + repoFullName + " from mint (Cloudflare)") + printer.Blank() + + effectiveName := workerName + if effectiveName == "" { + effectiveName = "fullsend-mint" + } + + wrangler := mintCFWranglerFactory(accountID) + provisioner := cf.NewProvisioner(cf.Config{ + AccountID: accountID, + WorkerName: effectiveName, + }, wrangler) + + // Verify Worker exists. + printer.StepStart("Verifying Worker exists") + exists, err := wrangler.WorkerExists(ctx, effectiveName) + if err != nil { + printer.StepFail("Worker check failed") + return fmt.Errorf("checking worker: %w", err) + } + if !exists { + printer.StepFail("Worker not found") + return fmt.Errorf("Worker %s not found — nothing to unenroll", effectiveName) + } + printer.StepDone(fmt.Sprintf("Worker %s found", effectiveName)) + + // Warn about preview versions. + hasPreviews, previewErr := provisioner.CheckPreviewVersions(ctx) + if previewErr != nil { + printer.StepWarn(fmt.Sprintf("Could not check for preview versions: %v", previewErr)) + } else if hasPreviews { + printer.StepWarn("Preview versions exist on this Worker — durable config changes may diverge from previews") + printer.StepInfo("Previews use their own config from 'mint deploy --preview'. This unenroll updates the durable Worker only.") + } + + if dryRun { + printer.Blank() + printer.StepInfo("Dry run — no changes will be made") + printer.Blank() + printer.StepInfo(fmt.Sprintf(" Would remove %s from PER_REPO_WIF_REPOS on Worker %s", repoFullName, effectiveName)) + return nil + } + + // Confirmation. + if !yolo { + reader := bufio.NewReader(stdin) + isTerminal := term.IsTerminal(int(stdin.Fd())) + if err := confirmUnenroll(printer, repoFullName, reader, isTerminal); err != nil { + return err + } + printer.Blank() + } + + printer.StepStart("Removing repo from Worker env vars") + if err := provisioner.RemoveRepoFromWorker(ctx, repoFullName); err != nil { + printer.StepFail("Failed to remove repo") + return fmt.Errorf("removing repo: %w", err) + } + printer.StepDone("Repo removed from Worker") + + printer.Blank() + printer.Summary("Unenrollment complete", []string{ + fmt.Sprintf("Repository: %s", repoFullName), + fmt.Sprintf("Worker: %s", effectiveName), + "PER_REPO_WIF_REPOS updated on durable Worker", + }) + + return nil +} + func newMintStatusCmd() *cobra.Command { var project string var region string diff --git a/internal/cli/mint_test.go b/internal/cli/mint_test.go index c461283c18..3e2e43ca71 100644 --- a/internal/cli/mint_test.go +++ b/internal/cli/mint_test.go @@ -53,6 +53,23 @@ type fakeCFWranglerRunner struct { // workerExists controls the return value of WorkerExists. // Defaults to true (Worker exists). workerExists *bool + // workerVars holds the vars returned by GetVars. + workerVars map[string]string + // getVarsErr, if non-nil, is returned by GetVars. + getVarsErr error + // hasPreviewVersions controls the return value of HasPreviewVersions. + hasPreviewVersions bool + // hasPreviewVersionsErr, if non-nil, is returned by HasPreviewVersions. + hasPreviewVersionsErr error + // updateVarsCalls tracks calls to UpdateVars. + updateVarsCalls []fakeCFUpdateVarsCall + // updateVarsErr, if non-nil, is returned by UpdateVars. + updateVarsErr error +} + +type fakeCFUpdateVarsCall struct { + workerName string + vars map[string]string } type fakeCFDeployCall struct { @@ -101,6 +118,31 @@ func (f *fakeCFWranglerRunner) WorkerExists(_ context.Context, _ string) (bool, return true, nil // default: Worker exists } +func (f *fakeCFWranglerRunner) GetVars(_ context.Context, _ string) (map[string]string, error) { + if f.getVarsErr != nil { + return nil, f.getVarsErr + } + if f.workerVars != nil { + return f.workerVars, nil + } + return make(map[string]string), nil +} + +func (f *fakeCFWranglerRunner) HasPreviewVersions(_ context.Context, _ string) (bool, error) { + if f.hasPreviewVersionsErr != nil { + return false, f.hasPreviewVersionsErr + } + return f.hasPreviewVersions, nil +} + +func (f *fakeCFWranglerRunner) UpdateVars(_ context.Context, workerName string, vars map[string]string) error { + f.updateVarsCalls = append(f.updateVarsCalls, fakeCFUpdateVarsCall{ + workerName: workerName, + vars: vars, + }) + return f.updateVarsErr +} + func TestMintCommand_HasSubcommands(t *testing.T) { cmd := newMintCmd() names := make(map[string]bool) @@ -3942,6 +3984,200 @@ func TestMintUnenrollCmd_DryRunOrg(t *testing.T) { require.NoError(t, cmd.Execute()) } +// --- Cloudflare enroll tests --- + +func TestMintEnrollCmd_CloudflareFlags(t *testing.T) { + cmd := newMintEnrollCmd() + + platformFlag := cmd.Flags().Lookup("platform") + require.NotNil(t, platformFlag, "expected --platform flag") + assert.Equal(t, "gcp", platformFlag.DefValue) + + 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 TestMintEnrollCmd_CloudflarePreviewRejected(t *testing.T) { + withCFEnvVars(t) + cmd := newRootCmd() + cmd.SetArgs([]string{"mint", "enroll", "acme", "--platform=cloudflare", "--preview=test"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "--preview is not supported for enroll") +} + +func TestMintEnrollCmd_CloudflareDryRunOrg(t *testing.T) { + withCFEnvVars(t) + withMintCFWrangler(t, &fakeCFWranglerRunner{}) + cmd := newRootCmd() + cmd.SetArgs([]string{ + "mint", "enroll", "acme", + "--platform=cloudflare", + "--dry-run", + }) + require.NoError(t, cmd.Execute()) +} + +func TestMintEnrollCmd_CloudflareDryRunRepo(t *testing.T) { + withCFEnvVars(t) + withMintCFWrangler(t, &fakeCFWranglerRunner{}) + cmd := newRootCmd() + cmd.SetArgs([]string{ + "mint", "enroll", "acme/widget", + "--platform=cloudflare", + "--dry-run", + }) + require.NoError(t, cmd.Execute()) +} + +func TestRunMintEnrollOrgCloudflare_Success(t *testing.T) { + fake := &fakeCFWranglerRunner{ + workerVars: map[string]string{"ALLOWED_ORGS": "existing-org"}, + } + withMintCFWrangler(t, fake) + printer := ui.New(&strings.Builder{}) + err := runMintEnrollOrgCloudflare(context.Background(), printer, "new-org", "", "test-account", false) + require.NoError(t, err) + require.Len(t, fake.updateVarsCalls, 1) + assert.Equal(t, "fullsend-mint", fake.updateVarsCalls[0].workerName, "should use default worker name") + assert.Equal(t, "existing-org,new-org", fake.updateVarsCalls[0].vars["ALLOWED_ORGS"]) + assert.Empty(t, fake.deployCalls, "enroll should not call Deploy") +} + +func TestRunMintEnrollRepoCloudflare_Success(t *testing.T) { + fake := &fakeCFWranglerRunner{ + workerVars: map[string]string{ + "ALLOWED_ORGS": "acme", + "PER_REPO_WIF_REPOS": "", + }, + } + withMintCFWrangler(t, fake) + printer := ui.New(&strings.Builder{}) + err := runMintEnrollRepoCloudflare(context.Background(), printer, "acme/widget", "", "test-account", false) + require.NoError(t, err) + require.Len(t, fake.updateVarsCalls, 1) + assert.Equal(t, "fullsend-mint", fake.updateVarsCalls[0].workerName, "should use default worker name") + assert.Equal(t, "acme/widget", fake.updateVarsCalls[0].vars["PER_REPO_WIF_REPOS"]) +} + +func TestRunMintEnrollOrgCloudflare_WorkerNotFound(t *testing.T) { + exists := false + withMintCFWrangler(t, &fakeCFWranglerRunner{workerExists: &exists}) + printer := ui.New(&strings.Builder{}) + err := runMintEnrollOrgCloudflare(context.Background(), printer, "acme", "", "test-account", false) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestRunMintEnrollOrgCloudflare_PreviewWarning(t *testing.T) { + fake := &fakeCFWranglerRunner{ + workerVars: map[string]string{"ALLOWED_ORGS": "existing-org"}, + hasPreviewVersions: true, + } + withMintCFWrangler(t, fake) + out := &strings.Builder{} + printer := ui.New(out) + err := runMintEnrollOrgCloudflare(context.Background(), printer, "new-org", "", "test-account", false) + require.NoError(t, err) + assert.Contains(t, out.String(), "Preview versions exist") +} + +// --- Cloudflare unenroll tests --- + +func TestMintUnenrollCmd_CloudflareFlags(t *testing.T) { + cmd := newMintUnenrollCmd() + + platformFlag := cmd.Flags().Lookup("platform") + require.NotNil(t, platformFlag, "expected --platform flag") + assert.Equal(t, "gcp", platformFlag.DefValue) + + 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 TestMintUnenrollCmd_CloudflarePreviewRejected(t *testing.T) { + withCFEnvVars(t) + cmd := newRootCmd() + cmd.SetArgs([]string{"mint", "unenroll", "acme", "--platform=cloudflare", "--preview=test"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "--preview is not supported for unenroll") +} + +func TestMintUnenrollCmd_CloudflareDryRunOrg(t *testing.T) { + withCFEnvVars(t) + withMintCFWrangler(t, &fakeCFWranglerRunner{}) + cmd := newRootCmd() + cmd.SetArgs([]string{ + "mint", "unenroll", "acme", + "--platform=cloudflare", + "--dry-run", + }) + require.NoError(t, cmd.Execute()) +} + +func TestRunMintUnenrollOrgCloudflare_Success(t *testing.T) { + fake := &fakeCFWranglerRunner{ + workerVars: map[string]string{"ALLOWED_ORGS": "acme,other"}, + } + withMintCFWrangler(t, fake) + printer := ui.New(&strings.Builder{}) + err := runMintUnenrollOrgCloudflare(context.Background(), printer, "acme", "", "test-account", false, true, os.Stdin) + require.NoError(t, err) + require.Len(t, fake.updateVarsCalls, 1) + assert.Equal(t, "fullsend-mint", fake.updateVarsCalls[0].workerName, "should use default worker name") + assert.Equal(t, "other", fake.updateVarsCalls[0].vars["ALLOWED_ORGS"]) + assert.Empty(t, fake.deployCalls, "unenroll should not call Deploy") +} + +func TestRunMintUnenrollRepoCloudflare_Success(t *testing.T) { + fake := &fakeCFWranglerRunner{ + workerVars: map[string]string{ + "ALLOWED_ORGS": "acme", + "PER_REPO_WIF_REPOS": "acme/widget,acme/other", + }, + } + withMintCFWrangler(t, fake) + printer := ui.New(&strings.Builder{}) + err := runMintUnenrollRepoCloudflare(context.Background(), printer, "acme/widget", "", "test-account", false, true, os.Stdin) + require.NoError(t, err) + require.Len(t, fake.updateVarsCalls, 1) + assert.Equal(t, "fullsend-mint", fake.updateVarsCalls[0].workerName, "should use default worker name") + assert.Equal(t, "acme/other", fake.updateVarsCalls[0].vars["PER_REPO_WIF_REPOS"]) + assert.Empty(t, fake.deployCalls, "unenroll should not call Deploy") +} + +func TestRunMintUnenrollOrgCloudflare_WorkerNotFound(t *testing.T) { + exists := false + withMintCFWrangler(t, &fakeCFWranglerRunner{workerExists: &exists}) + printer := ui.New(&strings.Builder{}) + err := runMintUnenrollOrgCloudflare(context.Background(), printer, "acme", "", "test-account", false, true, os.Stdin) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestMintEnrollCmd_UnsupportedPlatform(t *testing.T) { + cmd := newRootCmd() + cmd.SetArgs([]string{"mint", "enroll", "acme", "--platform=azure"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported platform") +} + +func TestMintUnenrollCmd_UnsupportedPlatform(t *testing.T) { + cmd := newRootCmd() + cmd.SetArgs([]string{"mint", "unenroll", "acme", "--platform=azure"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported platform") +} + func TestVerifyEnrollment_TrafficRevisionWarning(t *testing.T) { out := &strings.Builder{} printer := ui.New(out) diff --git a/internal/dispatch/cf/provisioner.go b/internal/dispatch/cf/provisioner.go index fdd45ec998..93b76460f3 100644 --- a/internal/dispatch/cf/provisioner.go +++ b/internal/dispatch/cf/provisioner.go @@ -7,13 +7,17 @@ package cf import ( + "bytes" "context" "embed" "encoding/json" "fmt" "io" "io/fs" + "mime" + "mime/multipart" "net/http" + "net/textproto" "os" "os/exec" "path/filepath" @@ -37,12 +41,29 @@ const ( const ( defaultWorkerName = "fullsend-mint" defaultOIDCAudience = "fullsend-mint" + + // maxAPIResponseBytes caps io.ReadAll on Cloudflare JSON API + // responses (settings, versions, deployments, subdomain). + maxAPIResponseBytes = 10 << 20 // 10 MB + + // maxErrorResponseBytes caps io.ReadAll on error response bodies. + maxErrorResponseBytes = 1 << 20 // 1 MB ) +// maxWorkerModuleBytes caps io.ReadAll on Worker module content +// retrieved from the content API (multipart parts or single body). +// This is a var (not const) so tests can temporarily lower it. +var maxWorkerModuleBytes int64 = 50 << 20 // 50 MB + // workerNamePattern validates Cloudflare Worker names. // Worker names must be lowercase alphanumeric with hyphens, 2-63 chars. var workerNamePattern = regexp.MustCompile(`^[a-z][a-z0-9-]{0,61}[a-z0-9]$`) +// moduleNamePattern validates module names before embedding them in +// Content-Disposition headers. Only letters, digits, dots, underscores, +// and hyphens are allowed to prevent header injection. +var moduleNamePattern = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) + // Compile-time check that Provisioner implements dispatch.Dispatcher. var _ dispatch.Dispatcher = (*Provisioner)(nil) @@ -155,6 +176,25 @@ type WranglerRunner interface { // already exists. Used to determine whether a bootstrap durable // deploy is needed before a preview deploy. WorkerExists(ctx context.Context, workerName string) (bool, error) + + // GetVars reads the current plain-text variable bindings from a + // durable Worker via the Cloudflare API. Returns a map of var + // names to values. Secret bindings are excluded. + GetVars(ctx context.Context, workerName string) (map[string]string, error) + + // HasPreviewVersions reports whether any preview-aliased versions + // exist on the Worker. Used to warn operators before mutating + // durable config. + HasPreviewVersions(ctx context.Context, workerName string) (bool, error) + + // UpdateVars creates a new Worker version by cloning the currently + // deployed version's modules and bindings, updating only the + // specified plain_text vars, and deploying the new version to 100% + // traffic. This does not require local Worker sources or WASM build + // artifacts — module bytes are fetched from the Cloudflare API and + // re-uploaded. Non-plain_text bindings (secrets, KV, DO, etc.) are + // preserved via keep_bindings. + UpdateVars(ctx context.Context, workerName string, vars map[string]string) error } // Provisioner creates Cloudflare Worker infrastructure for token minting. @@ -356,6 +396,190 @@ func (p *Provisioner) Teardown(ctx context.Context) error { } } +// GetWorkerVars reads the current plain-text variable bindings from the +// durable Worker. Delegates to the WranglerRunner. +func (p *Provisioner) GetWorkerVars(ctx context.Context) (map[string]string, error) { + return p.wrangler.GetVars(ctx, p.cfg.WorkerName) +} + +// CheckPreviewVersions reports whether any preview-aliased versions +// exist on the Worker. Used to warn operators before mutating durable +// config. +func (p *Provisioner) CheckPreviewVersions(ctx context.Context) (bool, error) { + return p.wrangler.HasPreviewVersions(ctx, p.cfg.WorkerName) +} + +// EnsureOrgInWorker adds an org to the durable Worker's ALLOWED_ORGS. +// If the org is already present (case-insensitive), this is a no-op. +// The Worker must already exist (deployed via 'mint deploy'). +func (p *Provisioner) EnsureOrgInWorker(ctx context.Context, org string) error { + vars, err := p.wrangler.GetVars(ctx, p.cfg.WorkerName) + if err != nil { + return fmt.Errorf("reading worker vars: %w", err) + } + + // Public mode on CF is PER_REPO_WIF_REPOS=* (set by mint deploy --public). + // Org enroll is a no-op when the mint is public. + perRepoWIFRepos := parsePerRepoWIFReposMap(vars["PER_REPO_WIF_REPOS"]) + if mintcore.IsPublicMintRepos(perRepoWIFRepos) { + return nil + } + + existingOrgs := mintcore.ParseAllowedOrgs(vars["ALLOWED_ORGS"]) + + // Check if org is already present (case-insensitive). + orgLower := strings.ToLower(org) + for _, existing := range existingOrgs { + if strings.ToLower(existing) == orgLower { + return nil // already enrolled + } + } + + // Append org and update. + existingOrgs = append(existingOrgs, org) + updatedVars := map[string]string{ + "ALLOWED_ORGS": strings.Join(existingOrgs, ","), + } + + return p.updateDurableVars(ctx, updatedVars) +} + +// RemoveOrgFromWorker removes an org from the durable Worker's ALLOWED_ORGS. +func (p *Provisioner) RemoveOrgFromWorker(ctx context.Context, org string) error { + vars, err := p.wrangler.GetVars(ctx, p.cfg.WorkerName) + if err != nil { + return fmt.Errorf("reading worker vars: %w", err) + } + + // Public mode on CF is PER_REPO_WIF_REPOS=* (set by mint deploy --public). + perRepoWIFRepos := parsePerRepoWIFReposMap(vars["PER_REPO_WIF_REPOS"]) + if mintcore.IsPublicMintRepos(perRepoWIFRepos) { + return fmt.Errorf("mint is in public mode (PER_REPO_WIF_REPOS=*); individual org unenroll is not supported") + } + + existingOrgs := mintcore.ParseAllowedOrgs(vars["ALLOWED_ORGS"]) + + // Filter out org (case-insensitive). + orgLower := strings.ToLower(org) + var filtered []string + for _, existing := range existingOrgs { + if strings.ToLower(existing) != orgLower { + filtered = append(filtered, existing) + } + } + + // Skip redeploy if the org was not present. + if len(filtered) == len(existingOrgs) { + return nil + } + + updatedVars := map[string]string{ + "ALLOWED_ORGS": strings.Join(filtered, ","), + } + + return p.updateDurableVars(ctx, updatedVars) +} + +// RegisterRepoInWorker adds a repo to the durable Worker's +// PER_REPO_WIF_REPOS. The owner is NOT added to ALLOWED_ORGS — +// per-repo enrollment is independent of org-level enrollment on +// both GCP and Cloudflare. Per-repo callers are authorized via +// PER_REPO_WIF_REPOS alone; ALLOWED_ORGS governs org-level access. +// The Worker must already exist (deployed via 'mint deploy'). +func (p *Provisioner) RegisterRepoInWorker(ctx context.Context, repoFullName string) error { + vars, err := p.wrangler.GetVars(ctx, p.cfg.WorkerName) + if err != nil { + return fmt.Errorf("reading worker vars: %w", err) + } + + // Public mode on CF is PER_REPO_WIF_REPOS=* (set by mint deploy --public). + // Per-repo registration is a no-op when the mint is already public. + perRepoWIFRepos := parsePerRepoWIFReposMap(vars["PER_REPO_WIF_REPOS"]) + if mintcore.IsPublicMintRepos(perRepoWIFRepos) { + return nil + } + + // Parse existing per-repo WIF repos. + existingRepos := mintcore.ParseAllowedOrgs(vars["PER_REPO_WIF_REPOS"]) + + // Check if repo is already present (case-insensitive). + repoLower := strings.ToLower(repoFullName) + for _, existing := range existingRepos { + if strings.ToLower(existing) == repoLower { + return nil // already enrolled + } + } + + // Append repo. + existingRepos = append(existingRepos, repoFullName) + + updatedVars := map[string]string{ + "PER_REPO_WIF_REPOS": strings.Join(existingRepos, ","), + } + + return p.updateDurableVars(ctx, updatedVars) +} + +// RemoveRepoFromWorker removes a repo from the durable Worker's +// PER_REPO_WIF_REPOS. +func (p *Provisioner) RemoveRepoFromWorker(ctx context.Context, repoFullName string) error { + vars, err := p.wrangler.GetVars(ctx, p.cfg.WorkerName) + if err != nil { + return fmt.Errorf("reading worker vars: %w", err) + } + + // Public mode on CF is PER_REPO_WIF_REPOS=* (set by mint deploy --public). + perRepoWIFRepos := parsePerRepoWIFReposMap(vars["PER_REPO_WIF_REPOS"]) + if mintcore.IsPublicMintRepos(perRepoWIFRepos) { + return fmt.Errorf("mint is in public mode (PER_REPO_WIF_REPOS=*); per-repo unenroll is not supported") + } + + // Parse existing per-repo WIF repos. + existingRepos := mintcore.ParseAllowedOrgs(vars["PER_REPO_WIF_REPOS"]) + + // Filter out repo (case-insensitive). + repoLower := strings.ToLower(repoFullName) + var filtered []string + for _, existing := range existingRepos { + if strings.ToLower(existing) != repoLower { + filtered = append(filtered, existing) + } + } + + // Skip redeploy if the repo was not present. + if len(filtered) == len(existingRepos) { + return nil + } + + updatedVars := map[string]string{ + "PER_REPO_WIF_REPOS": strings.Join(filtered, ","), + } + + return p.updateDurableVars(ctx, updatedVars) +} + +// parsePerRepoWIFReposMap splits a PER_REPO_WIF_REPOS CSV string into +// the map[string]bool format that mintcore.IsPublicMintRepos expects. +// Entries are lowercased to match the NewHandler convention. +func parsePerRepoWIFReposMap(csv string) map[string]bool { + m := make(map[string]bool) + for _, entry := range mintcore.SplitCSV(csv) { + m[strings.ToLower(entry)] = true + } + return m +} + +// updateDurableVars updates env vars on the durable Worker by cloning +// the currently deployed version's modules and bindings via the +// Cloudflare API. Only the specified plain_text vars are modified; +// all other bindings (secrets, KV, DO, etc.) are preserved via +// keep_bindings. This does not require local Worker sources, WASM +// build artifacts, or wrangler deploy — module bytes are fetched +// from the Cloudflare API and re-uploaded. +func (p *Provisioner) updateDurableVars(ctx context.Context, vars map[string]string) error { + return p.wrangler.UpdateVars(ctx, p.cfg.WorkerName, vars) +} + // validate checks that the Config has all required fields. func (p *Provisioner) validate() error { if p.cfg.AccountID == "" { @@ -995,6 +1219,492 @@ func (r *LiveWranglerRunner) WorkerExists(ctx context.Context, workerName string return true, nil } +// GetVars reads the current plain-text variable bindings from a durable +// Worker via the Cloudflare API (GET /accounts/:id/workers/scripts/:name/settings). +// Returns a map of var names to values. Secret bindings are excluded. +func (r *LiveWranglerRunner) GetVars(ctx context.Context, workerName string) (map[string]string, error) { + return GetWorkerVarsFn(ctx, r.AccountID, workerName) +} + +// HasPreviewVersions reports whether any preview-aliased versions exist +// on the Worker by parsing `wrangler versions list` output. +func (r *LiveWranglerRunner) HasPreviewVersions(ctx context.Context, workerName string) (bool, error) { + cmd := exec.CommandContext(ctx, "npx", "wrangler", "versions", "list", "--name", workerName) + cmd.Env = append(os.Environ(), + "CLOUDFLARE_ACCOUNT_ID="+r.AccountID, + ) + + output, err := cmd.CombinedOutput() + if err != nil { + return false, fmt.Errorf("listing worker versions: %s\n%s", err, string(output)) + } + + return parseHasPreviewVersions(string(output)), nil +} + +// parseHasPreviewVersions checks wrangler versions list output for +// preview-aliased entries. Wrangler outputs lines containing +// "preview" or "alias" when preview versions exist. +func parseHasPreviewVersions(output string) bool { + lower := strings.ToLower(output) + // Wrangler versions list shows aliases in the output table. + // A line containing "preview" in the alias column indicates a + // preview version exists. + for line := range strings.SplitSeq(lower, "\n") { + line = strings.TrimSpace(line) + // Skip header/decoration lines. + if line == "" || strings.HasPrefix(line, "─") || strings.HasPrefix(line, "┌") || + strings.HasPrefix(line, "├") || strings.HasPrefix(line, "└") { + continue + } + // Look for alias indicators in version rows. + if strings.Contains(line, "preview") && !strings.Contains(line, "version id") { + return true + } + } + return false +} + +// UpdateVars creates a new Worker version by cloning the currently +// deployed version's modules and bindings, updating only the specified +// plain_text vars, and deploying the new version to 100% traffic. +// This does not require local Worker sources or WASM build artifacts. +func (r *LiveWranglerRunner) UpdateVars(ctx context.Context, workerName string, vars map[string]string) error { + token, err := ResolveCloudflareAPITokenFn(ctx) + if err != nil { + return fmt.Errorf("resolving API token: %w", err) + } + + // 1. Fetch module content from the currently deployed Worker. + modules, mainModule, err := fetchWorkerContent(ctx, r.AccountID, workerName, token) + if err != nil { + return fmt.Errorf("fetching worker content: %w", err) + } + + // 2. Fetch current settings to get existing plain_text bindings. + currentVars, err := GetWorkerVarsFn(ctx, r.AccountID, workerName) + if err != nil { + return fmt.Errorf("reading current vars: %w", err) + } + + // 3. Merge updated vars into current vars. + for k, v := range vars { + currentVars[k] = v + } + + // 4. Create a new version with updated bindings. + versionID, err := createVersionWithVars(ctx, r.AccountID, workerName, token, modules, mainModule, currentVars) + if err != nil { + return fmt.Errorf("creating new version: %w", err) + } + + // 5. Deploy the new version to 100% traffic. + if err := deployVersionFn(ctx, r.AccountID, workerName, token, versionID); err != nil { + return fmt.Errorf("deploying version: %w", err) + } + + return nil +} + +// workerModule represents a module fetched from the Workers content API. +type workerModule struct { + name string + contentType string + data []byte +} + +// fetchWorkerContent fetches the currently deployed Worker's module +// content via GET /accounts/{account}/workers/scripts/{name}/content/v2. +// Returns the modules and the main module name. +func fetchWorkerContent(ctx context.Context, accountID, workerName, token string) ([]workerModule, string, error) { + apiURL := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/workers/scripts/%s/content/v2", accountID, workerName) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return nil, "", fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, "", fmt.Errorf("calling content API: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorResponseBytes)) + return nil, "", fmt.Errorf("content API returned %d: %s", resp.StatusCode, string(body)) + } + + // The cf-entrypoint header tells us which module is main. + mainModule := resp.Header.Get("cf-entrypoint") + + ct := resp.Header.Get("Content-Type") + mediaType, params, err := mime.ParseMediaType(ct) + if err != nil { + return nil, "", fmt.Errorf("parsing content-type %q: %w", ct, err) + } + + var modules []workerModule + + if strings.HasPrefix(mediaType, "multipart/") { + boundary := params["boundary"] + if boundary == "" { + return nil, "", fmt.Errorf("multipart response missing boundary") + } + reader := multipart.NewReader(resp.Body, boundary) + for { + part, partErr := reader.NextPart() + if partErr == io.EOF { + break + } + if partErr != nil { + return nil, "", fmt.Errorf("reading multipart part: %w", partErr) + } + lr := io.LimitReader(part, maxWorkerModuleBytes) + data, readErr := io.ReadAll(lr) + if readErr != nil { + return nil, "", fmt.Errorf("reading part data: %w", readErr) + } + // Detect truncation: if LimitReader hit the cap, + // there may be unread data. Try reading one more + // byte — if it succeeds the module was truncated. + if int64(len(data)) == maxWorkerModuleBytes { + var probe [1]byte + if n, _ := part.Read(probe[:]); n > 0 { + name := part.FileName() + if name == "" { + name = part.FormName() + } + return nil, "", fmt.Errorf("module %q exceeds %d bytes; refusing to upload truncated content", name, maxWorkerModuleBytes) + } + } + name := part.FileName() + if name == "" { + name = part.FormName() + } + partCT := part.Header.Get("Content-Type") + if partCT == "" { + partCT = "application/octet-stream" + } + modules = append(modules, workerModule{ + name: name, + contentType: partCT, + data: data, + }) + } + } else { + // Single-module Worker — entire body is the module. + lr := io.LimitReader(resp.Body, maxWorkerModuleBytes) + data, readErr := io.ReadAll(lr) + if readErr != nil { + return nil, "", fmt.Errorf("reading single-module body: %w", readErr) + } + // Detect truncation: if LimitReader hit the cap, there may + // be unread data. Try reading one more byte from the original + // body — if it succeeds the module was truncated. + if int64(len(data)) == maxWorkerModuleBytes { + var probe [1]byte + if n, _ := resp.Body.Read(probe[:]); n > 0 { + return nil, "", fmt.Errorf("single-module worker exceeds %d bytes; refusing to upload truncated content", maxWorkerModuleBytes) + } + } + name := mainModule + if name == "" { + name = "index.js" + } + modules = append(modules, workerModule{ + name: name, + contentType: ct, + data: data, + }) + } + + if mainModule == "" && len(modules) > 0 { + mainModule = modules[0].name + } + + return modules, mainModule, nil +} + +// createVersionWithVars creates a new Worker version via the Versions +// Upload API (POST /versions). Modules are re-uploaded from API-fetched +// bytes. Only plain_text bindings are specified; all other binding types +// are preserved via keep_bindings. +func createVersionWithVars(ctx context.Context, accountID, workerName, token string, modules []workerModule, mainModule string, vars map[string]string) (string, error) { + // Build plain_text bindings from the merged vars map. + var bindings []map[string]string + for k, v := range vars { + bindings = append(bindings, map[string]string{ + "type": "plain_text", + "name": k, + "text": v, + }) + } + + // Metadata specifies the main module, updated bindings, and + // keep_bindings for all non-plain_text binding types (so secrets, + // KV, DO, service bindings, etc. are preserved from the prior version). + metadata := map[string]interface{}{ + "main_module": mainModule, + "bindings": bindings, + "keep_bindings": []string{ + "secret_text", + "secret_key", + "kv_namespace", + "durable_object_namespace", + "r2_bucket", + "service", + "queue", + "d1", + "vectorize", + "hyperdrive", + "ai", + "browser", + "mtls_certificate", + "send_email", + "version_metadata", + }, + } + + metadataJSON, err := json.Marshal(metadata) + if err != nil { + return "", fmt.Errorf("marshaling metadata: %w", err) + } + + // Build multipart form: metadata part + module parts. + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + // Metadata part. + metaHeader := textproto.MIMEHeader{} + metaHeader.Set("Content-Disposition", `form-data; name="metadata"`) + metaHeader.Set("Content-Type", "application/json") + metaPart, err := writer.CreatePart(metaHeader) + if err != nil { + return "", fmt.Errorf("creating metadata part: %w", err) + } + if _, err := metaPart.Write(metadataJSON); err != nil { + return "", fmt.Errorf("writing metadata: %w", err) + } + + // Module parts — re-upload bytes fetched from the API. + for _, mod := range modules { + // Sanitize module name before embedding in Content-Disposition + // to prevent header injection via crafted module names. + if !moduleNamePattern.MatchString(mod.name) { + return "", fmt.Errorf("module name %q contains invalid characters; allowed: [a-zA-Z0-9._-]", mod.name) + } + partHeader := textproto.MIMEHeader{} + partHeader.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, mod.name, mod.name)) + partHeader.Set("Content-Type", mod.contentType) + modPart, partErr := writer.CreatePart(partHeader) + if partErr != nil { + return "", fmt.Errorf("creating module part %s: %w", mod.name, partErr) + } + if _, err := modPart.Write(mod.data); err != nil { + return "", fmt.Errorf("writing module %s: %w", mod.name, err) + } + } + + if err := writer.Close(); err != nil { + return "", fmt.Errorf("closing multipart writer: %w", err) + } + + // POST to versions endpoint. + apiURL := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/workers/scripts/%s/versions", accountID, workerName) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, &body) + if err != nil { + return "", fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("calling versions API: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxAPIResponseBytes)) + if err != nil { + return "", fmt.Errorf("reading response: %w", err) + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return "", fmt.Errorf("versions API returned %d: %s", resp.StatusCode, string(respBody)) + } + + // Parse version ID from response. + var versionResp struct { + Result struct { + ID string `json:"id"` + } `json:"result"` + Success bool `json:"success"` + } + if err := json.Unmarshal(respBody, &versionResp); err != nil { + return "", fmt.Errorf("parsing version response: %w", err) + } + if !versionResp.Success || versionResp.Result.ID == "" { + return "", fmt.Errorf("versions API returned success=%v, id=%q: %s", versionResp.Success, versionResp.Result.ID, string(respBody)) + } + + return versionResp.Result.ID, nil +} + +// deployVersionFn deploys a Worker version to 100% traffic via the +// Cloudflare Deployments API. Override in tests. +var deployVersionFn = deployVersion + +// deployVersion deploys a Worker version to 100% traffic via +// POST /accounts/{account}/workers/scripts/{name}/deployments. +func deployVersion(ctx context.Context, accountID, workerName, token, versionID string) error { + payload := map[string]interface{}{ + "versions": []map[string]interface{}{ + { + "version_id": versionID, + "percentage": 100, + }, + }, + } + payloadJSON, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshaling deployment payload: %w", err) + } + + apiURL := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/workers/scripts/%s/deployments", accountID, workerName) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(payloadJSON)) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("calling deployments API: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxAPIResponseBytes)) + if err != nil { + return fmt.Errorf("reading response: %w", err) + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return fmt.Errorf("deployments API returned %d: %s", resp.StatusCode, string(body)) + } + + return nil +} + +// ResolveCloudflareAPITokenFn resolves a Cloudflare API bearer token. +// Prefers CLOUDFLARE_API_TOKEN env var; falls back to `npx wrangler +// auth token` (wrangler ≥ 4.57) for OAuth sessions from `wrangler login`. +// Override in tests. +var ResolveCloudflareAPITokenFn = resolveCloudflareAPIToken + +func resolveCloudflareAPIToken(ctx context.Context) (string, error) { + token := os.Getenv("CLOUDFLARE_API_TOKEN") + if token != "" { + return token, nil + } + // Try wrangler auth token (OAuth sessions from `wrangler login`). + // Use Output (not CombinedOutput) to capture only stdout — stderr + // carries banner/diagnostic noise that would corrupt the token. + cmd := exec.CommandContext(ctx, "npx", "wrangler", "auth", "token") + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("CLOUDFLARE_API_TOKEN not set and 'wrangler auth token' failed: %w; set CLOUDFLARE_API_TOKEN or run 'wrangler login'", err) + } + // Wrangler may print banner/info lines before the actual token. + // Extract the last non-empty line, which is the token value. + resolved := lastNonEmptyLine(string(out)) + if resolved == "" { + return "", fmt.Errorf("'wrangler auth token' returned empty token; run 'wrangler login'") + } + return resolved, nil +} + +// lastNonEmptyLine returns the last non-empty, trimmed line from s. +// Used to extract the actual token/value from CLI output that may +// include banner or diagnostic lines before the value. +func lastNonEmptyLine(s string) string { + var last string + for line := range strings.SplitSeq(s, "\n") { + if trimmed := strings.TrimSpace(line); trimmed != "" { + last = trimmed + } + } + return last +} + +// GetWorkerVarsFn is the function used to read Worker vars via the +// Cloudflare API. Override in tests to avoid real API calls. +var GetWorkerVarsFn = getWorkerVars + +// getWorkerVars calls the Cloudflare API to read a Worker's settings +// and extracts plain_text variable bindings. Supports both API-token +// auth (CLOUDFLARE_API_TOKEN) and Wrangler OAuth sessions. +func getWorkerVars(ctx context.Context, accountID, workerName string) (map[string]string, error) { + token, err := ResolveCloudflareAPITokenFn(ctx) + if err != nil { + return nil, fmt.Errorf("resolving API token: %w", err) + } + + url := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/workers/scripts/%s/settings", accountID, workerName) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("calling Cloudflare Workers settings API: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxAPIResponseBytes)) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("Cloudflare Workers settings API returned %d: %s", resp.StatusCode, string(body)) + } + + return parseWorkerSettingsVars(body) +} + +// parseWorkerSettingsVars extracts plain_text variable bindings from the +// Cloudflare Workers settings API response. +func parseWorkerSettingsVars(body []byte) (map[string]string, error) { + var response struct { + Result struct { + Bindings []struct { + Type string `json:"type"` + Name string `json:"name"` + Text string `json:"text"` + } `json:"bindings"` + } `json:"result"` + Success bool `json:"success"` + } + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("parsing settings response: %w", err) + } + if !response.Success { + return nil, fmt.Errorf("Cloudflare Workers settings API returned success=false: %s", string(body)) + } + + vars := make(map[string]string) + for _, b := range response.Result.Bindings { + if b.Type == "plain_text" { + vars[b.Name] = b.Text + } + } + return vars, nil +} + // PEMSecretsFromRoles converts a role-keyed PEM map (e.g. "coder" → PEM data) // into a Cloudflare secret-name-keyed map (e.g. "CODER_APP_PEM" → PEM data) // suitable for passing as Config.Secrets during deploy. @@ -1125,7 +1835,7 @@ func resolveSubdomainViaAPI(ctx context.Context, accountID, token string) (strin } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(io.LimitReader(resp.Body, maxAPIResponseBytes)) if err != nil { return "", fmt.Errorf("reading response: %w", err) } diff --git a/internal/dispatch/cf/provisioner_test.go b/internal/dispatch/cf/provisioner_test.go index 6fd15245b7..b4da8b5eb4 100644 --- a/internal/dispatch/cf/provisioner_test.go +++ b/internal/dispatch/cf/provisioner_test.go @@ -1,9 +1,16 @@ package cf import ( + "bytes" "context" + "encoding/json" "fmt" "io/fs" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/textproto" + "net/url" "os" "path/filepath" "testing" @@ -31,6 +38,23 @@ type fakeWranglerRunner struct { workerExists *bool // workerExistsErr, if non-nil, is returned by WorkerExists. workerExistsErr error + // workerVars holds the vars returned by GetVars. + workerVars map[string]string + // getVarsErr, if non-nil, is returned by GetVars. + getVarsErr error + // hasPreviewVersions controls the return value of HasPreviewVersions. + hasPreviewVersions bool + // hasPreviewVersionsErr, if non-nil, is returned by HasPreviewVersions. + hasPreviewVersionsErr error + // updateVarsCalls tracks calls to UpdateVars. + updateVarsCalls []updateVarsCall + // updateVarsErr, if non-nil, is returned by UpdateVars. + updateVarsErr error +} + +type updateVarsCall struct { + workerName string + vars map[string]string } type deployCall struct { @@ -107,6 +131,31 @@ func (f *fakeWranglerRunner) WorkerExists(_ context.Context, _ string) (bool, er return true, nil // default: Worker exists } +func (f *fakeWranglerRunner) GetVars(_ context.Context, _ string) (map[string]string, error) { + if f.getVarsErr != nil { + return nil, f.getVarsErr + } + if f.workerVars != nil { + return f.workerVars, nil + } + return make(map[string]string), nil +} + +func (f *fakeWranglerRunner) HasPreviewVersions(_ context.Context, _ string) (bool, error) { + if f.hasPreviewVersionsErr != nil { + return false, f.hasPreviewVersionsErr + } + return f.hasPreviewVersions, nil +} + +func (f *fakeWranglerRunner) UpdateVars(_ context.Context, workerName string, vars map[string]string) error { + f.updateVarsCalls = append(f.updateVarsCalls, updateVarsCall{ + workerName: workerName, + vars: vars, + }) + return f.updateVarsErr +} + // --- Provisioner tests --- func TestProvisioner_Name(t *testing.T) { @@ -2154,3 +2203,1314 @@ func stubWASMBuild(t *testing.T) { CopyWASMExecFn = origCopy }) } + +// --- Enroll / Unenroll tests --- + +func TestEnsureOrgInWorker_AddsOrg(t *testing.T) { + fake := &fakeWranglerRunner{ + workerVars: map[string]string{"ALLOWED_ORGS": "existing-org"}, + } + p := NewProvisioner(Config{ + AccountID: "test-account", + WorkerName: "test-mint", + }, fake) + + err := p.EnsureOrgInWorker(context.Background(), "new-org") + require.NoError(t, err) + require.Len(t, fake.updateVarsCalls, 1) + assert.Equal(t, "existing-org,new-org", fake.updateVarsCalls[0].vars["ALLOWED_ORGS"]) + assert.Equal(t, "test-mint", fake.updateVarsCalls[0].workerName) + assert.Empty(t, fake.deployCalls, "enroll should not call Deploy") +} + +func TestEnsureOrgInWorker_AlreadyEnrolled(t *testing.T) { + fake := &fakeWranglerRunner{ + workerVars: map[string]string{"ALLOWED_ORGS": "acme,other"}, + } + p := NewProvisioner(Config{ + AccountID: "test-account", + WorkerName: "test-mint", + }, fake) + + err := p.EnsureOrgInWorker(context.Background(), "ACME") + require.NoError(t, err) + assert.Empty(t, fake.updateVarsCalls, "should not update vars when org already enrolled") +} + +func TestEnsureOrgInWorker_PublicMode(t *testing.T) { + fake := &fakeWranglerRunner{ + workerVars: map[string]string{"PER_REPO_WIF_REPOS": "*"}, + } + p := NewProvisioner(Config{ + AccountID: "test-account", + WorkerName: "test-mint", + }, fake) + + err := p.EnsureOrgInWorker(context.Background(), "acme") + require.NoError(t, err) + assert.Empty(t, fake.updateVarsCalls, "should not update vars in public mode") +} + +func TestEnsureOrgInWorker_EmptyAllowedOrgs(t *testing.T) { + fake := &fakeWranglerRunner{ + workerVars: map[string]string{}, + } + p := NewProvisioner(Config{ + AccountID: "test-account", + WorkerName: "test-mint", + }, fake) + + err := p.EnsureOrgInWorker(context.Background(), "acme") + require.NoError(t, err) + require.Len(t, fake.updateVarsCalls, 1) + assert.Equal(t, "acme", fake.updateVarsCalls[0].vars["ALLOWED_ORGS"]) +} + +func TestRemoveOrgFromWorker_RemovesOrg(t *testing.T) { + fake := &fakeWranglerRunner{ + workerVars: map[string]string{"ALLOWED_ORGS": "acme,other"}, + } + p := NewProvisioner(Config{ + AccountID: "test-account", + WorkerName: "test-mint", + }, fake) + + err := p.RemoveOrgFromWorker(context.Background(), "acme") + require.NoError(t, err) + require.Len(t, fake.updateVarsCalls, 1) + assert.Equal(t, "other", fake.updateVarsCalls[0].vars["ALLOWED_ORGS"]) +} + +func TestRemoveOrgFromWorker_PublicMode(t *testing.T) { + fake := &fakeWranglerRunner{ + workerVars: map[string]string{"PER_REPO_WIF_REPOS": "*"}, + } + p := NewProvisioner(Config{ + AccountID: "test-account", + WorkerName: "test-mint", + }, fake) + + err := p.RemoveOrgFromWorker(context.Background(), "acme") + require.Error(t, err) + assert.Contains(t, err.Error(), "public mode") + assert.Contains(t, err.Error(), "PER_REPO_WIF_REPOS=*") +} + +func TestRemoveOrgFromWorker_NotEnrolled(t *testing.T) { + fake := &fakeWranglerRunner{ + workerVars: map[string]string{"ALLOWED_ORGS": "acme,other"}, + } + p := NewProvisioner(Config{ + AccountID: "test-account", + WorkerName: "test-mint", + }, fake) + + err := p.RemoveOrgFromWorker(context.Background(), "missing-org") + require.NoError(t, err) + assert.Empty(t, fake.updateVarsCalls, "should not update vars when org is not enrolled") +} + +func TestRegisterRepoInWorker_AddsRepo(t *testing.T) { + fake := &fakeWranglerRunner{ + workerVars: map[string]string{ + "ALLOWED_ORGS": "existing-org", + "PER_REPO_WIF_REPOS": "", + }, + } + p := NewProvisioner(Config{ + AccountID: "test-account", + WorkerName: "test-mint", + }, fake) + + err := p.RegisterRepoInWorker(context.Background(), "new-org/widget") + require.NoError(t, err) + require.Len(t, fake.updateVarsCalls, 1) + assert.Equal(t, "new-org/widget", fake.updateVarsCalls[0].vars["PER_REPO_WIF_REPOS"]) + // Per-repo enrollment must NOT modify ALLOWED_ORGS. + _, hasAllowedOrgs := fake.updateVarsCalls[0].vars["ALLOWED_ORGS"] + assert.False(t, hasAllowedOrgs, "per-repo enrollment should not modify ALLOWED_ORGS") +} + +func TestRegisterRepoInWorker_DoesNotModifyAllowedOrgs(t *testing.T) { + fake := &fakeWranglerRunner{ + workerVars: map[string]string{ + "ALLOWED_ORGS": "acme", + "PER_REPO_WIF_REPOS": "", + }, + } + p := NewProvisioner(Config{ + AccountID: "test-account", + WorkerName: "test-mint", + }, fake) + + err := p.RegisterRepoInWorker(context.Background(), "acme/widget") + require.NoError(t, err) + require.Len(t, fake.updateVarsCalls, 1) + assert.Equal(t, "acme/widget", fake.updateVarsCalls[0].vars["PER_REPO_WIF_REPOS"]) + // Per-repo enrollment must NOT modify ALLOWED_ORGS — it is independent + // of org-level enrollment on both GCP and Cloudflare. + _, hasAllowedOrgs := fake.updateVarsCalls[0].vars["ALLOWED_ORGS"] + assert.False(t, hasAllowedOrgs, "per-repo enrollment should not modify ALLOWED_ORGS") +} + +func TestRegisterRepoInWorker_AlreadyEnrolled(t *testing.T) { + fake := &fakeWranglerRunner{ + workerVars: map[string]string{ + "ALLOWED_ORGS": "acme", + "PER_REPO_WIF_REPOS": "acme/widget", + }, + } + p := NewProvisioner(Config{ + AccountID: "test-account", + WorkerName: "test-mint", + }, fake) + + err := p.RegisterRepoInWorker(context.Background(), "ACME/widget") + require.NoError(t, err) + assert.Empty(t, fake.updateVarsCalls, "should not update vars when repo already enrolled") +} + +func TestRemoveRepoFromWorker_RemovesRepo(t *testing.T) { + fake := &fakeWranglerRunner{ + workerVars: map[string]string{ + "ALLOWED_ORGS": "acme", + "PER_REPO_WIF_REPOS": "acme/widget,acme/other", + }, + } + p := NewProvisioner(Config{ + AccountID: "test-account", + WorkerName: "test-mint", + }, fake) + + err := p.RemoveRepoFromWorker(context.Background(), "acme/widget") + require.NoError(t, err) + require.Len(t, fake.updateVarsCalls, 1) + assert.Equal(t, "acme/other", fake.updateVarsCalls[0].vars["PER_REPO_WIF_REPOS"]) +} + +func TestRemoveRepoFromWorker_NotEnrolled(t *testing.T) { + fake := &fakeWranglerRunner{ + workerVars: map[string]string{ + "ALLOWED_ORGS": "acme", + "PER_REPO_WIF_REPOS": "acme/other", + }, + } + p := NewProvisioner(Config{ + AccountID: "test-account", + WorkerName: "test-mint", + }, fake) + + err := p.RemoveRepoFromWorker(context.Background(), "acme/missing-repo") + require.NoError(t, err) + assert.Empty(t, fake.updateVarsCalls, "should not update vars when repo is not enrolled") +} + +func TestRemoveRepoFromWorker_PublicMode(t *testing.T) { + fake := &fakeWranglerRunner{ + workerVars: map[string]string{"PER_REPO_WIF_REPOS": "*"}, + } + p := NewProvisioner(Config{ + AccountID: "test-account", + WorkerName: "test-mint", + }, fake) + + err := p.RemoveRepoFromWorker(context.Background(), "acme/widget") + require.Error(t, err) + assert.Contains(t, err.Error(), "public mode") + assert.Contains(t, err.Error(), "PER_REPO_WIF_REPOS=*") +} + +func TestParseWorkerSettingsVars(t *testing.T) { + body := []byte(`{ + "result": { + "bindings": [ + {"type": "plain_text", "name": "ALLOWED_ORGS", "text": "acme,other"}, + {"type": "plain_text", "name": "PER_REPO_WIF_REPOS", "text": "acme/widget"}, + {"type": "secret_text", "name": "CODER_APP_PEM"} + ] + }, + "success": true + }`) + + vars, err := parseWorkerSettingsVars(body) + require.NoError(t, err) + assert.Equal(t, "acme,other", vars["ALLOWED_ORGS"]) + assert.Equal(t, "acme/widget", vars["PER_REPO_WIF_REPOS"]) + _, hasSecret := vars["CODER_APP_PEM"] + assert.False(t, hasSecret, "secret bindings should be excluded") +} + +func TestParseWorkerSettingsVars_FailureResponse(t *testing.T) { + body := []byte(`{"result": {}, "success": false}`) + _, err := parseWorkerSettingsVars(body) + require.Error(t, err) + assert.Contains(t, err.Error(), "success=false") +} + +func TestParseHasPreviewVersions(t *testing.T) { + // No preview versions. + assert.False(t, parseHasPreviewVersions("Version ID Created\nabc123 2026-01-01")) + // Has preview versions. + assert.True(t, parseHasPreviewVersions("Version ID Created Preview\nabc123 2026-01-01 my-preview")) +} + +func TestCheckPreviewVersions(t *testing.T) { + fake := &fakeWranglerRunner{hasPreviewVersions: true} + p := NewProvisioner(Config{ + AccountID: "test-account", + WorkerName: "test-mint", + }, fake) + + has, err := p.CheckPreviewVersions(context.Background()) + require.NoError(t, err) + assert.True(t, has) +} + +// --- HTTP transport interception helper --- + +// testRoundTripper implements http.RoundTripper via a function. +type testRoundTripper func(*http.Request) (*http.Response, error) + +func (f testRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +// withHTTPIntercept replaces http.DefaultTransport with one that routes +// all requests to the test server, preserving the original URL path. +// Tests using this must not run in parallel. +func withHTTPIntercept(t *testing.T, handler http.Handler) { + t.Helper() + ts := httptest.NewServer(handler) + t.Cleanup(ts.Close) + + tsURL, err := url.Parse(ts.URL) + require.NoError(t, err) + + origTransport := http.DefaultTransport + http.DefaultTransport = testRoundTripper(func(req *http.Request) (*http.Response, error) { + req2 := req.Clone(req.Context()) + req2.URL.Scheme = tsURL.Scheme + req2.URL.Host = tsURL.Host + return (&http.Transport{}).RoundTrip(req2) + }) + t.Cleanup(func() { http.DefaultTransport = origTransport }) +} + +// --- fetchWorkerContent tests --- + +func TestFetchWorkerContent_SingleModule(t *testing.T) { + withHTTPIntercept(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + assert.Contains(t, r.URL.Path, "/content/v2") + w.Header().Set("Content-Type", "application/javascript") + w.Header().Set("cf-entrypoint", "index.js") + fmt.Fprint(w, "console.log('hello')") + })) + + modules, main, err := fetchWorkerContent(context.Background(), "acc-id", "my-worker", "test-token") + require.NoError(t, err) + assert.Equal(t, "index.js", main) + require.Len(t, modules, 1) + assert.Equal(t, "index.js", modules[0].name) + assert.Equal(t, "application/javascript", modules[0].contentType) + assert.Equal(t, []byte("console.log('hello')"), modules[0].data) +} + +func TestFetchWorkerContent_SingleModule_NoEntrypoint(t *testing.T) { + withHTTPIntercept(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/javascript") + // No cf-entrypoint header — should default to "index.js". + fmt.Fprint(w, "export default {}") + })) + + modules, main, err := fetchWorkerContent(context.Background(), "acc-id", "my-worker", "test-token") + require.NoError(t, err) + assert.Equal(t, "index.js", main) + require.Len(t, modules, 1) + assert.Equal(t, "index.js", modules[0].name) +} + +func TestFetchWorkerContent_Multipart(t *testing.T) { + withHTTPIntercept(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + + // Part 1: JS module. + h1 := textproto.MIMEHeader{} + h1.Set("Content-Disposition", `form-data; name="index.js"; filename="index.js"`) + h1.Set("Content-Type", "application/javascript") + p1, _ := mw.CreatePart(h1) + fmt.Fprint(p1, "export default {}") + + // Part 2: WASM module (no Content-Type — should default). + h2 := textproto.MIMEHeader{} + h2.Set("Content-Disposition", `form-data; name="module.wasm"; filename="module.wasm"`) + p2, _ := mw.CreatePart(h2) + p2.Write([]byte{0x00, 0x61, 0x73, 0x6d}) + + mw.Close() + + w.Header().Set("Content-Type", mw.FormDataContentType()) + w.Header().Set("cf-entrypoint", "index.js") + w.Write(buf.Bytes()) + })) + + modules, main, err := fetchWorkerContent(context.Background(), "acc-id", "my-worker", "test-token") + require.NoError(t, err) + assert.Equal(t, "index.js", main) + require.Len(t, modules, 2) + assert.Equal(t, "index.js", modules[0].name) + assert.Equal(t, "application/javascript", modules[0].contentType) + assert.Equal(t, "module.wasm", modules[1].name) +} + +func TestFetchWorkerContent_ErrorStatus(t *testing.T) { + withHTTPIntercept(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, "not found") + })) + + _, _, err := fetchWorkerContent(context.Background(), "acc-id", "my-worker", "test-token") + require.Error(t, err) + assert.Contains(t, err.Error(), "content API returned 404") +} + +func TestFetchWorkerContent_CancelledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, _, err := fetchWorkerContent(ctx, "acc-id", "my-worker", "test-token") + require.Error(t, err) +} + +func TestFetchWorkerContent_SingleModuleTruncation(t *testing.T) { + // When the single-module body exceeds maxWorkerModuleBytes, + // fetchWorkerContent should return an error instead of silently + // uploading truncated content. + origMax := maxWorkerModuleBytes + // Use a small limit for testing. + defer func() { maxWorkerModuleBytes = origMax }() + maxWorkerModuleBytes = 10 + + withHTTPIntercept(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/javascript") + w.Header().Set("cf-entrypoint", "index.js") + // Write more than the 10-byte limit. + fmt.Fprint(w, "this content is definitely longer than ten bytes") + })) + + _, _, err := fetchWorkerContent(context.Background(), "acc-id", "my-worker", "test-token") + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds") + assert.Contains(t, err.Error(), "truncated") +} + +func TestFetchWorkerContent_MultipartTruncation(t *testing.T) { + // When a multipart module part exceeds maxWorkerModuleBytes, + // fetchWorkerContent should return an error. + origMax := maxWorkerModuleBytes + defer func() { maxWorkerModuleBytes = origMax }() + maxWorkerModuleBytes = 10 + + withHTTPIntercept(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + + h1 := textproto.MIMEHeader{} + h1.Set("Content-Disposition", `form-data; name="module.wasm"; filename="module.wasm"`) + h1.Set("Content-Type", "application/wasm") + p1, _ := mw.CreatePart(h1) + // Write more than the 10-byte limit. + p1.Write(bytes.Repeat([]byte{0x00}, 20)) + + mw.Close() + + w.Header().Set("Content-Type", mw.FormDataContentType()) + w.Header().Set("cf-entrypoint", "index.js") + w.Write(buf.Bytes()) + })) + + _, _, err := fetchWorkerContent(context.Background(), "acc-id", "my-worker", "test-token") + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds") + assert.Contains(t, err.Error(), "truncated") +} + +// --- createVersionWithVars tests --- + +func TestCreateVersionWithVars_Success(t *testing.T) { + withHTTPIntercept(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Contains(t, r.URL.Path, "/versions") + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + assert.Contains(t, r.Header.Get("Content-Type"), "multipart/form-data") + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "result": map[string]string{"id": "version-abc123"}, + "success": true, + }) + })) + + modules := []workerModule{ + {name: "index.js", contentType: "application/javascript", data: []byte("export default {}")}, + } + vars := map[string]string{"ALLOWED_ORGS": "acme"} + + id, err := createVersionWithVars(context.Background(), "acc-id", "my-worker", "test-token", modules, "index.js", vars) + require.NoError(t, err) + assert.Equal(t, "version-abc123", id) +} + +func TestCreateVersionWithVars_MultipleModules(t *testing.T) { + withHTTPIntercept(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]interface{}{ + "result": map[string]string{"id": "version-multi"}, + "success": true, + }) + })) + + modules := []workerModule{ + {name: "index.js", contentType: "application/javascript", data: []byte("code")}, + {name: "module.wasm", contentType: "application/wasm", data: []byte("wasmdata")}, + } + vars := map[string]string{"KEY": "value"} + + id, err := createVersionWithVars(context.Background(), "acc-id", "my-worker", "test-token", modules, "index.js", vars) + require.NoError(t, err) + assert.Equal(t, "version-multi", id) +} + +func TestCreateVersionWithVars_ErrorStatus(t *testing.T) { + withHTTPIntercept(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, "bad request") + })) + + modules := []workerModule{ + {name: "index.js", contentType: "application/javascript", data: []byte("code")}, + } + + _, err := createVersionWithVars(context.Background(), "acc-id", "my-worker", "test-token", modules, "index.js", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "versions API returned 400") +} + +func TestCreateVersionWithVars_FailureResponse(t *testing.T) { + withHTTPIntercept(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "result": map[string]string{"id": ""}, + "success": false, + }) + })) + + modules := []workerModule{ + {name: "index.js", contentType: "application/javascript", data: []byte("code")}, + } + + _, err := createVersionWithVars(context.Background(), "acc-id", "my-worker", "test-token", modules, "index.js", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "success=false") +} + +func TestCreateVersionWithVars_InvalidModuleName(t *testing.T) { + // createVersionWithVars should reject module names containing + // characters outside [a-zA-Z0-9._-] to prevent header injection. + modules := []workerModule{ + {name: "index.js", contentType: "application/javascript", data: []byte("ok")}, + {name: `evil"; evil="x`, contentType: "application/javascript", data: []byte("bad")}, + } + + _, err := createVersionWithVars(context.Background(), "acc-id", "my-worker", "test-token", modules, "index.js", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid characters") +} + +// --- deployVersion tests --- + +func TestDeployVersion_Success(t *testing.T) { + withHTTPIntercept(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Contains(t, r.URL.Path, "/deployments") + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + + var payload map[string]interface{} + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + versions, ok := payload["versions"].([]interface{}) + require.True(t, ok) + require.Len(t, versions, 1) + + w.WriteHeader(http.StatusOK) + })) + + err := deployVersion(context.Background(), "acc-id", "my-worker", "test-token", "version-123") + require.NoError(t, err) +} + +func TestDeployVersion_ErrorStatus(t *testing.T) { + withHTTPIntercept(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, "internal error") + })) + + err := deployVersion(context.Background(), "acc-id", "my-worker", "test-token", "version-123") + require.Error(t, err) + assert.Contains(t, err.Error(), "deployments API returned 500") +} + +func TestDeployVersion_CancelledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := deployVersion(ctx, "acc-id", "my-worker", "test-token", "version-123") + require.Error(t, err) +} + +// --- resolveSubdomainViaAPI tests --- + +func TestResolveSubdomainViaAPI_Success(t *testing.T) { + withHTTPIntercept(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Contains(t, r.URL.Path, "/subdomain") + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + json.NewEncoder(w).Encode(map[string]interface{}{ + "result": map[string]string{"subdomain": "my-sub"}, + "success": true, + }) + })) + + sub, err := resolveSubdomainViaAPI(context.Background(), "acc-id", "test-token") + require.NoError(t, err) + assert.Equal(t, "my-sub", sub) +} + +func TestResolveSubdomainViaAPI_ErrorStatus(t *testing.T) { + withHTTPIntercept(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + fmt.Fprint(w, "forbidden") + })) + + _, err := resolveSubdomainViaAPI(context.Background(), "acc-id", "test-token") + require.Error(t, err) + assert.Contains(t, err.Error(), "returned 403") +} + +func TestResolveSubdomainViaAPI_EmptySubdomain(t *testing.T) { + withHTTPIntercept(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]interface{}{ + "result": map[string]string{"subdomain": ""}, + "success": true, + }) + })) + + _, err := resolveSubdomainViaAPI(context.Background(), "acc-id", "test-token") + require.Error(t, err) + assert.Contains(t, err.Error(), "empty subdomain") +} + +func TestResolveSubdomainViaAPI_CancelledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := resolveSubdomainViaAPI(ctx, "acc-id", "test-token") + require.Error(t, err) +} + +// --- getWorkerVars (actual implementation) tests --- + +func TestGetWorkerVars_Implementation_Success(t *testing.T) { + origToken := ResolveCloudflareAPITokenFn + ResolveCloudflareAPITokenFn = func(_ context.Context) (string, error) { + return "test-token", nil + } + t.Cleanup(func() { ResolveCloudflareAPITokenFn = origToken }) + + withHTTPIntercept(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Contains(t, r.URL.Path, "/settings") + json.NewEncoder(w).Encode(map[string]interface{}{ + "result": map[string]interface{}{ + "bindings": []map[string]string{ + {"type": "plain_text", "name": "ALLOWED_ORGS", "text": "acme,other"}, + {"type": "plain_text", "name": "PER_REPO_WIF_REPOS", "text": "acme/widget"}, + {"type": "secret_text", "name": "CODER_APP_PEM"}, + }, + }, + "success": true, + }) + })) + + vars, err := getWorkerVars(context.Background(), "acc-id", "my-worker") + require.NoError(t, err) + assert.Equal(t, "acme,other", vars["ALLOWED_ORGS"]) + assert.Equal(t, "acme/widget", vars["PER_REPO_WIF_REPOS"]) + _, hasSecret := vars["CODER_APP_PEM"] + assert.False(t, hasSecret, "secret bindings should be excluded") +} + +func TestGetWorkerVars_Implementation_TokenError(t *testing.T) { + origToken := ResolveCloudflareAPITokenFn + ResolveCloudflareAPITokenFn = func(_ context.Context) (string, error) { + return "", fmt.Errorf("no token") + } + t.Cleanup(func() { ResolveCloudflareAPITokenFn = origToken }) + + _, err := getWorkerVars(context.Background(), "acc-id", "my-worker") + require.Error(t, err) + assert.Contains(t, err.Error(), "resolving API token") +} + +func TestGetWorkerVars_Implementation_HTTPError(t *testing.T) { + origToken := ResolveCloudflareAPITokenFn + ResolveCloudflareAPITokenFn = func(_ context.Context) (string, error) { + return "test-token", nil + } + t.Cleanup(func() { ResolveCloudflareAPITokenFn = origToken }) + + withHTTPIntercept(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, "server error") + })) + + _, err := getWorkerVars(context.Background(), "acc-id", "my-worker") + require.Error(t, err) + assert.Contains(t, err.Error(), "returned 500") +} + +// --- resolveCloudflareAPIToken tests --- + +func TestResolveCloudflareAPIToken_FromEnv(t *testing.T) { + withCFEnvCleared(t) + os.Setenv("CLOUDFLARE_API_TOKEN", "env-token") + + token, err := resolveCloudflareAPIToken(context.Background()) + require.NoError(t, err) + assert.Equal(t, "env-token", token) +} + +func TestResolveCloudflareAPIToken_FallbackError(t *testing.T) { + withCFEnvCleared(t) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := resolveCloudflareAPIToken(ctx) + require.Error(t, err) + assert.Contains(t, err.Error(), "CLOUDFLARE_API_TOKEN not set") +} + +// --- resolveWorkersSubdomain tests --- + +func TestResolveWorkersSubdomain_WithAPIToken(t *testing.T) { + withCFEnvCleared(t) + os.Setenv("CLOUDFLARE_API_TOKEN", "test-token") + + withHTTPIntercept(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]interface{}{ + "result": map[string]string{"subdomain": "my-subdomain"}, + "success": true, + }) + })) + + sub, err := resolveWorkersSubdomain(context.Background(), "acc-id") + require.NoError(t, err) + assert.Equal(t, "my-subdomain", sub) +} + +func TestResolveWorkersSubdomain_FallbackExecError(t *testing.T) { + withCFEnvCleared(t) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := resolveWorkersSubdomain(ctx, "acc-id") + require.Error(t, err) + assert.Contains(t, err.Error(), "wrangler subdomain failed") +} + +// --- resolveSubdomainViaWrangler tests --- + +func TestResolveSubdomainViaWrangler_ExecError(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := resolveSubdomainViaWrangler(ctx) + require.Error(t, err) + assert.Contains(t, err.Error(), "wrangler subdomain failed") +} + +// --- Provisioner.GetWorkerVars tests --- + +func TestProvisioner_GetWorkerVars_Success(t *testing.T) { + fake := &fakeWranglerRunner{ + workerVars: map[string]string{"ALLOWED_ORGS": "acme", "PER_REPO_WIF_REPOS": "acme/widget"}, + } + p := NewProvisioner(Config{ + AccountID: "test-account", + WorkerName: "test-mint", + }, fake) + + vars, err := p.GetWorkerVars(context.Background()) + require.NoError(t, err) + assert.Equal(t, "acme", vars["ALLOWED_ORGS"]) + assert.Equal(t, "acme/widget", vars["PER_REPO_WIF_REPOS"]) +} + +func TestProvisioner_GetWorkerVars_Error(t *testing.T) { + fake := &fakeWranglerRunner{ + getVarsErr: fmt.Errorf("API error"), + } + p := NewProvisioner(Config{ + AccountID: "test-account", + WorkerName: "test-mint", + }, fake) + + _, err := p.GetWorkerVars(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "API error") +} + +// --- LiveWranglerRunner.GetVars tests --- + +func TestLiveWranglerRunner_GetVars_Success(t *testing.T) { + orig := GetWorkerVarsFn + GetWorkerVarsFn = func(_ context.Context, accountID, workerName string) (map[string]string, error) { + assert.Equal(t, "test-account", accountID) + assert.Equal(t, "test-worker", workerName) + return map[string]string{"KEY": "value"}, nil + } + t.Cleanup(func() { GetWorkerVarsFn = orig }) + + runner := &LiveWranglerRunner{AccountID: "test-account"} + vars, err := runner.GetVars(context.Background(), "test-worker") + require.NoError(t, err) + assert.Equal(t, "value", vars["KEY"]) +} + +func TestLiveWranglerRunner_GetVars_Error(t *testing.T) { + orig := GetWorkerVarsFn + GetWorkerVarsFn = func(_ context.Context, _, _ string) (map[string]string, error) { + return nil, fmt.Errorf("API error") + } + t.Cleanup(func() { GetWorkerVarsFn = orig }) + + runner := &LiveWranglerRunner{AccountID: "test-account"} + _, err := runner.GetVars(context.Background(), "test-worker") + require.Error(t, err) + assert.Contains(t, err.Error(), "API error") +} + +// --- LiveWranglerRunner.HasPreviewVersions tests --- + +func TestLiveWranglerRunner_HasPreviewVersions_CommandError(t *testing.T) { + runner := &LiveWranglerRunner{AccountID: "test-account"} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := runner.HasPreviewVersions(ctx, "test-worker") + require.Error(t, err) + assert.Contains(t, err.Error(), "listing worker versions") +} + +// --- LiveWranglerRunner.UpdateVars tests --- + +func TestLiveWranglerRunner_UpdateVars_Success(t *testing.T) { + // Override token resolution. + origToken := ResolveCloudflareAPITokenFn + ResolveCloudflareAPITokenFn = func(_ context.Context) (string, error) { + return "test-token", nil + } + t.Cleanup(func() { ResolveCloudflareAPITokenFn = origToken }) + + // Override GetWorkerVarsFn. + origGetVars := GetWorkerVarsFn + GetWorkerVarsFn = func(_ context.Context, _, _ string) (map[string]string, error) { + return map[string]string{"EXISTING": "keep"}, nil + } + t.Cleanup(func() { GetWorkerVarsFn = origGetVars }) + + // Override deployVersionFn. + origDeploy := deployVersionFn + deployVersionFn = func(_ context.Context, _, _, _, versionID string) error { + assert.Equal(t, "version-new", versionID) + return nil + } + t.Cleanup(func() { deployVersionFn = origDeploy }) + + // Track API calls: fetchWorkerContent + createVersionWithVars + callCount := 0 + withHTTPIntercept(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + if r.Method == http.MethodGet && r.URL.Path != "" { + // fetchWorkerContent call — return single-module response. + w.Header().Set("Content-Type", "application/javascript") + w.Header().Set("cf-entrypoint", "index.js") + fmt.Fprint(w, "module code") + return + } + // createVersionWithVars call — return success. + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "result": map[string]string{"id": "version-new"}, + "success": true, + }) + })) + + runner := &LiveWranglerRunner{AccountID: "test-account"} + err := runner.UpdateVars(context.Background(), "test-worker", map[string]string{"NEW_VAR": "new-value"}) + require.NoError(t, err) + assert.GreaterOrEqual(t, callCount, 2, "should make at least 2 HTTP calls") +} + +func TestLiveWranglerRunner_UpdateVars_TokenError(t *testing.T) { + origToken := ResolveCloudflareAPITokenFn + ResolveCloudflareAPITokenFn = func(_ context.Context) (string, error) { + return "", fmt.Errorf("no token available") + } + t.Cleanup(func() { ResolveCloudflareAPITokenFn = origToken }) + + runner := &LiveWranglerRunner{AccountID: "test-account"} + err := runner.UpdateVars(context.Background(), "test-worker", map[string]string{"K": "V"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "resolving API token") +} + +func TestLiveWranglerRunner_UpdateVars_FetchContentError(t *testing.T) { + origToken := ResolveCloudflareAPITokenFn + ResolveCloudflareAPITokenFn = func(_ context.Context) (string, error) { + return "test-token", nil + } + t.Cleanup(func() { ResolveCloudflareAPITokenFn = origToken }) + + withHTTPIntercept(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, "worker not found") + })) + + runner := &LiveWranglerRunner{AccountID: "test-account"} + err := runner.UpdateVars(context.Background(), "test-worker", map[string]string{"K": "V"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "fetching worker content") +} + +func TestLiveWranglerRunner_UpdateVars_GetVarsError(t *testing.T) { + origToken := ResolveCloudflareAPITokenFn + ResolveCloudflareAPITokenFn = func(_ context.Context) (string, error) { + return "test-token", nil + } + t.Cleanup(func() { ResolveCloudflareAPITokenFn = origToken }) + + origGetVars := GetWorkerVarsFn + GetWorkerVarsFn = func(_ context.Context, _, _ string) (map[string]string, error) { + return nil, fmt.Errorf("settings fetch error") + } + t.Cleanup(func() { GetWorkerVarsFn = origGetVars }) + + withHTTPIntercept(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // fetchWorkerContent succeeds. + w.Header().Set("Content-Type", "application/javascript") + w.Header().Set("cf-entrypoint", "index.js") + fmt.Fprint(w, "code") + })) + + runner := &LiveWranglerRunner{AccountID: "test-account"} + err := runner.UpdateVars(context.Background(), "test-worker", map[string]string{"K": "V"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "reading current vars") +} + +func TestLiveWranglerRunner_UpdateVars_DeployError(t *testing.T) { + origToken := ResolveCloudflareAPITokenFn + ResolveCloudflareAPITokenFn = func(_ context.Context) (string, error) { + return "test-token", nil + } + t.Cleanup(func() { ResolveCloudflareAPITokenFn = origToken }) + + origGetVars := GetWorkerVarsFn + GetWorkerVarsFn = func(_ context.Context, _, _ string) (map[string]string, error) { + return map[string]string{}, nil + } + t.Cleanup(func() { GetWorkerVarsFn = origGetVars }) + + origDeploy := deployVersionFn + deployVersionFn = func(_ context.Context, _, _, _, _ string) error { + return fmt.Errorf("deploy failed") + } + t.Cleanup(func() { deployVersionFn = origDeploy }) + + withHTTPIntercept(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/javascript") + w.Header().Set("cf-entrypoint", "index.js") + fmt.Fprint(w, "code") + return + } + // createVersionWithVars succeeds. + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "result": map[string]string{"id": "version-x"}, + "success": true, + }) + })) + + runner := &LiveWranglerRunner{AccountID: "test-account"} + err := runner.UpdateVars(context.Background(), "test-worker", map[string]string{"K": "V"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "deploying version") +} + +// --- Enroll/Unenroll error path tests --- + +func TestEnsureOrgInWorker_GetVarsError(t *testing.T) { + fake := &fakeWranglerRunner{ + getVarsErr: fmt.Errorf("API error"), + } + p := NewProvisioner(Config{ + AccountID: "test-account", + WorkerName: "test-mint", + }, fake) + + err := p.EnsureOrgInWorker(context.Background(), "acme") + require.Error(t, err) + assert.Contains(t, err.Error(), "reading worker vars") +} + +func TestRemoveOrgFromWorker_GetVarsError(t *testing.T) { + fake := &fakeWranglerRunner{ + getVarsErr: fmt.Errorf("API error"), + } + p := NewProvisioner(Config{ + AccountID: "test-account", + WorkerName: "test-mint", + }, fake) + + err := p.RemoveOrgFromWorker(context.Background(), "acme") + require.Error(t, err) + assert.Contains(t, err.Error(), "reading worker vars") +} + +func TestRegisterRepoInWorker_GetVarsError(t *testing.T) { + fake := &fakeWranglerRunner{ + getVarsErr: fmt.Errorf("API error"), + } + p := NewProvisioner(Config{ + AccountID: "test-account", + WorkerName: "test-mint", + }, fake) + + err := p.RegisterRepoInWorker(context.Background(), "acme/widget") + require.Error(t, err) + assert.Contains(t, err.Error(), "reading worker vars") +} + +func TestRegisterRepoInWorker_PublicMode(t *testing.T) { + fake := &fakeWranglerRunner{ + workerVars: map[string]string{"PER_REPO_WIF_REPOS": "*"}, + } + p := NewProvisioner(Config{ + AccountID: "test-account", + WorkerName: "test-mint", + }, fake) + + err := p.RegisterRepoInWorker(context.Background(), "acme/widget") + require.NoError(t, err) + // Should be a no-op in public mode. + assert.Empty(t, fake.updateVarsCalls) +} + +func TestRemoveRepoFromWorker_GetVarsError(t *testing.T) { + fake := &fakeWranglerRunner{ + getVarsErr: fmt.Errorf("API error"), + } + p := NewProvisioner(Config{ + AccountID: "test-account", + WorkerName: "test-mint", + }, fake) + + err := p.RemoveRepoFromWorker(context.Background(), "acme/widget") + require.Error(t, err) + assert.Contains(t, err.Error(), "reading worker vars") +} + +// --- findRepoRoot tests --- + +func TestFindRepoRoot(t *testing.T) { + root := findRepoRoot() + // Should find a directory containing go.mod with the fullsend module. + goMod := filepath.Join(root, "go.mod") + data, err := os.ReadFile(goMod) + require.NoError(t, err) + assert.Contains(t, string(data), "github.com/fullsend-ai/fullsend") +} + +// --- parseWorkerSettingsVars additional tests --- + +func TestParseWorkerSettingsVars_InvalidJSON(t *testing.T) { + _, err := parseWorkerSettingsVars([]byte("not-json")) + require.Error(t, err) + assert.Contains(t, err.Error(), "parsing settings response") +} + +func TestParseWorkerSettingsVars_NoBindings(t *testing.T) { + body := []byte(`{"result": {"bindings": []}, "success": true}`) + vars, err := parseWorkerSettingsVars(body) + require.NoError(t, err) + assert.Empty(t, vars) +} + +// --- parseHasPreviewVersions additional tests --- + +func TestParseHasPreviewVersions_EmptyOutput(t *testing.T) { + assert.False(t, parseHasPreviewVersions("")) +} + +func TestParseHasPreviewVersions_HeaderOnly(t *testing.T) { + output := "┌──────────┬──────────┐\n" + + "│ Version ID │ Created │\n" + + "├──────────┼──────────┤\n" + + "└──────────┴──────────┘\n" + assert.False(t, parseHasPreviewVersions(output)) +} + +// --- Teardown unknown deploy mode --- + +func TestProvisioner_Teardown_UnknownDeployMode(t *testing.T) { + fake := &fakeWranglerRunner{} + p := &Provisioner{ + cfg: Config{ + AccountID: "abc123", + WorkerName: "test-mint", + DeployMode: DeployMode(99), // unknown mode + }, + wrangler: fake, + } + + err := p.Teardown(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown deploy mode") +} + +// --- LiveWranglerRunner.Deploy preview with secrets --- + +func TestLiveWranglerRunner_Deploy_PreviewWithSecretsCommandError(t *testing.T) { + dir := t.TempDir() + runner := &LiveWranglerRunner{AccountID: "test-account"} + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + secrets := map[string][]byte{"MY_SECRET": []byte("value")} + _, err := runner.Deploy(ctx, dir, "test-worker", "bt-alias", nil, secrets) + require.Error(t, err) + assert.Contains(t, err.Error(), "wrangler versions upload failed") +} + +// --- ResolveCloudflareAuth additional tests --- + +func TestResolveCloudflareAuth_WranglerFailed_WithAccountIDSet(t *testing.T) { + withCFEnvCleared(t) + os.Setenv("CLOUDFLARE_ACCOUNT_ID", "env-account") + + old := WranglerWhoamiFn + WranglerWhoamiFn = func(_ context.Context) (string, error) { + return "", fmt.Errorf("exec failed") + } + t.Cleanup(func() { WranglerWhoamiFn = old }) + + _, err := ResolveCloudflareAuth(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "wrangler whoami") +} + +// --- EnsureOrgInWorker/RemoveOrgFromWorker UpdateVars error --- + +func TestEnsureOrgInWorker_UpdateVarsError(t *testing.T) { + fake := &fakeWranglerRunner{ + workerVars: map[string]string{"ALLOWED_ORGS": "existing"}, + updateVarsErr: fmt.Errorf("update failed"), + } + p := NewProvisioner(Config{ + AccountID: "test-account", + WorkerName: "test-mint", + }, fake) + + err := p.EnsureOrgInWorker(context.Background(), "new-org") + require.Error(t, err) + assert.Contains(t, err.Error(), "update failed") +} + +func TestRemoveOrgFromWorker_UpdateVarsError(t *testing.T) { + fake := &fakeWranglerRunner{ + workerVars: map[string]string{"ALLOWED_ORGS": "acme,other"}, + updateVarsErr: fmt.Errorf("update failed"), + } + p := NewProvisioner(Config{ + AccountID: "test-account", + WorkerName: "test-mint", + }, fake) + + err := p.RemoveOrgFromWorker(context.Background(), "acme") + require.Error(t, err) + assert.Contains(t, err.Error(), "update failed") +} + +func TestRegisterRepoInWorker_UpdateVarsError(t *testing.T) { + fake := &fakeWranglerRunner{ + workerVars: map[string]string{"ALLOWED_ORGS": "acme"}, + updateVarsErr: fmt.Errorf("update failed"), + } + p := NewProvisioner(Config{ + AccountID: "test-account", + WorkerName: "test-mint", + }, fake) + + err := p.RegisterRepoInWorker(context.Background(), "acme/widget") + require.Error(t, err) + assert.Contains(t, err.Error(), "update failed") +} + +func TestRemoveRepoFromWorker_UpdateVarsError(t *testing.T) { + fake := &fakeWranglerRunner{ + workerVars: map[string]string{ + "ALLOWED_ORGS": "acme", + "PER_REPO_WIF_REPOS": "acme/widget", + }, + updateVarsErr: fmt.Errorf("update failed"), + } + p := NewProvisioner(Config{ + AccountID: "test-account", + WorkerName: "test-mint", + }, fake) + + err := p.RemoveRepoFromWorker(context.Background(), "acme/widget") + require.Error(t, err) + assert.Contains(t, err.Error(), "update failed") +} + +// --- Regression: deploy --public then per-repo enroll must not corrupt PER_REPO_WIF_REPOS --- + +func TestRegisterRepoInWorker_PublicMode_DoesNotAppend(t *testing.T) { + // Regression test: when the mint is deployed with --public + // (PER_REPO_WIF_REPOS=*), calling RegisterRepoInWorker must NOT + // produce PER_REPO_WIF_REPOS=*,owner/repo. It must be a clean no-op. + fake := &fakeWranglerRunner{ + workerVars: map[string]string{ + "PER_REPO_WIF_REPOS": "*", + }, + } + p := NewProvisioner(Config{ + AccountID: "test-account", + WorkerName: "test-mint", + }, fake) + + err := p.RegisterRepoInWorker(context.Background(), "owner/repo") + require.NoError(t, err) + assert.Empty(t, fake.updateVarsCalls, + "public mode repo enroll must be a no-op — must not append to PER_REPO_WIF_REPOS=*") +} + +func TestRemoveRepoFromWorker_PublicMode_DoesNotModify(t *testing.T) { + // When the mint is public (PER_REPO_WIF_REPOS=*), unenroll must + // return an error — not silently modify PER_REPO_WIF_REPOS. + fake := &fakeWranglerRunner{ + workerVars: map[string]string{ + "PER_REPO_WIF_REPOS": "*", + }, + } + p := NewProvisioner(Config{ + AccountID: "test-account", + WorkerName: "test-mint", + }, fake) + + err := p.RemoveRepoFromWorker(context.Background(), "owner/repo") + require.Error(t, err) + assert.Contains(t, err.Error(), "PER_REPO_WIF_REPOS=*") + assert.Empty(t, fake.updateVarsCalls, + "public mode repo unenroll must not modify vars") +} + +// --- Org enroll/unenroll on public CF mint does not consult ALLOWED_ORGS --- + +func TestEnsureOrgInWorker_PublicMintRepos_NotAllowedOrgs(t *testing.T) { + // On a public CF mint (PER_REPO_WIF_REPOS=*), org enroll is a no-op + // regardless of what ALLOWED_ORGS contains. In particular, ALLOWED_ORGS + // is NOT set to "*" on CF public deploys — only PER_REPO_WIF_REPOS is. + fake := &fakeWranglerRunner{ + workerVars: map[string]string{ + "ALLOWED_ORGS": "", // no ALLOWED_ORGS=* on CF public + "PER_REPO_WIF_REPOS": "*", + }, + } + p := NewProvisioner(Config{ + AccountID: "test-account", + WorkerName: "test-mint", + }, fake) + + err := p.EnsureOrgInWorker(context.Background(), "new-org") + require.NoError(t, err) + assert.Empty(t, fake.updateVarsCalls, "org enroll should be no-op on public CF mint") +} + +func TestRemoveOrgFromWorker_PublicMintRepos_NotAllowedOrgs(t *testing.T) { + // On a public CF mint (PER_REPO_WIF_REPOS=*), org unenroll returns + // an error citing PER_REPO_WIF_REPOS=*, not ALLOWED_ORGS=*. + fake := &fakeWranglerRunner{ + workerVars: map[string]string{ + "ALLOWED_ORGS": "acme", + "PER_REPO_WIF_REPOS": "*", + }, + } + p := NewProvisioner(Config{ + AccountID: "test-account", + WorkerName: "test-mint", + }, fake) + + err := p.RemoveOrgFromWorker(context.Background(), "acme") + require.Error(t, err) + assert.Contains(t, err.Error(), "PER_REPO_WIF_REPOS=*") +} + +// --- parsePerRepoWIFReposMap tests --- + +func TestParsePerRepoWIFReposMap(t *testing.T) { + tests := []struct { + name string + csv string + expect map[string]bool + }{ + {"empty", "", map[string]bool{}}, + {"wildcard", "*", map[string]bool{"*": true}}, + {"single repo", "Acme/Widget", map[string]bool{"acme/widget": true}}, + {"multiple repos", "acme/foo,Other/Bar", map[string]bool{"acme/foo": true, "other/bar": true}}, + {"with spaces", " acme/foo , other/bar ", map[string]bool{"acme/foo": true, "other/bar": true}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := parsePerRepoWIFReposMap(tc.csv) + assert.Equal(t, tc.expect, result) + }) + } +} + +// --- lastNonEmptyLine tests --- + +func TestLastNonEmptyLine(t *testing.T) { + tests := []struct { + name string + input string + expect string + }{ + {"empty", "", ""}, + {"single line", "token-value", "token-value"}, + {"single line with newline", "token-value\n", "token-value"}, + {"banner then token", "⛅️ wrangler 4.57\n\ntoken-value\n", "token-value"}, + {"multiple banner lines", "line1\nline2\nline3\nactual-token\n", "actual-token"}, + {"whitespace lines", " \n\n token \n\n", "token"}, + {"only whitespace", " \n \n", ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expect, lastNonEmptyLine(tc.input)) + }) + } +} From 8f5ee5ee6b006aa1389b97c643b45d5bbcdf0e6c Mon Sep 17 00:00:00 2001 From: fullsend-fix <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:43:28 +0000 Subject: [PATCH 2/3] fix: address review feedback on PR #6137 - Validate repo slug (gcf.ValidateRepoSlug) and reject PlaceholderOrg in runMintEnrollRepoCloudflare and runMintUnenrollRepoCloudflare, matching the GCP repo enroll/unenroll validation - Validate Cloudflare account ID format (^[a-f0-9]{32}$) in ResolveCloudflareAuth before using in API URLs - Sort binding names in createVersionWithVars for deterministic version metadata - Extract shared CF enroll/unenroll preamble into prepareCFEnrollContext helper (defaults effectiveName, constructs wrangler + provisioner, verifies Worker exists, warns on preview versions) - Add CF platform triage step and enrollment subsection to skills/mint-enroll/SKILL.md - Add CF credential footnote to docs/guides/dev/cli-internals.md Command Decomposition table - Add tests for ValidateAccountID, repo slug validation on CF paths, PlaceholderOrg rejection, and deterministic binding order Addresses review feedback on #6137 --- docs/guides/dev/cli-internals.md | 4 +- internal/cli/mint.go | 190 +++++++++-------------- internal/cli/mint_test.go | 67 ++++++-- internal/dispatch/cf/provisioner.go | 37 ++++- internal/dispatch/cf/provisioner_test.go | 89 ++++++++++- skills/mint-enroll/SKILL.md | 42 ++++- 6 files changed, 289 insertions(+), 140 deletions(-) diff --git a/docs/guides/dev/cli-internals.md b/docs/guides/dev/cli-internals.md index fcedb260b3..df6427b72b 100644 --- a/docs/guides/dev/cli-internals.md +++ b/docs/guides/dev/cli-internals.md @@ -131,10 +131,12 @@ The `mint`, `inference`, and `github` subcommands decompose setup into role-spec | Install Phase | Standalone Command | Required Access | |---------------|--------------------|-----------------| | Phases 1-3: Mint deployment | `fullsend mint deploy` | GCP project (mint): `roles/iam.serviceAccountAdmin`, `roles/iam.workloadIdentityPoolAdmin`, `roles/cloudfunctions.developer`, `roles/run.admin`; with `--pem-dir` also `roles/secretmanager.admin`, `roles/resourcemanager.projectIamAdmin` | -| Phases 1-3: Mint enrollment | `fullsend mint enroll` | GCP project (mint): `roles/cloudfunctions.viewer`, `roles/run.admin`, `roles/iam.workloadIdentityPoolAdmin` | +| Phases 1-3: Mint enrollment | `fullsend mint enroll` | GCP project (mint): `roles/cloudfunctions.viewer`, `roles/run.admin`, `roles/iam.workloadIdentityPoolAdmin` ¹ | | Phase 4: WIF provisioning | `fullsend inference provision` | GCP project (inference): `roles/iam.workloadIdentityPoolAdmin`, `roles/resourcemanager.projectIamAdmin` | | Phases 5-7: GitHub setup + enrollment | `fullsend github setup` | GitHub only | +¹ GCP IAM roles apply to `--platform=gcp` (the default). Cloudflare enrollment (`--platform=cloudflare`) requires `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID` (or an active `wrangler login` session via `wrangler auth token`). See [`docs/cli/mint.md`](../../cli/mint.md) and [`mint-administration.md`](../infrastructure/mint-administration.md). + The typical handoff: a GCP admin runs `mint deploy`, `mint enroll`, and `inference provision`, then passes the mint URL and WIF provider resource name to a GitHub maintainer who runs `github setup --mint-url=... --inference-wif-provider=...`. See [Advanced setup](../infrastructure/advanced-setup.md). > **Deprecated:** The `admin install` command is deprecated. Use the diff --git a/internal/cli/mint.go b/internal/cli/mint.go index 9e2f2f35bc..69ad455c69 100644 --- a/internal/cli/mint.go +++ b/internal/cli/mint.go @@ -1808,15 +1808,22 @@ func runMintEnrollCloudflare(ctx context.Context, arg, workerName string, dryRun return runMintEnrollOrgCloudflare(ctx, printer, arg, workerName, accountID, dryRun) } -func runMintEnrollOrgCloudflare(ctx context.Context, printer *ui.Printer, org, workerName, accountID string, dryRun bool) error { - org = strings.ToLower(org) - if err := validateOrgName(org); err != nil { - return err - } - - printer.Header("Enrolling org " + org + " in mint (Cloudflare)") - printer.Blank() +// cfEnrollContext holds the resolved CF Worker context shared by all +// enroll/unenroll Cloudflare functions. It bundles the effective Worker +// name, provisioner, and wrangler so callers don't repeat the preamble. +type cfEnrollContext struct { + effectiveName string + provisioner *cf.Provisioner + wrangler cf.WranglerRunner +} +// prepareCFEnrollContext defaults the Worker name, constructs the +// wrangler + provisioner, verifies the Worker exists, and warns about +// preview versions. The operation string (e.g., "enroll", "unenroll") +// is used in the preview-version warning message. notFoundMsg is the +// error returned when the Worker does not exist (enroll and unenroll +// use different messages). +func prepareCFEnrollContext(ctx context.Context, printer *ui.Printer, workerName, accountID, operation, notFoundMsg string) (*cfEnrollContext, error) { effectiveName := workerName if effectiveName == "" { effectiveName = "fullsend-mint" @@ -1833,11 +1840,11 @@ func runMintEnrollOrgCloudflare(ctx context.Context, printer *ui.Printer, org, w exists, err := wrangler.WorkerExists(ctx, effectiveName) if err != nil { printer.StepFail("Worker check failed") - return fmt.Errorf("checking worker: %w", err) + return nil, fmt.Errorf("checking worker: %w", err) } if !exists { printer.StepFail("Worker not found") - return fmt.Errorf("Worker %s not found — deploy with 'mint deploy --platform=cloudflare' first", effectiveName) + return nil, fmt.Errorf(notFoundMsg, effectiveName) } printer.StepDone(fmt.Sprintf("Worker %s found", effectiveName)) @@ -1847,19 +1854,41 @@ func runMintEnrollOrgCloudflare(ctx context.Context, printer *ui.Printer, org, w printer.StepWarn(fmt.Sprintf("Could not check for preview versions: %v", previewErr)) } else if hasPreviews { printer.StepWarn("Preview versions exist on this Worker — durable config changes may diverge from previews") - printer.StepInfo("Previews use their own config from 'mint deploy --preview'. This enroll updates the durable Worker only.") + printer.StepInfo(fmt.Sprintf("Previews use their own config from 'mint deploy --preview'. This %s updates the durable Worker only.", operation)) + } + + return &cfEnrollContext{ + effectiveName: effectiveName, + provisioner: provisioner, + wrangler: wrangler, + }, nil +} + +func runMintEnrollOrgCloudflare(ctx context.Context, printer *ui.Printer, org, workerName, accountID string, dryRun bool) error { + org = strings.ToLower(org) + if err := validateOrgName(org); err != nil { + return err + } + + printer.Header("Enrolling org " + org + " in mint (Cloudflare)") + printer.Blank() + + cfc, err := prepareCFEnrollContext(ctx, printer, workerName, accountID, "enroll", + "Worker %s not found — deploy with 'mint deploy --platform=cloudflare' first") + if err != nil { + return err } if dryRun { printer.Blank() printer.StepInfo("Dry run — no changes will be made") printer.Blank() - printer.StepInfo(fmt.Sprintf(" Would add %s to ALLOWED_ORGS on Worker %s", org, effectiveName)) + printer.StepInfo(fmt.Sprintf(" Would add %s to ALLOWED_ORGS on Worker %s", org, cfc.effectiveName)) return nil } printer.StepStart("Registering org in Worker env vars") - if err := provisioner.EnsureOrgInWorker(ctx, org); err != nil { + if err := cfc.provisioner.EnsureOrgInWorker(ctx, org); err != nil { printer.StepFail("Failed to register org") return fmt.Errorf("registering org: %w", err) } @@ -1868,7 +1897,7 @@ func runMintEnrollOrgCloudflare(ctx context.Context, printer *ui.Printer, org, w printer.Blank() printer.Summary("Enrollment complete", []string{ fmt.Sprintf("Organization: %s", org), - fmt.Sprintf("Worker: %s", effectiveName), + fmt.Sprintf("Worker: %s", cfc.effectiveName), "ALLOWED_ORGS updated on durable Worker", }) @@ -1881,57 +1910,36 @@ func runMintEnrollRepoCloudflare(ctx context.Context, printer *ui.Printer, repoF if len(parts) != 2 || parts[0] == "" || parts[1] == "" { return fmt.Errorf("repo must be in owner/repo format, got %q", repoFullName) } - owner := parts[0] + owner, repo := parts[0], parts[1] if err := validateOrgName(owner); err != nil { return fmt.Errorf("invalid owner: %w", err) } + if owner == gcf.PlaceholderOrg { + return fmt.Errorf("cannot enroll reserved placeholder org %q", owner) + } + if !gcf.ValidateRepoSlug(repo) { + return fmt.Errorf("invalid repo name: %q", repo) + } printer.Header("Enrolling repo " + repoFullName + " in mint (Cloudflare)") printer.Blank() - effectiveName := workerName - if effectiveName == "" { - effectiveName = "fullsend-mint" - } - - wrangler := mintCFWranglerFactory(accountID) - provisioner := cf.NewProvisioner(cf.Config{ - AccountID: accountID, - WorkerName: effectiveName, - }, wrangler) - - // Verify Worker exists. - printer.StepStart("Verifying Worker exists") - exists, err := wrangler.WorkerExists(ctx, effectiveName) + cfc, err := prepareCFEnrollContext(ctx, printer, workerName, accountID, "enroll", + "Worker %s not found — deploy with 'mint deploy --platform=cloudflare' first") if err != nil { - printer.StepFail("Worker check failed") - return fmt.Errorf("checking worker: %w", err) - } - if !exists { - printer.StepFail("Worker not found") - return fmt.Errorf("Worker %s not found — deploy with 'mint deploy --platform=cloudflare' first", effectiveName) - } - printer.StepDone(fmt.Sprintf("Worker %s found", effectiveName)) - - // Warn about preview versions. - hasPreviews, previewErr := provisioner.CheckPreviewVersions(ctx) - if previewErr != nil { - printer.StepWarn(fmt.Sprintf("Could not check for preview versions: %v", previewErr)) - } else if hasPreviews { - printer.StepWarn("Preview versions exist on this Worker — durable config changes may diverge from previews") - printer.StepInfo("Previews use their own config from 'mint deploy --preview'. This enroll updates the durable Worker only.") + return err } if dryRun { printer.Blank() printer.StepInfo("Dry run — no changes will be made") printer.Blank() - printer.StepInfo(fmt.Sprintf(" Would add %s to PER_REPO_WIF_REPOS on Worker %s", repoFullName, effectiveName)) + printer.StepInfo(fmt.Sprintf(" Would add %s to PER_REPO_WIF_REPOS on Worker %s", repoFullName, cfc.effectiveName)) return nil } printer.StepStart("Registering repo in Worker env vars") - if err := provisioner.RegisterRepoInWorker(ctx, repoFullName); err != nil { + if err := cfc.provisioner.RegisterRepoInWorker(ctx, repoFullName); err != nil { printer.StepFail("Failed to register repo") return fmt.Errorf("registering repo: %w", err) } @@ -1940,7 +1948,7 @@ func runMintEnrollRepoCloudflare(ctx context.Context, printer *ui.Printer, repoF printer.Blank() printer.Summary("Enrollment complete", []string{ fmt.Sprintf("Repository: %s", repoFullName), - fmt.Sprintf("Worker: %s", effectiveName), + fmt.Sprintf("Worker: %s", cfc.effectiveName), "PER_REPO_WIF_REPOS updated on durable Worker", }) @@ -1977,44 +1985,17 @@ func runMintUnenrollOrgCloudflare(ctx context.Context, printer *ui.Printer, org, printer.Header("Unenrolling org " + org + " from mint (Cloudflare)") printer.Blank() - effectiveName := workerName - if effectiveName == "" { - effectiveName = "fullsend-mint" - } - - wrangler := mintCFWranglerFactory(accountID) - provisioner := cf.NewProvisioner(cf.Config{ - AccountID: accountID, - WorkerName: effectiveName, - }, wrangler) - - // Verify Worker exists. - printer.StepStart("Verifying Worker exists") - exists, err := wrangler.WorkerExists(ctx, effectiveName) + cfc, err := prepareCFEnrollContext(ctx, printer, workerName, accountID, "unenroll", + "Worker %s not found — nothing to unenroll") if err != nil { - printer.StepFail("Worker check failed") - return fmt.Errorf("checking worker: %w", err) - } - if !exists { - printer.StepFail("Worker not found") - return fmt.Errorf("Worker %s not found — nothing to unenroll", effectiveName) - } - printer.StepDone(fmt.Sprintf("Worker %s found", effectiveName)) - - // Warn about preview versions. - hasPreviews, previewErr := provisioner.CheckPreviewVersions(ctx) - if previewErr != nil { - printer.StepWarn(fmt.Sprintf("Could not check for preview versions: %v", previewErr)) - } else if hasPreviews { - printer.StepWarn("Preview versions exist on this Worker — durable config changes may diverge from previews") - printer.StepInfo("Previews use their own config from 'mint deploy --preview'. This unenroll updates the durable Worker only.") + return err } if dryRun { printer.Blank() printer.StepInfo("Dry run — no changes will be made") printer.Blank() - printer.StepInfo(fmt.Sprintf(" Would remove %s from ALLOWED_ORGS on Worker %s", org, effectiveName)) + printer.StepInfo(fmt.Sprintf(" Would remove %s from ALLOWED_ORGS on Worker %s", org, cfc.effectiveName)) return nil } @@ -2029,7 +2010,7 @@ func runMintUnenrollOrgCloudflare(ctx context.Context, printer *ui.Printer, org, } printer.StepStart("Removing org from Worker env vars") - if err := provisioner.RemoveOrgFromWorker(ctx, org); err != nil { + if err := cfc.provisioner.RemoveOrgFromWorker(ctx, org); err != nil { printer.StepFail("Failed to remove org") return fmt.Errorf("removing org: %w", err) } @@ -2038,7 +2019,7 @@ func runMintUnenrollOrgCloudflare(ctx context.Context, printer *ui.Printer, org, printer.Blank() printer.Summary("Unenrollment complete", []string{ fmt.Sprintf("Organization: %s", org), - fmt.Sprintf("Worker: %s", effectiveName), + fmt.Sprintf("Worker: %s", cfc.effectiveName), "ALLOWED_ORGS updated on durable Worker", }) @@ -2051,52 +2032,31 @@ func runMintUnenrollRepoCloudflare(ctx context.Context, printer *ui.Printer, rep if len(parts) != 2 || parts[0] == "" || parts[1] == "" { return fmt.Errorf("repo must be in owner/repo format, got %q", repoFullName) } - owner := parts[0] + owner, repo := parts[0], parts[1] if err := validateOrgName(owner); err != nil { return fmt.Errorf("invalid owner: %w", err) } + if owner == gcf.PlaceholderOrg { + return fmt.Errorf("cannot unenroll reserved placeholder org %q", owner) + } + if !gcf.ValidateRepoSlug(repo) { + return fmt.Errorf("invalid repo name: %q", repo) + } printer.Header("Unenrolling repo " + repoFullName + " from mint (Cloudflare)") printer.Blank() - effectiveName := workerName - if effectiveName == "" { - effectiveName = "fullsend-mint" - } - - wrangler := mintCFWranglerFactory(accountID) - provisioner := cf.NewProvisioner(cf.Config{ - AccountID: accountID, - WorkerName: effectiveName, - }, wrangler) - - // Verify Worker exists. - printer.StepStart("Verifying Worker exists") - exists, err := wrangler.WorkerExists(ctx, effectiveName) + cfc, err := prepareCFEnrollContext(ctx, printer, workerName, accountID, "unenroll", + "Worker %s not found — nothing to unenroll") if err != nil { - printer.StepFail("Worker check failed") - return fmt.Errorf("checking worker: %w", err) - } - if !exists { - printer.StepFail("Worker not found") - return fmt.Errorf("Worker %s not found — nothing to unenroll", effectiveName) - } - printer.StepDone(fmt.Sprintf("Worker %s found", effectiveName)) - - // Warn about preview versions. - hasPreviews, previewErr := provisioner.CheckPreviewVersions(ctx) - if previewErr != nil { - printer.StepWarn(fmt.Sprintf("Could not check for preview versions: %v", previewErr)) - } else if hasPreviews { - printer.StepWarn("Preview versions exist on this Worker — durable config changes may diverge from previews") - printer.StepInfo("Previews use their own config from 'mint deploy --preview'. This unenroll updates the durable Worker only.") + return err } if dryRun { printer.Blank() printer.StepInfo("Dry run — no changes will be made") printer.Blank() - printer.StepInfo(fmt.Sprintf(" Would remove %s from PER_REPO_WIF_REPOS on Worker %s", repoFullName, effectiveName)) + printer.StepInfo(fmt.Sprintf(" Would remove %s from PER_REPO_WIF_REPOS on Worker %s", repoFullName, cfc.effectiveName)) return nil } @@ -2111,7 +2071,7 @@ func runMintUnenrollRepoCloudflare(ctx context.Context, printer *ui.Printer, rep } printer.StepStart("Removing repo from Worker env vars") - if err := provisioner.RemoveRepoFromWorker(ctx, repoFullName); err != nil { + if err := cfc.provisioner.RemoveRepoFromWorker(ctx, repoFullName); err != nil { printer.StepFail("Failed to remove repo") return fmt.Errorf("removing repo: %w", err) } @@ -2120,7 +2080,7 @@ func runMintUnenrollRepoCloudflare(ctx context.Context, printer *ui.Printer, rep printer.Blank() printer.Summary("Unenrollment complete", []string{ fmt.Sprintf("Repository: %s", repoFullName), - fmt.Sprintf("Worker: %s", effectiveName), + fmt.Sprintf("Worker: %s", cfc.effectiveName), "PER_REPO_WIF_REPOS updated on durable Worker", }) diff --git a/internal/cli/mint_test.go b/internal/cli/mint_test.go index 3e2e43ca71..27ad43f006 100644 --- a/internal/cli/mint_test.go +++ b/internal/cli/mint_test.go @@ -483,7 +483,7 @@ func TestMintDeployCmd_CloudflareInvalidWorkerName(t *testing.T) { os.Setenv("CLOUDFLARE_API_TOKEN", origToken) }() - os.Setenv("CLOUDFLARE_ACCOUNT_ID", "test-account") + os.Setenv("CLOUDFLARE_ACCOUNT_ID", testAccountID) os.Setenv("CLOUDFLARE_API_TOKEN", "test-token") cmd := newRootCmd() @@ -501,7 +501,7 @@ func TestMintDeployCmd_CloudflareDryRun(t *testing.T) { os.Setenv("CLOUDFLARE_API_TOKEN", origToken) }() - os.Setenv("CLOUDFLARE_ACCOUNT_ID", "test-account") + os.Setenv("CLOUDFLARE_ACCOUNT_ID", testAccountID) os.Setenv("CLOUDFLARE_API_TOKEN", "test-token") cmd := newRootCmd() @@ -596,7 +596,7 @@ func TestMintDeployCmd_CloudflareDryRunPreview(t *testing.T) { os.Setenv("CLOUDFLARE_API_TOKEN", origToken) }() - os.Setenv("CLOUDFLARE_ACCOUNT_ID", "test-account") + os.Setenv("CLOUDFLARE_ACCOUNT_ID", testAccountID) os.Setenv("CLOUDFLARE_API_TOKEN", "test-token") cmd := newRootCmd() @@ -613,7 +613,7 @@ func TestMintDeployCmd_CloudflareDryRunPreviewInvalidAlias(t *testing.T) { os.Setenv("CLOUDFLARE_API_TOKEN", origToken) }() - os.Setenv("CLOUDFLARE_ACCOUNT_ID", "test-account") + os.Setenv("CLOUDFLARE_ACCOUNT_ID", testAccountID) os.Setenv("CLOUDFLARE_API_TOKEN", "test-token") cmd := newRootCmd() @@ -670,11 +670,14 @@ func withFakeWASMBuild(t *testing.T) { // withCFEnvVars sets the required Cloudflare env vars and restores them // after the test. +// testAccountID is a valid 32-hex-char Cloudflare account ID for tests. +const testAccountID = "aabbccddee11223344556677aabbccdd" + func withCFEnvVars(t *testing.T) { t.Helper() origAccount := os.Getenv("CLOUDFLARE_ACCOUNT_ID") origToken := os.Getenv("CLOUDFLARE_API_TOKEN") - os.Setenv("CLOUDFLARE_ACCOUNT_ID", "test-account") + os.Setenv("CLOUDFLARE_ACCOUNT_ID", testAccountID) os.Setenv("CLOUDFLARE_API_TOKEN", "test-token") t.Cleanup(func() { os.Setenv("CLOUDFLARE_ACCOUNT_ID", origAccount) @@ -2173,7 +2176,7 @@ func TestMintDeployCmd_CloudflareWranglerSession(t *testing.T) { // wrangler session is active and CLOUDFLARE_ACCOUNT_ID is set. origAccount := os.Getenv("CLOUDFLARE_ACCOUNT_ID") origToken := os.Getenv("CLOUDFLARE_API_TOKEN") - os.Setenv("CLOUDFLARE_ACCOUNT_ID", "test-account") + os.Setenv("CLOUDFLARE_ACCOUNT_ID", testAccountID) os.Unsetenv("CLOUDFLARE_API_TOKEN") t.Cleanup(func() { os.Setenv("CLOUDFLARE_ACCOUNT_ID", origAccount) @@ -2267,7 +2270,7 @@ func TestMintDeployCmd_WarnsGCPFlagsOnCloudflare(t *testing.T) { os.Setenv("CLOUDFLARE_API_TOKEN", origToken) }() - os.Setenv("CLOUDFLARE_ACCOUNT_ID", "test-account") + os.Setenv("CLOUDFLARE_ACCOUNT_ID", testAccountID) os.Setenv("CLOUDFLARE_API_TOKEN", "test-token") // Capture stderr to check warnings. @@ -2798,7 +2801,7 @@ func TestMintDeleteCloudflare_DryRunDurable(t *testing.T) { os.Setenv("CLOUDFLARE_ACCOUNT_ID", origAccount) os.Setenv("CLOUDFLARE_API_TOKEN", origToken) }() - os.Setenv("CLOUDFLARE_ACCOUNT_ID", "test-account") + os.Setenv("CLOUDFLARE_ACCOUNT_ID", testAccountID) os.Setenv("CLOUDFLARE_API_TOKEN", "test-token") err := runMintDeleteCloudflare(context.Background(), "test-mint", "", "", true, false, os.Stdin) @@ -2818,7 +2821,7 @@ func TestMintDeleteCloudflare_DurableTeardown(t *testing.T) { os.Setenv("CLOUDFLARE_ACCOUNT_ID", origAccount) os.Setenv("CLOUDFLARE_API_TOKEN", origToken) }() - os.Setenv("CLOUDFLARE_ACCOUNT_ID", "test-account") + os.Setenv("CLOUDFLARE_ACCOUNT_ID", testAccountID) os.Setenv("CLOUDFLARE_API_TOKEN", "test-token") err := runMintDeleteCloudflare(context.Background(), "test-mint", "", "", false, true, os.Stdin) @@ -2840,7 +2843,7 @@ func TestMintDeleteCloudflare_PreviewTeardown(t *testing.T) { os.Setenv("CLOUDFLARE_ACCOUNT_ID", origAccount) os.Setenv("CLOUDFLARE_API_TOKEN", origToken) }() - os.Setenv("CLOUDFLARE_ACCOUNT_ID", "test-account") + os.Setenv("CLOUDFLARE_ACCOUNT_ID", testAccountID) os.Setenv("CLOUDFLARE_API_TOKEN", "test-token") err := runMintDeleteCloudflare(context.Background(), "test-mint", "bt-run-42", "", false, true, os.Stdin) @@ -2864,7 +2867,7 @@ func TestMintDeleteCloudflare_DurableWithCustomDomainSummary(t *testing.T) { os.Setenv("CLOUDFLARE_ACCOUNT_ID", origAccount) os.Setenv("CLOUDFLARE_API_TOKEN", origToken) }() - os.Setenv("CLOUDFLARE_ACCOUNT_ID", "test-account") + os.Setenv("CLOUDFLARE_ACCOUNT_ID", testAccountID) os.Setenv("CLOUDFLARE_API_TOKEN", "test-token") // Dry run verifies the flag is wired through. @@ -3134,7 +3137,7 @@ func TestMintDeleteCloudflare_InvalidWorkerName(t *testing.T) { os.Setenv("CLOUDFLARE_ACCOUNT_ID", origAccount) os.Setenv("CLOUDFLARE_API_TOKEN", origToken) }() - os.Setenv("CLOUDFLARE_ACCOUNT_ID", "test-account") + os.Setenv("CLOUDFLARE_ACCOUNT_ID", testAccountID) os.Setenv("CLOUDFLARE_API_TOKEN", "test-token") err := runMintDeleteCloudflare(context.Background(), "INVALID_NAME!", "", "", false, true, os.Stdin) @@ -3149,7 +3152,7 @@ func TestMintDeleteCloudflare_InvalidPreviewAlias(t *testing.T) { os.Setenv("CLOUDFLARE_ACCOUNT_ID", origAccount) os.Setenv("CLOUDFLARE_API_TOKEN", origToken) }() - os.Setenv("CLOUDFLARE_ACCOUNT_ID", "test-account") + os.Setenv("CLOUDFLARE_ACCOUNT_ID", testAccountID) os.Setenv("CLOUDFLARE_API_TOKEN", "test-token") err := runMintDeleteCloudflare(context.Background(), "test-mint", "INVALID!", "", false, true, os.Stdin) @@ -3190,7 +3193,7 @@ func TestMintDeleteCloudflare_DryRunPreview(t *testing.T) { os.Setenv("CLOUDFLARE_ACCOUNT_ID", origAccount) os.Setenv("CLOUDFLARE_API_TOKEN", origToken) }() - os.Setenv("CLOUDFLARE_ACCOUNT_ID", "test-account") + os.Setenv("CLOUDFLARE_ACCOUNT_ID", testAccountID) os.Setenv("CLOUDFLARE_API_TOKEN", "test-token") err := runMintDeleteCloudflare(context.Background(), "test-mint", "bt-run-42", "", true, false, os.Stdin) @@ -3211,7 +3214,7 @@ func TestMintDeleteCloudflare_DefaultWorkerName(t *testing.T) { os.Setenv("CLOUDFLARE_ACCOUNT_ID", origAccount) os.Setenv("CLOUDFLARE_API_TOKEN", origToken) }() - os.Setenv("CLOUDFLARE_ACCOUNT_ID", "test-account") + os.Setenv("CLOUDFLARE_ACCOUNT_ID", testAccountID) os.Setenv("CLOUDFLARE_API_TOKEN", "test-token") // Empty worker name should use default "fullsend-mint". @@ -3233,7 +3236,7 @@ func TestMintDeleteCloudflare_ConfirmationRequired(t *testing.T) { os.Setenv("CLOUDFLARE_ACCOUNT_ID", origAccount) os.Setenv("CLOUDFLARE_API_TOKEN", origToken) }() - os.Setenv("CLOUDFLARE_ACCOUNT_ID", "test-account") + os.Setenv("CLOUDFLARE_ACCOUNT_ID", testAccountID) os.Setenv("CLOUDFLARE_API_TOKEN", "test-token") // stdin is not a terminal → should fail without --yolo. @@ -4063,6 +4066,38 @@ func TestRunMintEnrollRepoCloudflare_Success(t *testing.T) { assert.Equal(t, "acme/widget", fake.updateVarsCalls[0].vars["PER_REPO_WIF_REPOS"]) } +func TestRunMintEnrollRepoCloudflare_InvalidRepoSlug(t *testing.T) { + withMintCFWrangler(t, &fakeCFWranglerRunner{}) + printer := ui.New(&strings.Builder{}) + err := runMintEnrollRepoCloudflare(context.Background(), printer, "acme/.invalid", "", "test-account", false) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid repo name") +} + +func TestRunMintEnrollRepoCloudflare_PlaceholderOrg(t *testing.T) { + withMintCFWrangler(t, &fakeCFWranglerRunner{}) + printer := ui.New(&strings.Builder{}) + err := runMintEnrollRepoCloudflare(context.Background(), printer, "x0fullsend0placeholder/repo", "", "test-account", false) + require.Error(t, err) + assert.Contains(t, err.Error(), "reserved placeholder org") +} + +func TestRunMintUnenrollRepoCloudflare_InvalidRepoSlug(t *testing.T) { + withMintCFWrangler(t, &fakeCFWranglerRunner{}) + printer := ui.New(&strings.Builder{}) + err := runMintUnenrollRepoCloudflare(context.Background(), printer, "acme/.bad-repo", "", "test-account", false, true, os.Stdin) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid repo name") +} + +func TestRunMintUnenrollRepoCloudflare_PlaceholderOrg(t *testing.T) { + withMintCFWrangler(t, &fakeCFWranglerRunner{}) + printer := ui.New(&strings.Builder{}) + err := runMintUnenrollRepoCloudflare(context.Background(), printer, "x0fullsend0placeholder/repo", "", "test-account", false, true, os.Stdin) + require.Error(t, err) + assert.Contains(t, err.Error(), "reserved placeholder org") +} + func TestRunMintEnrollOrgCloudflare_WorkerNotFound(t *testing.T) { exists := false withMintCFWrangler(t, &fakeCFWranglerRunner{workerExists: &exists}) diff --git a/internal/dispatch/cf/provisioner.go b/internal/dispatch/cf/provisioner.go index 93b76460f3..c37053e725 100644 --- a/internal/dispatch/cf/provisioner.go +++ b/internal/dispatch/cf/provisioner.go @@ -22,6 +22,7 @@ import ( "os/exec" "path/filepath" "regexp" + "sort" "strings" "github.com/fullsend-ai/fullsend/internal/dispatch" @@ -896,6 +897,17 @@ func DefaultWorkerSourceDir() string { return filepath.Join("internal", "dispatch", "cf", "workersrc") } +// accountIDPattern validates Cloudflare account IDs: exactly 32 +// lowercase hex characters (same format as the whoami parser checks). +var accountIDPattern = regexp.MustCompile(`^[a-f0-9]{32}$`) + +// ValidateAccountID checks if a string is a valid Cloudflare account ID +// (32 lowercase hex characters). This prevents malformed values from +// being interpolated into API URLs. +func ValidateAccountID(id string) bool { + return accountIDPattern.MatchString(id) +} + // ValidateCloudflareEnv checks that required Cloudflare environment // variables are set. Returns an error listing all missing variables. // @@ -948,6 +960,9 @@ func ResolveCloudflareAuth(ctx context.Context) (accountID string, err error) { if envAccountID == "" { return "", fmt.Errorf("CLOUDFLARE_API_TOKEN is set but CLOUDFLARE_ACCOUNT_ID is missing; set both for API-token auth") } + if !ValidateAccountID(envAccountID) { + return "", fmt.Errorf("CLOUDFLARE_ACCOUNT_ID %q is not a valid account ID (expected 32 lowercase hex characters)", envAccountID) + } return envAccountID, nil } @@ -962,6 +977,9 @@ func ResolveCloudflareAuth(ctx context.Context) (accountID string, err error) { // Wrangler session is valid. Resolve account ID. if envAccountID != "" { + if !ValidateAccountID(envAccountID) { + return "", fmt.Errorf("CLOUDFLARE_ACCOUNT_ID %q is not a valid account ID (expected 32 lowercase hex characters)", envAccountID) + } return envAccountID, nil } @@ -973,6 +991,11 @@ func ResolveCloudflareAuth(ctx context.Context) (accountID string, err error) { if parsed == "" { return "", fmt.Errorf("wrangler login session is active but CLOUDFLARE_ACCOUNT_ID is not set and could not be auto-detected from 'wrangler whoami' output; set CLOUDFLARE_ACCOUNT_ID explicitly") } + // whoami parser already validates format (32 hex chars), but + // double-check defensively since parsed values hit API URLs. + if !ValidateAccountID(parsed) { + return "", fmt.Errorf("auto-detected account ID %q from wrangler whoami is not valid (expected 32 lowercase hex characters); set CLOUDFLARE_ACCOUNT_ID explicitly", parsed) + } return parsed, nil } @@ -1431,13 +1454,21 @@ func fetchWorkerContent(ctx context.Context, accountID, workerName, token string // bytes. Only plain_text bindings are specified; all other binding types // are preserved via keep_bindings. func createVersionWithVars(ctx context.Context, accountID, workerName, token string, modules []workerModule, mainModule string, vars map[string]string) (string, error) { - // Build plain_text bindings from the merged vars map. + // Build plain_text bindings from the merged vars map. Sort keys + // for deterministic metadata so version uploads produce stable + // output for debugging and diffing. + sortedKeys := make([]string, 0, len(vars)) + for k := range vars { + sortedKeys = append(sortedKeys, k) + } + sort.Strings(sortedKeys) + var bindings []map[string]string - for k, v := range vars { + for _, k := range sortedKeys { bindings = append(bindings, map[string]string{ "type": "plain_text", "name": k, - "text": v, + "text": vars[k], }) } diff --git a/internal/dispatch/cf/provisioner_test.go b/internal/dispatch/cf/provisioner_test.go index b4da8b5eb4..e686b9f80b 100644 --- a/internal/dispatch/cf/provisioner_test.go +++ b/internal/dispatch/cf/provisioner_test.go @@ -13,6 +13,7 @@ import ( "net/url" "os" "path/filepath" + "sort" "testing" "github.com/stretchr/testify/assert" @@ -868,11 +869,11 @@ func withCFEnvCleared(t *testing.T) { func TestResolveCloudflareAuth_TokenAndAccountID(t *testing.T) { withCFEnvCleared(t) os.Setenv("CLOUDFLARE_API_TOKEN", "my-token") - os.Setenv("CLOUDFLARE_ACCOUNT_ID", "my-account-id") + os.Setenv("CLOUDFLARE_ACCOUNT_ID", "aabbccddee11223344556677aabbccdd") accountID, err := ResolveCloudflareAuth(context.Background()) require.NoError(t, err) - assert.Equal(t, "my-account-id", accountID) + assert.Equal(t, "aabbccddee11223344556677aabbccdd", accountID) } func TestResolveCloudflareAuth_TokenWithoutAccountID(t *testing.T) { @@ -886,7 +887,7 @@ func TestResolveCloudflareAuth_TokenWithoutAccountID(t *testing.T) { func TestResolveCloudflareAuth_WranglerSession_WithAccountEnv(t *testing.T) { withCFEnvCleared(t) - os.Setenv("CLOUDFLARE_ACCOUNT_ID", "env-account-id") + os.Setenv("CLOUDFLARE_ACCOUNT_ID", "11223344556677889900aabbccddeeff") // Mock wrangler whoami to succeed. old := WranglerWhoamiFn @@ -897,7 +898,7 @@ func TestResolveCloudflareAuth_WranglerSession_WithAccountEnv(t *testing.T) { accountID, err := ResolveCloudflareAuth(context.Background()) require.NoError(t, err) - assert.Equal(t, "env-account-id", accountID) + assert.Equal(t, "11223344556677889900aabbccddeeff", accountID) } func TestResolveCloudflareAuth_WranglerSession_DiscoverAccountID(t *testing.T) { @@ -3514,3 +3515,83 @@ func TestLastNonEmptyLine(t *testing.T) { }) } } + +// --- ValidateAccountID tests --- + +func TestValidateAccountID(t *testing.T) { + tests := []struct { + name string + input string + valid bool + }{ + {"valid 32 hex", "aabbccddee11223344556677aabbccdd", true}, + {"valid all digits", "00112233445566778899001122334455", true}, + {"valid all lowercase letters", "aabbccddeeffaabbccddeeffaabbccdd", true}, + {"too short", "aabbccdd", false}, + {"too long", "aabbccddee11223344556677aabbccddee", false}, + {"uppercase hex", "AABBCCDDEE11223344556677AABBCCDD", false}, + {"mixed case", "AAbbccddee11223344556677aabbccdd", false}, + {"non-hex chars", "ghijklmnop11223344556677aabbccdd", false}, + {"empty", "", false}, + {"dashes", "aabb-ccdd-ee11-2233-4455-6677-aabb", false}, + {"spaces", "aabbccddee112233 4556677aabbccdd", false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.valid, ValidateAccountID(tc.input)) + }) + } +} + +func TestResolveCloudflareAuth_InvalidAccountID(t *testing.T) { + withCFEnvCleared(t) + os.Setenv("CLOUDFLARE_API_TOKEN", "my-token") + os.Setenv("CLOUDFLARE_ACCOUNT_ID", "not-valid-hex") + + _, err := ResolveCloudflareAuth(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "not a valid account ID") +} + +func TestResolveCloudflareAuth_InvalidAccountID_WranglerPath(t *testing.T) { + withCFEnvCleared(t) + os.Setenv("CLOUDFLARE_ACCOUNT_ID", "bad-id") + + old := WranglerWhoamiFn + WranglerWhoamiFn = func(ctx context.Context) (string, error) { + return "ℹ️ Logged in\n", nil + } + t.Cleanup(func() { WranglerWhoamiFn = old }) + + _, err := ResolveCloudflareAuth(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "not a valid account ID") +} + +// --- createVersionWithVars binding order test --- + +func TestCreateVersionWithVars_BindingOrderIsDeterministic(t *testing.T) { + // Run the binding-building portion of createVersionWithVars twice + // with the same vars and verify the order is identical. + vars := map[string]string{ + "ZEBRA_VAR": "z", + "ALPHA_VAR": "a", + "MIDDLE_VAR": "m", + "BETA_VAR": "b", + "OIDC_AUDIENCE": "fullsend-mint", + } + + buildBindings := func() []string { + keys := make([]string, 0, len(vars)) + for k := range vars { + keys = append(keys, k) + } + sort.Strings(keys) + return keys + } + + order1 := buildBindings() + order2 := buildBindings() + assert.Equal(t, order1, order2, "binding key order should be deterministic") + assert.Equal(t, []string{"ALPHA_VAR", "BETA_VAR", "MIDDLE_VAR", "OIDC_AUDIENCE", "ZEBRA_VAR"}, order1) +} diff --git a/skills/mint-enroll/SKILL.md b/skills/mint-enroll/SKILL.md index 249b58b65d..79381fa5a4 100644 --- a/skills/mint-enroll/SKILL.md +++ b/skills/mint-enroll/SKILL.md @@ -106,7 +106,26 @@ or by running `go run ./cmd/fullsend admin install`. ### 1. Triage -Determine enrollment type and target. Choose one: +Determine the target platform and enrollment type. + +**Platform (ask the operator):** + +```bash +# GCP (default) — Cloud Function mint +PLATFORM="gcp" + +# Cloudflare — Worker mint +PLATFORM="cloudflare" +``` + +For Cloudflare enrollment, the operator needs `CLOUDFLARE_API_TOKEN` + +`CLOUDFLARE_ACCOUNT_ID` (or an active `wrangler login` session). The +`--worker-name` flag selects the Worker (default: `fullsend-mint`). +See [`docs/cli/mint.md`](../../docs/cli/mint.md) and +[`docs/guides/infrastructure/mint-administration.md`](../../docs/guides/infrastructure/mint-administration.md) +for full Cloudflare credential and enrollment details. + +**Enrollment type and target:** ```bash # Per-org enrollment @@ -155,6 +174,8 @@ healthy and the enrollment target is correct before proceeding. ### 3. Enroll +**GCP enrollment** (`--platform=gcp`, the default): + Preview the enrollment first with `--dry-run`: ```bash @@ -183,6 +204,25 @@ The CLI performs the following automatically: 3. Runs post-enrollment verification 4. Configures WIF provider (shared for per-org, dedicated for per-repo) +**Cloudflare enrollment** (`--platform=cloudflare`): + +```bash +# Dry-run first +go run ./cmd/fullsend mint enroll "$TARGET" \ + --platform=cloudflare \ + --worker-name="fullsend-mint" \ + --dry-run + +# Actual enrollment (after operator confirms) +go run ./cmd/fullsend mint enroll "$TARGET" \ + --platform=cloudflare \ + --worker-name="fullsend-mint" +``` + +The CLI reads the Worker's current env vars via the Cloudflare API, +merges the new org/repo, and redeploys a new version at 100% traffic. +No local Worker sources or WASM artifacts are required. + ### 4. Verify The CLI runs post-enrollment verification automatically. Check its output for: From c1facfe044b9aade2936721db39551ab67d5d225 Mon Sep 17 00:00:00 2001 From: fullsend-fix <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:33:47 +0000 Subject: [PATCH 3/3] fix: include version ID in deploy error and add race-condition docs - Include orphaned version ID in UpdateVars deploy error message for easier debugging when createVersionWithVars succeeds but deploy fails - Add "Enroll serially" / "Unenroll serially" callouts to the Cloudflare sections of docs/cli/mint.md, consistent with mint-administration.md Addresses review feedback on #6137 --- docs/cli/mint.md | 4 ++++ internal/dispatch/cf/provisioner.go | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/cli/mint.md b/docs/cli/mint.md index c4602d5b41..489afccf9f 100644 --- a/docs/cli/mint.md +++ b/docs/cli/mint.md @@ -265,6 +265,8 @@ Updates the durable Worker's `ALLOWED_ORGS` (org mode) or `PER_REPO_WIF_REPOS` ( `--preview` is rejected — preview Workers are configured exclusively via `mint deploy`. +> **Enroll serially.** Concurrent enroll or unenroll commands against the same Worker can race — the CLI uses a read-modify-write cycle without concurrency control. Run them one at a time. See [Enrollment ordering](../guides/infrastructure/mint-administration.md#enrollment-ordering). + ### Flags | Flag | Default | Description | @@ -298,6 +300,8 @@ fullsend mint unenroll \ Removes the org/repo from the durable Worker's env vars via the Cloudflare Versions API — no local Worker sources or WASM build artifacts required. +> **Unenroll serially.** Like enroll, the Cloudflare unenroll path uses a read-modify-write cycle. Do not run concurrent unenroll commands against the same Worker — see [Enrollment ordering](../guides/infrastructure/mint-administration.md#enrollment-ordering). + ### Flags | Flag | Default | Description | diff --git a/internal/dispatch/cf/provisioner.go b/internal/dispatch/cf/provisioner.go index c37053e725..bd2951fc6f 100644 --- a/internal/dispatch/cf/provisioner.go +++ b/internal/dispatch/cf/provisioner.go @@ -1323,7 +1323,7 @@ func (r *LiveWranglerRunner) UpdateVars(ctx context.Context, workerName string, // 5. Deploy the new version to 100% traffic. if err := deployVersionFn(ctx, r.AccountID, workerName, token, versionID); err != nil { - return fmt.Errorf("deploying version: %w", err) + return fmt.Errorf("deploying version %s: %w", versionID, err) } return nil