diff --git a/docs/architecture.md b/docs/architecture.md index 3b2439c495..f4406545f5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,7 +43,7 @@ the dedicated org-level `/.fullsend` config repo is deprecated - Forge abstraction: all forge operations go through the `forge.Client` interface, keeping the rest of the codebase forge-agnostic ([ADR 0005](ADRs/0005-forge-abstraction-layer.md)). - Installation model: ordered layer stack (install forward, uninstall reverse, analyze for status reporting) with idempotent operations. Current stack: config-repo → workflows → vendor-binary → secrets → inference → dispatch → enrollment ([ADR 0006](ADRs/0006-ordered-layer-model.md)). -- Cross-repo dispatch: enrolled repos call `.fullsend` via `workflow_call`; a dispatch workflow mints OIDC tokens exchanged at a central token mint (GCP Cloud Function) for scoped GitHub App installation tokens per agent role. App PEM secrets are stored in Secret Manager (GCF mint) or the local filesystem (standalone mint), not the config repo ([ADR 0008](ADRs/0008-workflow-dispatch-for-cross-repo-dispatch.md)). +- Cross-repo dispatch: enrolled repos call `.fullsend` via `workflow_call`; a dispatch workflow mints OIDC tokens exchanged at a central token mint (GCP Cloud Function or Cloudflare Worker) for scoped GitHub App installation tokens per agent role. App PEM secrets are stored in Secret Manager (GCF mint), Worker secrets (CF mint), or the local filesystem (standalone mint), not the config repo ([ADR 0008](ADRs/0008-workflow-dispatch-for-cross-repo-dispatch.md)). - Shim workflow security: `pull_request_target` prevents PR authors from modifying the shim workflow. No long-lived secrets flow through the shim — OIDC tokens are issued by the GitHub runtime and scoped to the workflow run ([ADR 0009](ADRs/0009-pull-request-target-in-shim-workflows.md)). - Repo maintenance: a workflow in `.fullsend` (`.github/workflows/repo-maintenance.yml`) reconciles enrollment shims in target repos when `config.yaml` changes or on manual dispatch. The CLI's `EnrollmentLayer.Install()` dispatches this workflow via `workflow_dispatch` and monitors it for completion, then reports any enrollment PRs created in target repos. - Installer scaffold: the `WorkflowsLayer` deploys content from an embedded scaffold (`internal/scaffold/`), keeping deployable files as real files under version control rather than Go string constants. diff --git a/docs/cli/mint.md b/docs/cli/mint.md index 5c57b0a6e1..39c9e1688a 100644 --- a/docs/cli/mint.md +++ b/docs/cli/mint.md @@ -4,13 +4,13 @@ sidebar_label: fullsend mint # fullsend mint -Deploy and manage the OIDC token mint service. The mint is a GCP Cloud Function that exchanges GitHub Actions OIDC tokens for short-lived GitHub App installation tokens, enabling agents to authenticate without long-lived credentials. +Deploy and manage the OIDC token mint service. The mint exchanges GitHub Actions OIDC tokens for short-lived GitHub App installation tokens, enabling agents to authenticate without long-lived credentials. The mint can be deployed on GCP (Cloud Function) or Cloudflare (Worker). ## Commands | Command | Description | |---------|-------------| -| `fullsend mint deploy` | Deploy or update the mint Cloud Function | +| `fullsend mint deploy` | Deploy or update the token mint (GCP or Cloudflare) | | `fullsend mint add-role ` | Register a role PEM and app ID on the mint | | `fullsend mint remove-role ` | Remove a role from the mint | | `fullsend mint enroll ` | Register an org or repo in the mint | @@ -20,7 +20,11 @@ Deploy and manage the OIDC token mint service. The mint is a GCP Cloud Function ## `mint deploy` -Deploys or updates the token mint Cloud Function, creating the service account, WIF pool, and Secret Manager secrets as needed. +Deploys or updates the token mint. Use `--platform` to select the target platform (default: `gcp`). + +### GCP mode (`--platform=gcp`) + +Deploys the mint as a GCP Cloud Function, creating the service account, WIF pool, and Secret Manager secrets as needed. ```bash fullsend mint deploy \ @@ -43,16 +47,36 @@ fullsend mint deploy \ --public ``` +### Cloudflare mode (`--platform=cloudflare`) + +Deploys the mint as a Cloudflare Worker running the mintcore WASM module with a thin TypeScript adapter. + +```bash +fullsend mint deploy \ + --platform cloudflare +``` + +Use `--preview` for ephemeral test deploys (supports teardown). Use `--worker-name` to target a specific Worker script name. + +Required environment variables: +- `CLOUDFLARE_ACCOUNT_ID` — Cloudflare account identifier +- `CLOUDFLARE_API_TOKEN` — API token with Workers write permission + ### Flags | Flag | Default | Description | |------|---------|-------------| -| `--project` | | GCP project ID | -| `--region` | `us-central1` | Cloud region for the function | -| `--pem-dir` | | Directory containing role PEM files (first-time bootstrap) | -| `--public` | `false` | Deploy public mint (`ALLOWED_ORGS=*`, permissive WIF) | - -### Required IAM roles +| `--platform` | `gcp` | Target platform: `gcp` or `cloudflare` | +| `--project` | | GCP project ID (GCP only) | +| `--region` | `us-central1` | Cloud region for the function (GCP only) | +| `--pem-dir` | | Directory containing role PEM files (GCP only, first-time bootstrap) | +| `--public` | `false` | Deploy public mint (GCP only) | +| `--source-dir` | | Path to local mint source (default: embedded) | +| `--dry-run` | `false` | Preview changes without making them | +| `--worker-name` | `fullsend-mint` | Cloudflare Worker script name (Cloudflare only) | +| `--preview` | `false` | Deploy as ephemeral preview Worker (Cloudflare only) | + +### Required IAM roles (GCP) | Role | Description | |------|-------------| diff --git a/docs/guides/README.md b/docs/guides/README.md index 18ec9a8961..e5c79d76dc 100644 --- a/docs/guides/README.md +++ b/docs/guides/README.md @@ -22,7 +22,7 @@ Guides for organization owners and repository administrators who manage fullsend Advanced guides for platform operators who deploy and manage the GCP-side infrastructure (token mint, WIF, secrets). -- [Mint service administration](infrastructure/mint-administration.md) — Deploying and managing the token mint Cloud Function +- [Mint service administration](infrastructure/mint-administration.md) — Deploying and managing the token mint (GCP or Cloudflare) - [Standalone mint](infrastructure/standalone-mint.md) — Running the token mint as a standalone HTTP server without GCP - [Infrastructure reference](infrastructure/infrastructure-reference.md) — Token mint, WIF, and secrets deployment details - [Enabling fullsend on private repositories](infrastructure/private-repositories.md) — Additional guardrails and configuration for private repos diff --git a/docs/guides/infrastructure/infrastructure-reference.md b/docs/guides/infrastructure/infrastructure-reference.md index cd081675d6..d31f793ac0 100644 --- a/docs/guides/infrastructure/infrastructure-reference.md +++ b/docs/guides/infrastructure/infrastructure-reference.md @@ -2,11 +2,11 @@ This guide provides implementation details for fullsend's infrastructure components: the OIDC token mint, Workload Identity Federation (WIF), and secrets deployment. For basic installation instructions, see the [Getting Started guides](../getting-started/). -## Token Mint (OIDC) — GCF Cloud Function +## Token Mint (OIDC) > Managed by: `fullsend mint deploy`, `fullsend mint enroll`, `fullsend mint unenroll`, `fullsend mint status`, `fullsend mint add-role`, `fullsend mint remove-role`, `fullsend mint token` -The mint is a GCP Cloud Function that exchanges GitHub OIDC tokens for scoped GitHub App installation tokens. This eliminates long-lived PATs from the system. +The mint exchanges GitHub OIDC tokens for scoped GitHub App installation tokens. This eliminates long-lived PATs from the system. The mint can be deployed on GCP (Cloud Function) or Cloudflare (Worker) — see `fullsend mint deploy --platform`. ### Mint Architecture diff --git a/docs/guides/infrastructure/mint-administration.md b/docs/guides/infrastructure/mint-administration.md index c097bf722e..e6542426d4 100644 --- a/docs/guides/infrastructure/mint-administration.md +++ b/docs/guides/infrastructure/mint-administration.md @@ -1,10 +1,10 @@ # Mint service administration -This guide covers deploying and managing the fullsend token mint Cloud Function. The mint is the OIDC token exchange service that lets GitHub Actions workflows authenticate as GitHub Apps — it is infrastructure that serves all enrolled organizations and repositories. +This guide covers deploying and managing the fullsend token mint. The mint is the OIDC token exchange service that lets GitHub Actions workflows authenticate as GitHub Apps — it is infrastructure that serves all enrolled organizations and repositories. The mint can be deployed on GCP (Cloud Function) or Cloudflare (Worker). | Command | Description | |---------|-------------| -| `mint deploy` | Deploy or update the mint Cloud Function and GCP infrastructure | +| `mint deploy` | Deploy or update the token mint (GCP Cloud Function or Cloudflare Worker) | | `mint add-role` | Add an agent role (PEM secret + `ROLE_APP_IDS` entry) | | `mint remove-role` | Remove an agent role from the mint (deletes PEM secret by default) | | `mint enroll` | Register an org or repo in `ALLOWED_ORGS` and configure WIF | @@ -12,7 +12,7 @@ This guide covers deploying and managing the fullsend token mint Cloud Function. | `mint status` | Inspect mint health, enrolled orgs, and PEM secrets | | `mint token` | Exchange a GitHub Actions OIDC token for an installation token | -> **This guide is for platform operators** who deploy, manage, or troubleshoot the token mint Cloud Function. If you are an end user setting up fullsend for your organization, see [Getting Started](../getting-started/) instead — the mint is typically deployed once by a platform operator, and organizations are enrolled as needed. +> **This guide is for platform operators** who deploy, manage, or troubleshoot the token mint. If you are an end user setting up fullsend for your organization, see [Getting Started](../getting-started/) instead — the mint is typically deployed once by a platform operator, and organizations are enrolled as needed. ## Hosted mint diff --git a/internal/cli/mint.go b/internal/cli/mint.go index dbffc2b758..e1fa37f6a9 100644 --- a/internal/cli/mint.go +++ b/internal/cli/mint.go @@ -28,6 +28,7 @@ import ( "github.com/fullsend-ai/fullsend/internal/appsetup" "github.com/fullsend-ai/fullsend/internal/config" + "github.com/fullsend-ai/fullsend/internal/dispatch/cf" "github.com/fullsend-ai/fullsend/internal/dispatch/gcf" "github.com/fullsend-ai/fullsend/internal/mintcore" "github.com/fullsend-ai/fullsend/internal/ui" @@ -38,6 +39,11 @@ var mintGCFClientFactory = func(projectID string) gcf.GCFClient { return gcf.NewLiveGCFClient(projectID) } +// mintCFWranglerFactory creates Wrangler runners for CF mint deploy. Overridden in tests. +var mintCFWranglerFactory = func(accountID string) cf.WranglerRunner { + return cf.NewLiveWranglerRunner(accountID) +} + // defaultMintRoles returns the default roles for mint enrollment. // The "fix" role is an alias for "coder" (same app, same PEM) and is // not a separate enrollment target. @@ -325,11 +331,14 @@ func newMintCmd() *cobra.Command { cmd := &cobra.Command{ Use: "mint", Short: "Manage token mint infrastructure and mint tokens", - Long: `Manage the GCP Cloud Function that mints GitHub App installation tokens, + Long: `Manage the token mint that produces GitHub App installation tokens, and mint short-lived tokens via OIDC. -Infrastructure subcommands (deploy, enroll, unenroll, status, add-role, remove-role) require GCP -project access. The 'token' subcommand requires only GitHub Actions OIDC.`, +The mint can be deployed on GCP (Cloud Function) or Cloudflare (Worker). +Use 'fullsend mint deploy --platform' to select the target platform. + +Infrastructure subcommands (deploy, enroll, unenroll, status, add-role, remove-role) require +platform-specific access. The 'token' subcommand requires only GitHub Actions OIDC.`, } cmd.AddCommand(newMintDeployCmd()) cmd.AddCommand(newMintEnrollCmd()) @@ -342,6 +351,7 @@ project access. The 'token' subcommand requires only GitHub Actions OIDC.`, } func newMintDeployCmd() *cobra.Command { + var platform string var project string var region string var sourceDir string @@ -350,166 +360,322 @@ func newMintDeployCmd() *cobra.Command { var pemDir string var public bool + // Cloudflare-specific flags. + var workerName string + var preview bool + cmd := &cobra.Command{ Use: "deploy", - Short: "Deploy or update the token mint Cloud Function", - Long: `Deploys the fullsend-mint Cloud Function and supporting GCP infrastructure -(service account, WIF pool/provider). Does NOT enroll any org — use -'fullsend mint enroll' after deployment (tight mode only). - -Use --public to deploy a public mint (ALLOWED_ORGS=* with permissive WIF). -Public mints accept any org via upstream reusable workflows; org enrollment -is not required. - -Most runs need only --project and --region. The optional --pem-dir flag is -for first-time bootstrap only: it seeds the default app set's PEM secrets so -that 'mint enroll' can work without running 'admin install' first. - -Redeploying an existing mint must use the same mode as the deployment: ---public for public mints, omit --public for tight mints. - -Required GCP APIs (gcloud services enable): - - iam.googleapis.com - - cloudresourcemanager.googleapis.com - - cloudfunctions.googleapis.com - - run.googleapis.com - - secretmanager.googleapis.com - - iamcredentials.googleapis.com (runtime: used by deployed function, not CLI) - -Required IAM roles on the target project: - - roles/iam.serviceAccountAdmin (create mint service account) - - roles/iam.workloadIdentityPoolAdmin (create WIF pool and provider) - - roles/cloudfunctions.developer (deploy Cloud Function) - - roles/run.admin (set Cloud Run invoker policy) - -When using --pem-dir, additionally requires: - - roles/secretmanager.admin (create and manage PEM secrets) - - roles/resourcemanager.projectIamAdmin (grant roles/aiplatform.user to WIF principals)`, + Short: "Deploy or update the token mint", + Long: `Deploys the token mint on GCP (Cloud Function) or Cloudflare (Worker). + +Use --platform to select the target (default: gcp). + +GCP mode (--platform=gcp): + Deploys the fullsend-mint Cloud Function and supporting GCP infrastructure + (service account, WIF pool/provider). Does NOT enroll any org — use + 'fullsend mint enroll' after deployment (tight mode only). + + Required flags: --project + Optional: --region, --source-dir, --skip-deploy, --pem-dir, --public + + Required GCP APIs (gcloud services enable): + - iam.googleapis.com + - cloudresourcemanager.googleapis.com + - cloudfunctions.googleapis.com + - run.googleapis.com + - secretmanager.googleapis.com + - iamcredentials.googleapis.com (runtime: used by deployed function, not CLI) + + Required IAM roles on the target project: + - roles/iam.serviceAccountAdmin + - roles/iam.workloadIdentityPoolAdmin + - roles/cloudfunctions.developer + - roles/run.admin + When using --pem-dir, additionally requires: + - roles/secretmanager.admin + - roles/resourcemanager.projectIamAdmin + +Cloudflare mode (--platform=cloudflare): + Deploys the fullsend-mint Cloudflare Worker. The Worker runs the mintcore + WASM module with a thin TypeScript adapter for I/O. + + Required flags: none (Worker name defaults to "fullsend-mint") + Optional: --worker-name, --preview, --source-dir + + Required environment variables: + - CLOUDFLARE_ACCOUNT_ID Cloudflare account identifier + - CLOUDFLARE_API_TOKEN API token with Workers write permission + + Use --preview for ephemeral BT test deploys (supports teardown). + Use --worker-name to target a specific Worker script name.`, Args: cobra.NoArgs, 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) + // Warn about flags set for the wrong platform so users + // discover misconfigurations immediately. + warnIrrelevantFlags(cmd, platform) + + switch platform { + case "gcp": + return runMintDeployGCP(cmd.Context(), project, region, sourceDir, skipDeploy, dryRun, pemDir, public) + case "cloudflare": + return runMintDeployCloudflare(cmd.Context(), workerName, sourceDir, preview, dryRun) + default: + return fmt.Errorf("unsupported platform %q: must be \"gcp\" or \"cloudflare\"", platform) } + }, + } - printer := ui.New(os.Stdout) - ctx := cmd.Context() + // Common flags. + cmd.Flags().StringVar(&platform, "platform", "gcp", "target platform: gcp or cloudflare") + cmd.Flags().StringVar(&sourceDir, "source-dir", "", "path to local mint source (default: embedded)") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview changes without making them") - printer.Banner(Version()) - printer.Blank() - printer.Header("Deploying token mint") - printer.Blank() + // GCP-specific flags. + cmd.Flags().StringVar(&project, "project", "", "GCP project ID (required for --platform=gcp)") + cmd.Flags().StringVar(®ion, "region", "us-central1", "GCP region for the Cloud Function") + cmd.Flags().BoolVar(&skipDeploy, "skip-deploy", false, "skip code upload, reuse existing function (GCP only)") + cmd.Flags().StringVar(&pemDir, "pem-dir", "", "optional: directory containing {role}.pem files to bootstrap the default app set (GCP only)") + cmd.Flags().BoolVar(&public, "public", false, "deploy public mint (ALLOWED_ORGS=*, permissive WIF) (GCP only)") - if dryRun { - printer.StepInfo("Dry run — no changes will be made") - printer.Blank() - printer.StepInfo(fmt.Sprintf("Would deploy mint to project %s, region %s", project, region)) - if sourceDir != "" { - printer.StepInfo(fmt.Sprintf("Source directory: %s", sourceDir)) - } else { - printer.StepInfo("Source: embedded mint function") - } - if skipDeploy { - printer.StepInfo("Would skip code deployment (--skip-deploy)") - } - if public { - printer.StepInfo("Would deploy public mint (ALLOWED_ORGS=*, permissive WIF)") - } - if pemDir != "" { - if _, err := validatePEMDir(pemDir); err != nil { - return err - } - printer.StepInfo(fmt.Sprintf("Would bootstrap app set %q with PEMs from %s (app ID lookup and PEM verification skipped in dry-run)", appsetup.DefaultAppSet, pemDir)) - } - return nil - } + // Cloudflare-specific flags. + cmd.Flags().StringVar(&workerName, "worker-name", "", "Cloudflare Worker script name (default: fullsend-mint)") + cmd.Flags().BoolVar(&preview, "preview", false, "deploy as ephemeral preview Worker for testing (Cloudflare only)") - gcpClient := mintGCFClientFactory(project) + return cmd +} - if sourceDir == "" { - sourceDir = gcf.DefaultFunctionSourceDir() - } +// warnIrrelevantFlags prints a warning for each flag that was explicitly +// set but belongs to a different platform than the one being used. This +// helps users catch misconfigurations (e.g. --project with --platform=cloudflare) +// immediately rather than silently ignoring them. +func warnIrrelevantFlags(cmd *cobra.Command, platform string) { + // Map each platform to the flags that are irrelevant for it. + irrelevant := map[string][]struct{ flag, owner string }{ + "gcp": { + {"worker-name", "Cloudflare"}, + {"preview", "Cloudflare"}, + }, + "cloudflare": { + {"project", "GCP"}, + {"region", "GCP"}, + {"skip-deploy", "GCP"}, + {"pem-dir", "GCP"}, + {"public", "GCP"}, + }, + } - deployMode := gcf.DeployAuto - if skipDeploy { - deployMode = gcf.DeploySkip - } + 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) + } + } +} - cfg := gcf.Config{ - ProjectID: project, - Region: region, - FunctionSourceDir: sourceDir, - DeployMode: deployMode, - Version: version, - Commit: commitSHA, - PublicMint: public, - } +func runMintDeployGCP(ctx context.Context, project, region, sourceDir string, skipDeploy, dryRun bool, pemDir string, public bool) 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) + } - if pemDir != "" { - printer.StepStart(fmt.Sprintf("Loading PEMs and discovering app IDs for app set %q", appsetup.DefaultAppSet)) - agentPEMs, agentAppIDs, err := loadAppSetPEMs(ctx, pemDir, appsetup.DefaultAppSet) - if err != nil { - printer.StepFail("Failed to load app set PEMs") - return fmt.Errorf("loading app set PEMs: %w", err) - } - printer.StepDone(fmt.Sprintf("Loaded %d role PEMs for app set %q", len(agentPEMs), appsetup.DefaultAppSet)) + printer := ui.New(os.Stdout) - cfg.AgentPEMs = agentPEMs - cfg.AgentAppIDs = agentAppIDs - } + printer.Banner(Version()) + printer.Blank() + printer.Header("Deploying token mint (GCP)") + printer.Blank() - if !public { - // Role app IDs are shared across orgs; enrolling orgs only updates ALLOWED_ORGS. - cfg.GitHubOrgs = []string{gcf.PlaceholderOrg} + if dryRun { + printer.StepInfo("Dry run — no changes will be made") + printer.Blank() + printer.StepInfo(fmt.Sprintf("Would deploy mint to project %s, region %s", project, region)) + if sourceDir != "" { + printer.StepInfo(fmt.Sprintf("Source directory: %s", sourceDir)) + } else { + printer.StepInfo("Source: embedded mint function") + } + if skipDeploy { + printer.StepInfo("Would skip code deployment (--skip-deploy)") + } + if public { + printer.StepInfo("Would deploy public mint (ALLOWED_ORGS=*, permissive WIF)") + } + if pemDir != "" { + if _, err := validatePEMDir(pemDir); err != nil { + return err } + printer.StepInfo(fmt.Sprintf("Would bootstrap app set %q with PEMs from %s (app ID lookup and PEM verification skipped in dry-run)", appsetup.DefaultAppSet, pemDir)) + } + return nil + } - provisioner := gcf.NewProvisioner(cfg, gcpClient) + gcpClient := mintGCFClientFactory(project) - printer.StepStart("Provisioning mint infrastructure") - result, err := provisioner.Provision(ctx) - if err != nil { - printer.StepFail("Mint deployment failed") - return fmt.Errorf("deploying mint: %w", err) - } + if sourceDir == "" { + sourceDir = gcf.DefaultFunctionSourceDir() + } - mintURL := result["FULLSEND_MINT_URL"] - printer.StepDone(fmt.Sprintf("Mint deployed at %s", mintURL)) - printer.Blank() + deployMode := gcf.DeployAuto + if skipDeploy { + deployMode = gcf.DeploySkip + } - summaryLines := []string{ - fmt.Sprintf("Project: %s", project), - fmt.Sprintf("Region: %s", region), - fmt.Sprintf("URL: %s", mintURL), - } - if pemDir != "" { - summaryLines = append(summaryLines, fmt.Sprintf("App set: %s (PEMs bootstrapped)", appsetup.DefaultAppSet)) - } - if public { - summaryLines = append(summaryLines, "Mode: public (ALLOWED_ORGS=*)") - summaryLines = append(summaryLines, "Orgs may call this mint via upstream reusable workflows after installing shared Apps") - } else { - summaryLines = append(summaryLines, "Next: fullsend mint enroll --project="+project) - } - printer.Summary("Deployment complete", summaryLines) + cfg := gcf.Config{ + ProjectID: project, + Region: region, + FunctionSourceDir: sourceDir, + DeployMode: deployMode, + Version: version, + Commit: commitSHA, + PublicMint: public, + } - return nil - }, + if pemDir != "" { + printer.StepStart(fmt.Sprintf("Loading PEMs and discovering app IDs for app set %q", appsetup.DefaultAppSet)) + agentPEMs, agentAppIDs, err := loadAppSetPEMs(ctx, pemDir, appsetup.DefaultAppSet) + if err != nil { + printer.StepFail("Failed to load app set PEMs") + return fmt.Errorf("loading app set PEMs: %w", err) + } + printer.StepDone(fmt.Sprintf("Loaded %d role PEMs for app set %q", len(agentPEMs), appsetup.DefaultAppSet)) + + cfg.AgentPEMs = agentPEMs + cfg.AgentAppIDs = agentAppIDs } - cmd.Flags().StringVar(&project, "project", "", "GCP project ID (required)") - cmd.Flags().StringVar(®ion, "region", "us-central1", "GCP region for the Cloud Function") - cmd.Flags().StringVar(&sourceDir, "source-dir", "", "path to local mint source (default: embedded)") - cmd.Flags().BoolVar(&skipDeploy, "skip-deploy", false, "skip code upload, reuse existing function") - cmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview changes without making them") - cmd.Flags().StringVar(&pemDir, "pem-dir", "", "optional: directory containing {role}.pem files to bootstrap the default app set") - cmd.Flags().BoolVar(&public, "public", false, "deploy public mint (ALLOWED_ORGS=*, permissive WIF); required to redeploy an existing public mint") + if !public { + // Role app IDs are shared across orgs; enrolling orgs only updates ALLOWED_ORGS. + cfg.GitHubOrgs = []string{gcf.PlaceholderOrg} + } - return cmd + provisioner := gcf.NewProvisioner(cfg, gcpClient) + + printer.StepStart("Provisioning mint infrastructure") + result, err := provisioner.Provision(ctx) + if err != nil { + printer.StepFail("Mint deployment failed") + return fmt.Errorf("deploying mint: %w", err) + } + + mintURL := result["FULLSEND_MINT_URL"] + printer.StepDone(fmt.Sprintf("Mint deployed at %s", mintURL)) + printer.Blank() + + summaryLines := []string{ + fmt.Sprintf("Project: %s", project), + fmt.Sprintf("Region: %s", region), + fmt.Sprintf("URL: %s", mintURL), + } + if pemDir != "" { + summaryLines = append(summaryLines, fmt.Sprintf("App set: %s (PEMs bootstrapped)", appsetup.DefaultAppSet)) + } + if public { + summaryLines = append(summaryLines, "Mode: public (ALLOWED_ORGS=*)") + summaryLines = append(summaryLines, "Orgs may call this mint via upstream reusable workflows after installing shared Apps") + } else { + summaryLines = append(summaryLines, "Next: fullsend mint enroll --project="+project) + } + printer.Summary("Deployment complete", summaryLines) + + return nil +} + +func runMintDeployCloudflare(ctx context.Context, workerName, sourceDir string, preview, dryRun bool) error { + if err := cf.ValidateCloudflareEnv(); err != nil { + return err + } + + accountID := os.Getenv("CLOUDFLARE_ACCOUNT_ID") + + 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() + printer.Header("Deploying token mint (Cloudflare)") + printer.Blank() + + deployMode := cf.DeployDurable + if preview { + deployMode = cf.DeployPreview + } + + if dryRun { + printer.StepInfo("Dry run — no changes will be made") + printer.Blank() + effectiveName := workerName + if effectiveName == "" { + effectiveName = "fullsend-mint (default)" + } + printer.StepInfo(fmt.Sprintf("Would deploy Worker %s", effectiveName)) + printer.StepInfo(fmt.Sprintf("Account: %s", accountID)) + if sourceDir != "" { + printer.StepInfo(fmt.Sprintf("Source directory: %s", sourceDir)) + } else { + printer.StepInfo("Source: embedded Worker adapter") + } + if preview { + printer.StepInfo("Mode: preview (ephemeral, supports teardown)") + } else { + printer.StepInfo("Mode: durable (persistent)") + } + return nil + } + + if sourceDir == "" { + sourceDir = cf.DefaultWorkerSourceDir() + } + + cfg := cf.Config{ + AccountID: accountID, + WorkerName: workerName, + DeployMode: deployMode, + SourceDir: sourceDir, + Version: version, + Commit: commitSHA, + } + + wrangler := mintCFWranglerFactory(accountID) + provisioner := cf.NewProvisioner(cfg, wrangler) + + modeLabel := "durable" + if preview { + modeLabel = "preview" + } + printer.StepStart(fmt.Sprintf("Deploying %s Worker", modeLabel)) + result, err := provisioner.Provision(ctx) + if err != nil { + printer.StepFail("Worker deployment failed") + return fmt.Errorf("deploying worker: %w", err) + } + + mintURL := result["FULLSEND_MINT_URL"] + printer.StepDone(fmt.Sprintf("Worker deployed at %s", mintURL)) + printer.Blank() + + effectiveName := workerName + if effectiveName == "" { + effectiveName = "fullsend-mint" + } + summaryLines := []string{ + fmt.Sprintf("Worker: %s", effectiveName), + fmt.Sprintf("URL: %s", mintURL), + fmt.Sprintf("Mode: %s", modeLabel), + } + if preview { + summaryLines = append(summaryLines, "Teardown: fullsend mint deploy --platform=cloudflare --worker-name="+effectiveName+" --preview (then delete)") + } + printer.Summary("Deployment complete", summaryLines) + + return nil } func newMintEnrollCmd() *cobra.Command { diff --git a/internal/cli/mint_test.go b/internal/cli/mint_test.go index 2d013ab590..cd2b2d28c8 100644 --- a/internal/cli/mint_test.go +++ b/internal/cli/mint_test.go @@ -8,6 +8,7 @@ import ( "crypto/x509" "encoding/pem" "fmt" + "io" "net/http" "net/http/httptest" "os" @@ -20,6 +21,7 @@ import ( "github.com/stretchr/testify/require" "github.com/fullsend-ai/fullsend/internal/config" + "github.com/fullsend-ai/fullsend/internal/dispatch/cf" "github.com/fullsend-ai/fullsend/internal/dispatch/gcf" "github.com/fullsend-ai/fullsend/internal/forge" "github.com/fullsend-ai/fullsend/internal/layers" @@ -39,6 +41,37 @@ func generateTestPEM(t *testing.T) []byte { }) } +// fakeCFWranglerRunner implements cf.WranglerRunner for CLI tests. +type fakeCFWranglerRunner struct { + deployErr error + deployURL string + deployCalls []fakeCFDeployCall +} + +type fakeCFDeployCall struct { + workerName string +} + +func (f *fakeCFWranglerRunner) Deploy(_ context.Context, _ string, workerName string, _ bool, _ map[string]string) (string, error) { + f.deployCalls = append(f.deployCalls, fakeCFDeployCall{workerName: workerName}) + if f.deployErr != nil { + return "", f.deployErr + } + url := f.deployURL + if url == "" { + url = fmt.Sprintf("https://%s.workers.dev", workerName) + } + return url, nil +} + +func (f *fakeCFWranglerRunner) PutSecret(context.Context, string, string, []byte) error { + return nil +} + +func (f *fakeCFWranglerRunner) Delete(context.Context, string) error { + return nil +} + func TestMintCommand_HasSubcommands(t *testing.T) { cmd := newMintCmd() names := make(map[string]bool) @@ -204,6 +237,326 @@ func TestMintDeployCmd_DryRunWithInvalidPEM(t *testing.T) { assert.Contains(t, err.Error(), "invalid PEM for role") } +// --- deploy command: platform flag tests --- + +func TestMintDeployCmd_PlatformFlag(t *testing.T) { + cmd := newMintDeployCmd() + platformFlag := cmd.Flags().Lookup("platform") + require.NotNil(t, platformFlag, "expected --platform flag") + assert.Equal(t, "gcp", platformFlag.DefValue) +} + +func TestMintDeployCmd_CloudflareFlags(t *testing.T) { + cmd := newMintDeployCmd() + + 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") + assert.Equal(t, "false", previewFlag.DefValue) +} + +func TestMintDeployCmd_InvalidPlatform(t *testing.T) { + cmd := newRootCmd() + cmd.SetArgs([]string{"mint", "deploy", "--platform=azure"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported platform") +} + +func TestMintDeployCmd_CloudflareMissingEnv(t *testing.T) { + // Save and restore env vars. + origAccount := os.Getenv("CLOUDFLARE_ACCOUNT_ID") + origToken := os.Getenv("CLOUDFLARE_API_TOKEN") + defer func() { + os.Setenv("CLOUDFLARE_ACCOUNT_ID", origAccount) + os.Setenv("CLOUDFLARE_API_TOKEN", origToken) + }() + + os.Unsetenv("CLOUDFLARE_ACCOUNT_ID") + os.Unsetenv("CLOUDFLARE_API_TOKEN") + + cmd := newRootCmd() + cmd.SetArgs([]string{"mint", "deploy", "--platform=cloudflare"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "CLOUDFLARE_ACCOUNT_ID") + assert.Contains(t, err.Error(), "CLOUDFLARE_API_TOKEN") +} + +func TestMintDeployCmd_CloudflareInvalidWorkerName(t *testing.T) { + origAccount := os.Getenv("CLOUDFLARE_ACCOUNT_ID") + origToken := os.Getenv("CLOUDFLARE_API_TOKEN") + defer func() { + os.Setenv("CLOUDFLARE_ACCOUNT_ID", origAccount) + os.Setenv("CLOUDFLARE_API_TOKEN", origToken) + }() + + os.Setenv("CLOUDFLARE_ACCOUNT_ID", "test-account") + os.Setenv("CLOUDFLARE_API_TOKEN", "test-token") + + cmd := newRootCmd() + cmd.SetArgs([]string{"mint", "deploy", "--platform=cloudflare", "--worker-name=INVALID_NAME"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid --worker-name") +} + +func TestMintDeployCmd_CloudflareDryRun(t *testing.T) { + origAccount := os.Getenv("CLOUDFLARE_ACCOUNT_ID") + origToken := os.Getenv("CLOUDFLARE_API_TOKEN") + defer func() { + os.Setenv("CLOUDFLARE_ACCOUNT_ID", origAccount) + os.Setenv("CLOUDFLARE_API_TOKEN", origToken) + }() + + os.Setenv("CLOUDFLARE_ACCOUNT_ID", "test-account") + os.Setenv("CLOUDFLARE_API_TOKEN", "test-token") + + cmd := newRootCmd() + cmd.SetArgs([]string{"mint", "deploy", "--platform=cloudflare", "--dry-run"}) + err := cmd.Execute() + require.NoError(t, err) +} + +func TestMintDeployCmd_CloudflareDryRunPreview(t *testing.T) { + origAccount := os.Getenv("CLOUDFLARE_ACCOUNT_ID") + origToken := os.Getenv("CLOUDFLARE_API_TOKEN") + defer func() { + os.Setenv("CLOUDFLARE_ACCOUNT_ID", origAccount) + os.Setenv("CLOUDFLARE_API_TOKEN", origToken) + }() + + os.Setenv("CLOUDFLARE_ACCOUNT_ID", "test-account") + os.Setenv("CLOUDFLARE_API_TOKEN", "test-token") + + cmd := newRootCmd() + cmd.SetArgs([]string{"mint", "deploy", "--platform=cloudflare", "--dry-run", "--preview"}) + err := cmd.Execute() + require.NoError(t, err) +} + +// --- Cloudflare non-dry-run deploy tests --- + +// withMintCFWrangler overrides the mintCFWranglerFactory package-level +// variable to return a fake WranglerRunner for the test's lifetime. +func withMintCFWrangler(t *testing.T, runner cf.WranglerRunner) { + t.Helper() + old := mintCFWranglerFactory + mintCFWranglerFactory = func(string) cf.WranglerRunner { return runner } + t.Cleanup(func() { mintCFWranglerFactory = old }) +} + +// createMinimalWorkerSourceDir creates a temp directory with the minimal +// files required by validateSourceDir (src/index.ts, wrangler.toml, +// package.json) so Provision can succeed without real wrangler. +func createMinimalWorkerSourceDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "src"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "src/index.ts"), []byte("export default {}"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "wrangler.toml"), []byte("name = \"test\""), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte("{}"), 0o644)) + return dir +} + +// withCFEnvVars sets the required Cloudflare env vars and restores them +// after the test. +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_API_TOKEN", "test-token") + t.Cleanup(func() { + os.Setenv("CLOUDFLARE_ACCOUNT_ID", origAccount) + os.Setenv("CLOUDFLARE_API_TOKEN", origToken) + }) +} + +func TestMintDeployCmd_CloudflareDurableDeploy(t *testing.T) { + withCFEnvVars(t) + sourceDir := createMinimalWorkerSourceDir(t) + withMintCFWrangler(t, &fakeCFWranglerRunner{ + deployURL: "https://fullsend-mint.workers.dev", + }) + + cmd := newRootCmd() + cmd.SetArgs([]string{ + "mint", "deploy", + "--platform=cloudflare", + "--source-dir=" + sourceDir, + }) + err := cmd.Execute() + require.NoError(t, err) +} + +func TestMintDeployCmd_CloudflarePreviewDeploy(t *testing.T) { + withCFEnvVars(t) + sourceDir := createMinimalWorkerSourceDir(t) + withMintCFWrangler(t, &fakeCFWranglerRunner{ + deployURL: "https://fullsend-mint-preview.workers.dev", + }) + + cmd := newRootCmd() + cmd.SetArgs([]string{ + "mint", "deploy", + "--platform=cloudflare", + "--preview", + "--source-dir=" + sourceDir, + }) + err := cmd.Execute() + require.NoError(t, err) +} + +func TestMintDeployCmd_CloudflareCustomWorkerName(t *testing.T) { + withCFEnvVars(t) + sourceDir := createMinimalWorkerSourceDir(t) + fake := &fakeCFWranglerRunner{ + deployURL: "https://custom-mint.workers.dev", + } + withMintCFWrangler(t, fake) + + cmd := newRootCmd() + cmd.SetArgs([]string{ + "mint", "deploy", + "--platform=cloudflare", + "--worker-name=custom-mint", + "--source-dir=" + sourceDir, + }) + err := cmd.Execute() + require.NoError(t, err) + require.Len(t, fake.deployCalls, 1) + assert.Equal(t, "custom-mint", fake.deployCalls[0].workerName) +} + +func TestMintDeployCmd_CloudflareDeployFailure(t *testing.T) { + withCFEnvVars(t) + sourceDir := createMinimalWorkerSourceDir(t) + withMintCFWrangler(t, &fakeCFWranglerRunner{ + deployErr: fmt.Errorf("wrangler deploy failed: exit status 1"), + }) + + cmd := newRootCmd() + cmd.SetArgs([]string{ + "mint", "deploy", + "--platform=cloudflare", + "--source-dir=" + sourceDir, + }) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "deploying worker") +} + +func TestMintDeployCmd_CloudflareDeployBadSourceDir(t *testing.T) { + withCFEnvVars(t) + withMintCFWrangler(t, &fakeCFWranglerRunner{}) + + cmd := newRootCmd() + cmd.SetArgs([]string{ + "mint", "deploy", + "--platform=cloudflare", + "--source-dir=/nonexistent/path", + }) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "deploying worker") +} + +func TestMintDeployCmd_GCPDefaultPlatform(t *testing.T) { + // Default platform is GCP, so omitting --platform should require --project. + cmd := newRootCmd() + cmd.SetArgs([]string{"mint", "deploy"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "--project is required") +} + +func TestMintDeployCmd_GCPExplicitPlatform(t *testing.T) { + // Explicitly setting --platform=gcp should still require --project. + cmd := newRootCmd() + cmd.SetArgs([]string{"mint", "deploy", "--platform=gcp"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "--project is required") +} + +func TestMintDeployCmd_GCPPlatformDryRun(t *testing.T) { + cmd := newRootCmd() + cmd.SetArgs([]string{"mint", "deploy", "--platform=gcp", "--project=my-project-id", "--dry-run"}) + err := cmd.Execute() + require.NoError(t, err) +} + +// --- platform flag warning tests --- + +func TestMintDeployCmd_WarnsGCPFlagsOnCloudflare(t *testing.T) { + origAccount := os.Getenv("CLOUDFLARE_ACCOUNT_ID") + origToken := os.Getenv("CLOUDFLARE_API_TOKEN") + defer func() { + os.Setenv("CLOUDFLARE_ACCOUNT_ID", origAccount) + os.Setenv("CLOUDFLARE_API_TOKEN", origToken) + }() + + os.Setenv("CLOUDFLARE_ACCOUNT_ID", "test-account") + os.Setenv("CLOUDFLARE_API_TOKEN", "test-token") + + // Capture stderr to check warnings. + oldStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + cmd := newRootCmd() + cmd.SetArgs([]string{"mint", "deploy", "--platform=cloudflare", "--project=my-project", "--dry-run"}) + _ = cmd.Execute() + + w.Close() + os.Stderr = oldStderr + + out, _ := io.ReadAll(r) + stderr := string(out) + assert.Contains(t, stderr, "--project is a GCP flag") + assert.Contains(t, stderr, "--platform=cloudflare") +} + +func TestMintDeployCmd_WarnsCFFlagsOnGCP(t *testing.T) { + // Capture stderr to check warnings. + oldStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + cmd := newRootCmd() + cmd.SetArgs([]string{"mint", "deploy", "--platform=gcp", "--project=my-project-id", "--worker-name=test", "--dry-run"}) + _ = cmd.Execute() + + w.Close() + os.Stderr = oldStderr + + out, _ := io.ReadAll(r) + stderr := string(out) + assert.Contains(t, stderr, "--worker-name is a Cloudflare flag") + assert.Contains(t, stderr, "--platform=gcp") +} + +func TestMintDeployCmd_NoWarningForCorrectPlatformFlags(t *testing.T) { + // Capture stderr to check no warnings. + oldStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + cmd := newRootCmd() + cmd.SetArgs([]string{"mint", "deploy", "--platform=gcp", "--project=my-project-id", "--dry-run"}) + _ = cmd.Execute() + + w.Close() + os.Stderr = oldStderr + + out, _ := io.ReadAll(r) + stderr := string(out) + assert.NotContains(t, stderr, "WARNING:") +} + // --- lookupAppID tests --- func TestLookupAppID_Success(t *testing.T) { diff --git a/internal/dispatch/cf/provisioner.go b/internal/dispatch/cf/provisioner.go new file mode 100644 index 0000000000..beca557fb8 --- /dev/null +++ b/internal/dispatch/cf/provisioner.go @@ -0,0 +1,445 @@ +// Package cf implements the dispatch.Dispatcher interface using a +// Cloudflare Worker as the token mint. The Worker runs the mintcore +// WASM module compiled from cmd/mint-wasm, with a thin TypeScript +// adapter (workersrc/) handling I/O. Credentials are read from env +// vars (CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN) — no secrets +// are passed as CLI flags. +package cf + +import ( + "context" + "embed" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + "github.com/fullsend-ai/fullsend/internal/dispatch" + "github.com/fullsend-ai/fullsend/internal/mintcore" +) + +// DeployMode controls Worker deployment behavior. +type DeployMode int + +const ( + // DeployDurable deploys a persistent, production Worker. + DeployDurable DeployMode = iota + // DeployPreview deploys an ephemeral preview Worker for testing. + DeployPreview +) + +const ( + defaultWorkerName = "fullsend-mint" + defaultOIDCAudience = "fullsend-mint" +) + +// 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]$`) + +// Compile-time check that Provisioner implements dispatch.Dispatcher. +var _ dispatch.Dispatcher = (*Provisioner)(nil) + +// embeddedWorkerSource contains the TypeScript Worker adapter source +// files. These are extracted to a temp directory at deploy time so +// wrangler can build and deploy the Worker. +// +// The WASM binary (mintcore.wasm) and Go WASM support (wasm_exec.js) +// are NOT embedded here — they are build artifacts staged by +// `make wasm-stage`. The provisioner expects them to be present in +// the source directory at deploy time. +// +//go:embed workersrc/src/index.ts workersrc/src/version.ts workersrc/wrangler.toml workersrc/package.json workersrc/tsconfig.json workersrc/wasm.d.ts workersrc/wasm_exec.d.ts +var embeddedWorkerSource embed.FS + +// embeddedWorkerFiles lists the embedded files for extraction. +// Maps embedded path (under workersrc/) to extraction path. +var embeddedWorkerFiles = []string{ + "workersrc/src/index.ts", + "workersrc/src/version.ts", + "workersrc/wrangler.toml", + "workersrc/package.json", + "workersrc/tsconfig.json", + "workersrc/wasm.d.ts", + "workersrc/wasm_exec.d.ts", +} + +// Config holds the inputs for CF Worker mint provisioning. +type Config struct { + // AccountID is the Cloudflare account ID. Read from + // CLOUDFLARE_ACCOUNT_ID env var. + AccountID string + + // WorkerName is the Worker script name (e.g. "fullsend-mint", + // "fullsend-mint-test"). Defaults to "fullsend-mint". + WorkerName string + + // DeployMode controls whether the Worker is deployed as a durable + // production Worker or an ephemeral preview. + DeployMode DeployMode + + // SourceDir overrides the embedded Worker source with a local + // directory. When set, the provisioner uses this path directly + // instead of extracting embedded files. The directory must + // contain the workersrc tree including mintcore.wasm and + // wasm_exec.js (staged by `make wasm-stage`). + SourceDir string + + // EnvVars are non-secret environment variables to set on the Worker + // (e.g. ROLE_APP_IDS, ALLOWED_ORGS, OIDC_AUDIENCE). + EnvVars map[string]string + + // Version is the fullsend semver stamped on the deployed Worker. + Version string + + // Commit is the git SHA stamped on the deployed Worker. + Commit string +} + +// WranglerRunner abstracts wrangler CLI operations for testing. +type WranglerRunner interface { + // Deploy deploys a Worker from sourceDir. Returns the Worker URL. + Deploy(ctx context.Context, sourceDir, workerName string, preview bool, envVars map[string]string) (url string, err error) + + // PutSecret stores a secret value on a Worker. + PutSecret(ctx context.Context, workerName, secretName string, value []byte) error + + // Delete removes a Worker deployment. + Delete(ctx context.Context, workerName string) error +} + +// Provisioner creates Cloudflare Worker infrastructure for token minting. +type Provisioner struct { + cfg Config + wrangler WranglerRunner +} + +// NewProvisioner creates a new CF Provisioner with defaults applied. +func NewProvisioner(cfg Config, wrangler WranglerRunner) *Provisioner { + if cfg.WorkerName == "" { + cfg.WorkerName = defaultWorkerName + } + if cfg.EnvVars == nil { + cfg.EnvVars = make(map[string]string) + } + if cfg.EnvVars["OIDC_AUDIENCE"] == "" { + cfg.EnvVars["OIDC_AUDIENCE"] = defaultOIDCAudience + } + return &Provisioner{cfg: cfg, wrangler: wrangler} +} + +// Name returns the dispatcher identifier. +func (p *Provisioner) Name() string { + return "cf" +} + +// OrgSecretNames returns nil — PEM secrets are stored as Worker secrets. +func (p *Provisioner) OrgSecretNames() []string { + return nil +} + +// OrgVariableNames returns the org variables this dispatcher manages. +func (p *Provisioner) OrgVariableNames() []string { + return []string{"FULLSEND_MINT_URL"} +} + +// Provision deploys the Cloudflare Worker mint and returns the Worker +// URL as FULLSEND_MINT_URL. +func (p *Provisioner) Provision(ctx context.Context) (map[string]string, error) { + if err := p.validate(); err != nil { + return nil, err + } + + sourceDir, cleanup, err := p.resolveSourceDir() + if err != nil { + return nil, fmt.Errorf("resolving worker source: %w", err) + } + if cleanup != nil { + defer cleanup() + } + + // Stamp version metadata into the Worker source at deploy time so + // the WASM module can report them via /health and /status. This + // mirrors the GCF approach (writeVersionGoToZip) — version data is + // compiled into the deployed bundle and cannot diverge via admin + // action on environment variables. + if err := writeVersionTS(sourceDir, p.cfg.Version, p.cfg.Commit); err != nil { + return nil, fmt.Errorf("writing version.ts: %w", err) + } + + preview := p.cfg.DeployMode == DeployPreview + url, err := p.wrangler.Deploy(ctx, sourceDir, p.cfg.WorkerName, preview, p.cfg.EnvVars) + if err != nil { + return nil, fmt.Errorf("deploying worker: %w", err) + } + + return map[string]string{ + "FULLSEND_MINT_URL": url, + }, nil +} + +// StoreAgentPEM stores a role's PEM key as a Cloudflare Worker secret. +// Secret names follow the convention _APP_PEM (e.g. CODER_APP_PEM). +func (p *Provisioner) StoreAgentPEM(ctx context.Context, role string, pemData []byte) error { + if err := mintcore.ValidateRoleName(role); err != nil { + return fmt.Errorf("invalid role name %q: %w", role, err) + } + secretName := pemSecretName(role) + if err := p.wrangler.PutSecret(ctx, p.cfg.WorkerName, secretName, pemData); err != nil { + return fmt.Errorf("storing PEM secret %s: %w", secretName, err) + } + return nil +} + +// Teardown removes a preview Worker deployment. Only valid when +// DeployMode is DeployPreview. +func (p *Provisioner) Teardown(ctx context.Context) error { + if p.cfg.DeployMode != DeployPreview { + return fmt.Errorf("teardown is only supported for preview Workers") + } + if err := p.wrangler.Delete(ctx, p.cfg.WorkerName); err != nil { + return fmt.Errorf("deleting worker %s: %w", p.cfg.WorkerName, err) + } + return nil +} + +// validate checks that the Config has all required fields. +func (p *Provisioner) validate() error { + if p.cfg.AccountID == "" { + return fmt.Errorf("CLOUDFLARE_ACCOUNT_ID is required (set via environment variable)") + } + if !ValidateWorkerName(p.cfg.WorkerName) { + return fmt.Errorf("invalid Worker name %q: must be 2-63 lowercase alphanumeric characters or hyphens", p.cfg.WorkerName) + } + return nil +} + +// resolveSourceDir returns the path to the Worker source directory, +// either from Config.SourceDir or by extracting embedded files to +// a temp directory. Returns a cleanup function for temp dirs. +func (p *Provisioner) resolveSourceDir() (string, func(), error) { + if p.cfg.SourceDir != "" { + if err := validateSourceDir(p.cfg.SourceDir); err != nil { + return "", nil, err + } + return p.cfg.SourceDir, nil, nil + } + + // Extract embedded source to temp directory. + tmpDir, err := os.MkdirTemp("", "fullsend-cf-worker-*") + if err != nil { + return "", nil, fmt.Errorf("creating temp dir: %w", err) + } + cleanup := func() { os.RemoveAll(tmpDir) } + + if err := extractEmbeddedSource(tmpDir); err != nil { + cleanup() + return "", nil, fmt.Errorf("extracting embedded source: %w", err) + } + + return tmpDir, cleanup, nil +} + +// extractEmbeddedSource writes the embedded Worker source files to dir. +func extractEmbeddedSource(dir string) error { + for _, path := range embeddedWorkerFiles { + data, err := embeddedWorkerSource.ReadFile(path) + if err != nil { + return fmt.Errorf("reading embedded %s: %w", path, err) + } + + // Strip the "workersrc/" prefix for the extraction path. + relPath := strings.TrimPrefix(path, "workersrc/") + destPath := filepath.Join(dir, relPath) + + if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { + return fmt.Errorf("creating directory for %s: %w", relPath, err) + } + if err := os.WriteFile(destPath, data, 0o644); err != nil { + return fmt.Errorf("writing %s: %w", relPath, err) + } + } + return nil +} + +// writeVersionTS writes a generated version.ts into the Worker source +// directory with the provided version and commit values. This stamps +// the version identity directly into the deployed source code — +// mirroring how writeVersionGoToZip works for GCF deploys — so it +// cannot drift from the running code via admin changes to env vars. +func writeVersionTS(dir, version, commit string) error { + src := fmt.Sprintf( + "// Generated at deploy time by the CF provisioner. Do not edit.\n"+ + "export const FULLSEND_VERSION = %q;\n"+ + "export const FULLSEND_COMMIT = %q;\n", + version, commit) + destPath := filepath.Join(dir, "src", "version.ts") + if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { + return fmt.Errorf("creating directory for version.ts: %w", err) + } + return os.WriteFile(destPath, []byte(src), 0o644) +} + +// validateSourceDir checks that a source directory contains the +// required Worker files. +func validateSourceDir(dir string) error { + info, err := os.Stat(dir) + if err != nil { + return fmt.Errorf("source-dir %q: %w", dir, err) + } + if !info.IsDir() { + return fmt.Errorf("source-dir %q is not a directory", dir) + } + + required := []string{ + "src/index.ts", + "wrangler.toml", + "package.json", + } + for _, name := range required { + path := filepath.Join(dir, name) + if _, err := os.Stat(path); err != nil { + return fmt.Errorf("source-dir missing required file %s: %w", name, err) + } + } + return nil +} + +// pemSecretName returns the Cloudflare Worker secret name for a role's +// PEM key. Follows the convention _APP_PEM with hyphens mapped +// to underscores (CF secret names must be valid JS identifiers). +func pemSecretName(role string) string { + mapped := mintcore.PemSecretRole(role) + return strings.ToUpper(strings.ReplaceAll(mapped, "-", "_")) + "_APP_PEM" +} + +// ValidateWorkerName checks if a string is a valid CF Worker name. +func ValidateWorkerName(name string) bool { + return workerNamePattern.MatchString(name) +} + +// DefaultWorkerSourceDir returns the default path to the Worker source +// directory. This assumes the CLI is run from the repository root. +func DefaultWorkerSourceDir() string { + return filepath.Join("internal", "dispatch", "cf", "workersrc") +} + +// ValidateCloudflareEnv checks that required Cloudflare environment +// variables are set. Returns an error listing all missing variables. +func ValidateCloudflareEnv() error { + var missing []string + if os.Getenv("CLOUDFLARE_ACCOUNT_ID") == "" { + missing = append(missing, "CLOUDFLARE_ACCOUNT_ID") + } + if os.Getenv("CLOUDFLARE_API_TOKEN") == "" { + missing = append(missing, "CLOUDFLARE_API_TOKEN") + } + if len(missing) > 0 { + return fmt.Errorf("missing required Cloudflare environment variables: %s", strings.Join(missing, ", ")) + } + return nil +} + +// --- LiveWranglerRunner --- + +// LiveWranglerRunner executes wrangler commands via the CLI. +type LiveWranglerRunner struct { + // AccountID is passed to wrangler via CLOUDFLARE_ACCOUNT_ID. + AccountID string +} + +// NewLiveWranglerRunner creates a runner that uses the real wrangler CLI. +func NewLiveWranglerRunner(accountID string) *LiveWranglerRunner { + return &LiveWranglerRunner{AccountID: accountID} +} + +// Deploy deploys a Worker from sourceDir using wrangler deploy. +func (r *LiveWranglerRunner) Deploy(ctx context.Context, sourceDir, workerName string, preview bool, envVars map[string]string) (string, error) { + args := []string{"wrangler", "deploy", "--name", workerName} + // Always pass --keep-vars to preserve existing Worker secrets + // (e.g. PEM keys stored via StoreAgentPEM). Without this flag, + // wrangler overwrites all bindings on each deploy, wiping secrets. + args = append(args, "--keep-vars") + + // Pass env vars to wrangler via --var flags. + for k, v := range envVars { + args = append(args, "--var", fmt.Sprintf("%s:%s", k, v)) + } + + cmd := exec.CommandContext(ctx, "npx", args...) + cmd.Dir = sourceDir + cmd.Env = append(os.Environ(), + "CLOUDFLARE_ACCOUNT_ID="+r.AccountID, + ) + + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("wrangler deploy failed: %s\n%s", err, string(output)) + } + + // Parse the Worker URL from wrangler output. + url := parseWorkerURL(string(output), workerName) + if url == "" { + url = fmt.Sprintf("https://%s.workers.dev", workerName) + } + return url, nil +} + +// PutSecret stores a secret value on a Worker via wrangler secret put. +func (r *LiveWranglerRunner) PutSecret(ctx context.Context, workerName, secretName string, value []byte) error { + cmd := exec.CommandContext(ctx, "npx", "wrangler", "secret", "put", secretName, "--name", workerName) + cmd.Stdin = strings.NewReader(string(value)) + cmd.Env = append(os.Environ(), + "CLOUDFLARE_ACCOUNT_ID="+r.AccountID, + ) + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("wrangler secret put failed: %s\n%s", err, string(output)) + } + return nil +} + +// Delete removes a Worker deployment via wrangler delete. +func (r *LiveWranglerRunner) Delete(ctx context.Context, workerName string) error { + cmd := exec.CommandContext(ctx, "npx", "wrangler", "delete", "--name", workerName, "--force") + cmd.Env = append(os.Environ(), + "CLOUDFLARE_ACCOUNT_ID="+r.AccountID, + ) + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("wrangler delete failed: %s\n%s", err, string(output)) + } + return nil +} + +// parseWorkerURL extracts the deployed Worker URL from wrangler output. +func parseWorkerURL(output, _ string) string { + // Wrangler prints the URL in various formats. Look for common patterns. + for line := range strings.SplitSeq(output, "\n") { + line = strings.TrimSpace(line) + if strings.Contains(line, "workers.dev") && strings.Contains(line, "https://") { + // Extract URL from the line. + start := strings.Index(line, "https://") + if start >= 0 { + url := line[start:] + // Trim trailing whitespace and punctuation. + url = strings.TrimRight(url, " \t\n\r.,;") + return url + } + } + } + return "" +} + +// --- Test support --- + +// EmbeddedWorkerSource returns the embedded Worker source filesystem +// for testing embed integrity. +func EmbeddedWorkerSource() fs.FS { + return embeddedWorkerSource +} diff --git a/internal/dispatch/cf/provisioner_test.go b/internal/dispatch/cf/provisioner_test.go new file mode 100644 index 0000000000..69437ad64f --- /dev/null +++ b/internal/dispatch/cf/provisioner_test.go @@ -0,0 +1,705 @@ +package cf + +import ( + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- FakeWranglerRunner --- + +type fakeWranglerRunner struct { + deployErr error + deployURL string + deployCalls []deployCall + secretCalls []secretCall + deleteCalls []string + deleteErr error + secretPutErr error +} + +type deployCall struct { + sourceDir string + workerName string + preview bool + envVars map[string]string +} + +type secretCall struct { + workerName string + secretName string + value []byte +} + +func (f *fakeWranglerRunner) Deploy(_ context.Context, sourceDir, workerName string, preview bool, envVars map[string]string) (string, error) { + f.deployCalls = append(f.deployCalls, deployCall{ + sourceDir: sourceDir, + workerName: workerName, + preview: preview, + envVars: envVars, + }) + if f.deployErr != nil { + return "", f.deployErr + } + url := f.deployURL + if url == "" { + url = fmt.Sprintf("https://%s.workers.dev", workerName) + } + return url, nil +} + +func (f *fakeWranglerRunner) PutSecret(_ context.Context, workerName, secretName string, value []byte) error { + f.secretCalls = append(f.secretCalls, secretCall{ + workerName: workerName, + secretName: secretName, + value: value, + }) + return f.secretPutErr +} + +func (f *fakeWranglerRunner) Delete(_ context.Context, workerName string) error { + f.deleteCalls = append(f.deleteCalls, workerName) + return f.deleteErr +} + +// --- Provisioner tests --- + +func TestProvisioner_Name(t *testing.T) { + p := NewProvisioner(Config{}, &fakeWranglerRunner{}) + assert.Equal(t, "cf", p.Name()) +} + +func TestProvisioner_OrgVariableNames(t *testing.T) { + p := NewProvisioner(Config{}, &fakeWranglerRunner{}) + assert.Equal(t, []string{"FULLSEND_MINT_URL"}, p.OrgVariableNames()) +} + +func TestProvisioner_OrgSecretNames(t *testing.T) { + p := NewProvisioner(Config{}, &fakeWranglerRunner{}) + assert.Nil(t, p.OrgSecretNames()) +} + +func TestProvisioner_Provision_MissingAccountID(t *testing.T) { + p := NewProvisioner(Config{ + WorkerName: "test-mint", + }, &fakeWranglerRunner{}) + + _, err := p.Provision(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "CLOUDFLARE_ACCOUNT_ID") +} + +func TestProvisioner_Provision_InvalidWorkerName(t *testing.T) { + p := NewProvisioner(Config{ + AccountID: "abc123", + WorkerName: "INVALID_NAME", + }, &fakeWranglerRunner{}) + + _, err := p.Provision(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid Worker name") +} + +func TestProvisioner_Provision_WithSourceDir(t *testing.T) { + sourceDir := createFakeWorkerSourceDir(t) + fake := &fakeWranglerRunner{ + deployURL: "https://test-mint.workers.dev", + } + + p := NewProvisioner(Config{ + AccountID: "abc123", + WorkerName: "test-mint", + SourceDir: sourceDir, + }, fake) + + result, err := p.Provision(context.Background()) + require.NoError(t, err) + assert.Equal(t, "https://test-mint.workers.dev", result["FULLSEND_MINT_URL"]) + require.Len(t, fake.deployCalls, 1) + assert.Equal(t, sourceDir, fake.deployCalls[0].sourceDir) + assert.Equal(t, "test-mint", fake.deployCalls[0].workerName) + assert.False(t, fake.deployCalls[0].preview) +} + +func TestProvisioner_Provision_Preview(t *testing.T) { + sourceDir := createFakeWorkerSourceDir(t) + fake := &fakeWranglerRunner{} + + p := NewProvisioner(Config{ + AccountID: "abc123", + WorkerName: "test-mint-preview", + DeployMode: DeployPreview, + SourceDir: sourceDir, + }, fake) + + _, err := p.Provision(context.Background()) + require.NoError(t, err) + require.Len(t, fake.deployCalls, 1) + assert.True(t, fake.deployCalls[0].preview) +} + +func TestProvisioner_Provision_EnvVars(t *testing.T) { + sourceDir := createFakeWorkerSourceDir(t) + fake := &fakeWranglerRunner{} + + envVars := map[string]string{ + "ROLE_APP_IDS": `{"coder":"12345"}`, + "ALLOWED_ORGS": "acme", + } + + p := NewProvisioner(Config{ + AccountID: "abc123", + WorkerName: "test-mint", + SourceDir: sourceDir, + EnvVars: envVars, + }, fake) + + _, err := p.Provision(context.Background()) + require.NoError(t, err) + require.Len(t, fake.deployCalls, 1) + assert.Equal(t, `{"coder":"12345"}`, fake.deployCalls[0].envVars["ROLE_APP_IDS"]) + assert.Equal(t, "acme", fake.deployCalls[0].envVars["ALLOWED_ORGS"]) + // OIDC_AUDIENCE should be set by default. + assert.Equal(t, "fullsend-mint", fake.deployCalls[0].envVars["OIDC_AUDIENCE"]) +} + +func TestProvisioner_Provision_StampsVersion(t *testing.T) { + sourceDir := createFakeWorkerSourceDir(t) + fake := &fakeWranglerRunner{} + + p := NewProvisioner(Config{ + AccountID: "abc123", + WorkerName: "test-mint", + SourceDir: sourceDir, + Version: "1.2.3", + Commit: "deadbeef", + }, fake) + + _, err := p.Provision(context.Background()) + require.NoError(t, err) + require.Len(t, fake.deployCalls, 1) + + // Version is stamped into src/version.ts, not env vars. + versionTS := filepath.Join(sourceDir, "src", "version.ts") + data, err := os.ReadFile(versionTS) + require.NoError(t, err, "version.ts should be written to source dir") + assert.Contains(t, string(data), `"1.2.3"`) + assert.Contains(t, string(data), `"deadbeef"`) + + // Env vars should NOT contain version fields. + _, hasVersion := fake.deployCalls[0].envVars["FULLSEND_VERSION"] + _, hasCommit := fake.deployCalls[0].envVars["FULLSEND_COMMIT"] + assert.False(t, hasVersion, "FULLSEND_VERSION should not be in env vars") + assert.False(t, hasCommit, "FULLSEND_COMMIT should not be in env vars") +} + +func TestProvisioner_Provision_OmitsEmptyVersion(t *testing.T) { + sourceDir := createFakeWorkerSourceDir(t) + fake := &fakeWranglerRunner{} + + p := NewProvisioner(Config{ + AccountID: "abc123", + WorkerName: "test-mint", + SourceDir: sourceDir, + // No Version or Commit set. + }, fake) + + _, err := p.Provision(context.Background()) + require.NoError(t, err) + require.Len(t, fake.deployCalls, 1) + + // version.ts should still be written (with empty values). + versionTS := filepath.Join(sourceDir, "src", "version.ts") + data, err := os.ReadFile(versionTS) + require.NoError(t, err, "version.ts should be written even with empty version") + assert.Contains(t, string(data), `""`) + + // Env vars should NOT contain version fields. + _, hasVersion := fake.deployCalls[0].envVars["FULLSEND_VERSION"] + _, hasCommit := fake.deployCalls[0].envVars["FULLSEND_COMMIT"] + assert.False(t, hasVersion, "FULLSEND_VERSION should not be set when empty") + assert.False(t, hasCommit, "FULLSEND_COMMIT should not be set when empty") +} + +func TestProvisioner_Provision_KeepVarsAlwaysPassed(t *testing.T) { + // Verify that --keep-vars is always passed to wrangler deploy, + // not just for preview deploys, to avoid wiping existing secrets. + sourceDir := createFakeWorkerSourceDir(t) + + for _, mode := range []DeployMode{DeployDurable, DeployPreview} { + t.Run(fmt.Sprintf("mode=%d", mode), func(t *testing.T) { + fake := &fakeWranglerRunner{} + p := NewProvisioner(Config{ + AccountID: "abc123", + WorkerName: "test-mint", + SourceDir: sourceDir, + DeployMode: mode, + }, fake) + + _, err := p.Provision(context.Background()) + require.NoError(t, err) + require.Len(t, fake.deployCalls, 1) + // The deploy call always passes preview=true/false to Deploy(), + // but --keep-vars is handled inside LiveWranglerRunner.Deploy. + // This test verifies Deploy() is called; the --keep-vars + // behavior is tested in integration tests via the runner. + }) + } +} + +func TestProvisioner_Provision_DeployError(t *testing.T) { + sourceDir := createFakeWorkerSourceDir(t) + fake := &fakeWranglerRunner{ + deployErr: fmt.Errorf("network error"), + } + + p := NewProvisioner(Config{ + AccountID: "abc123", + WorkerName: "test-mint", + SourceDir: sourceDir, + }, fake) + + _, err := p.Provision(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "deploying worker") +} + +func TestProvisioner_Provision_EmbeddedSource(t *testing.T) { + fake := &fakeWranglerRunner{ + deployURL: "https://test-mint.workers.dev", + } + + p := NewProvisioner(Config{ + AccountID: "abc123", + WorkerName: "test-mint", + // No SourceDir — uses embedded source. + }, fake) + + result, err := p.Provision(context.Background()) + require.NoError(t, err) + assert.Equal(t, "https://test-mint.workers.dev", result["FULLSEND_MINT_URL"]) + require.Len(t, fake.deployCalls, 1) + // Should have extracted to a temp dir. + assert.NotEmpty(t, fake.deployCalls[0].sourceDir) + // Temp dir should be cleaned up. + _, statErr := os.Stat(fake.deployCalls[0].sourceDir) + assert.True(t, os.IsNotExist(statErr), "temp dir should be cleaned up") +} + +func TestProvisioner_Provision_BadSourceDir(t *testing.T) { + fake := &fakeWranglerRunner{} + p := NewProvisioner(Config{ + AccountID: "abc123", + WorkerName: "test-mint", + SourceDir: "/nonexistent", + }, fake) + + _, err := p.Provision(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "source-dir") +} + +func TestProvisioner_Provision_DefaultWorkerName(t *testing.T) { + sourceDir := createFakeWorkerSourceDir(t) + fake := &fakeWranglerRunner{} + + p := NewProvisioner(Config{ + AccountID: "abc123", + SourceDir: sourceDir, + // No WorkerName — should default. + }, fake) + + _, err := p.Provision(context.Background()) + require.NoError(t, err) + require.Len(t, fake.deployCalls, 1) + assert.Equal(t, "fullsend-mint", fake.deployCalls[0].workerName) +} + +// --- StoreAgentPEM tests --- + +func TestProvisioner_StoreAgentPEM(t *testing.T) { + fake := &fakeWranglerRunner{} + p := NewProvisioner(Config{ + AccountID: "abc123", + WorkerName: "test-mint", + }, fake) + + err := p.StoreAgentPEM(context.Background(), "coder", []byte("pem-data")) + require.NoError(t, err) + require.Len(t, fake.secretCalls, 1) + assert.Equal(t, "test-mint", fake.secretCalls[0].workerName) + assert.Equal(t, "CODER_APP_PEM", fake.secretCalls[0].secretName) + assert.Equal(t, []byte("pem-data"), fake.secretCalls[0].value) +} + +func TestProvisioner_StoreAgentPEM_InvalidRole(t *testing.T) { + fake := &fakeWranglerRunner{} + p := NewProvisioner(Config{ + AccountID: "abc123", + WorkerName: "test-mint", + }, fake) + + err := p.StoreAgentPEM(context.Background(), "INVALID", []byte("pem")) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid role name") +} + +func TestProvisioner_StoreAgentPEM_Error(t *testing.T) { + fake := &fakeWranglerRunner{ + secretPutErr: fmt.Errorf("api error"), + } + p := NewProvisioner(Config{ + AccountID: "abc123", + WorkerName: "test-mint", + }, fake) + + err := p.StoreAgentPEM(context.Background(), "coder", []byte("pem")) + require.Error(t, err) + assert.Contains(t, err.Error(), "storing PEM secret") +} + +// --- Teardown tests --- + +func TestProvisioner_Teardown_Preview(t *testing.T) { + fake := &fakeWranglerRunner{} + p := NewProvisioner(Config{ + AccountID: "abc123", + WorkerName: "test-mint-preview", + DeployMode: DeployPreview, + }, fake) + + err := p.Teardown(context.Background()) + require.NoError(t, err) + require.Len(t, fake.deleteCalls, 1) + assert.Equal(t, "test-mint-preview", fake.deleteCalls[0]) +} + +func TestProvisioner_Teardown_DurableRejectsCleanup(t *testing.T) { + fake := &fakeWranglerRunner{} + p := NewProvisioner(Config{ + AccountID: "abc123", + WorkerName: "test-mint", + DeployMode: DeployDurable, + }, fake) + + err := p.Teardown(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "only supported for preview") +} + +func TestProvisioner_Teardown_Error(t *testing.T) { + fake := &fakeWranglerRunner{ + deleteErr: fmt.Errorf("delete failed"), + } + p := NewProvisioner(Config{ + AccountID: "abc123", + WorkerName: "test-mint-preview", + DeployMode: DeployPreview, + }, fake) + + err := p.Teardown(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "deleting worker") +} + +// --- pemSecretName tests --- + +func TestPemSecretName(t *testing.T) { + tests := []struct { + role string + expect string + }{ + {"coder", "CODER_APP_PEM"}, + {"triage", "TRIAGE_APP_PEM"}, + {"review", "REVIEW_APP_PEM"}, + } + for _, tc := range tests { + t.Run(tc.role, func(t *testing.T) { + assert.Equal(t, tc.expect, pemSecretName(tc.role)) + }) + } +} + +// --- ValidateWorkerName tests --- + +func TestValidateWorkerName(t *testing.T) { + tests := []struct { + name string + valid bool + }{ + {"fullsend-mint", true}, + {"my-worker-123", true}, + {"ab", true}, + {"a", false}, // too short + {"UPPER", false}, // uppercase + {"has_underscore", false}, // underscore + {"-starts-with-hyphen", false}, // starts with hyphen + {"ends-with-hyphen-", false}, // ends with hyphen + {"", false}, // empty + {"a-very-long-worker-name-that-exceeds-the-maximum-allowed-length-of-63-chars", false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.valid, ValidateWorkerName(tc.name)) + }) + } +} + +// --- ValidateCloudflareEnv tests --- + +func TestValidateCloudflareEnv_Missing(t *testing.T) { + // Save and restore env vars. + origAccount := os.Getenv("CLOUDFLARE_ACCOUNT_ID") + origToken := os.Getenv("CLOUDFLARE_API_TOKEN") + defer func() { + os.Setenv("CLOUDFLARE_ACCOUNT_ID", origAccount) + os.Setenv("CLOUDFLARE_API_TOKEN", origToken) + }() + + os.Unsetenv("CLOUDFLARE_ACCOUNT_ID") + os.Unsetenv("CLOUDFLARE_API_TOKEN") + + err := ValidateCloudflareEnv() + require.Error(t, err) + assert.Contains(t, err.Error(), "CLOUDFLARE_ACCOUNT_ID") + assert.Contains(t, err.Error(), "CLOUDFLARE_API_TOKEN") +} + +func TestValidateCloudflareEnv_Present(t *testing.T) { + origAccount := os.Getenv("CLOUDFLARE_ACCOUNT_ID") + origToken := os.Getenv("CLOUDFLARE_API_TOKEN") + defer func() { + os.Setenv("CLOUDFLARE_ACCOUNT_ID", origAccount) + os.Setenv("CLOUDFLARE_API_TOKEN", origToken) + }() + + os.Setenv("CLOUDFLARE_ACCOUNT_ID", "test-account") + os.Setenv("CLOUDFLARE_API_TOKEN", "test-token") + + err := ValidateCloudflareEnv() + require.NoError(t, err) +} + +// --- Embed integrity tests --- + +func TestEmbeddedWorkerSource_ContainsRequiredFiles(t *testing.T) { + for _, path := range embeddedWorkerFiles { + t.Run(path, func(t *testing.T) { + data, err := embeddedWorkerSource.ReadFile(path) + require.NoError(t, err, "embedded file %s should be readable", path) + assert.NotEmpty(t, data, "embedded file %s should not be empty", path) + }) + } +} + +func TestExtractEmbeddedSource(t *testing.T) { + dir := t.TempDir() + err := extractEmbeddedSource(dir) + require.NoError(t, err) + + // Verify key files were extracted. + for _, name := range []string{"src/index.ts", "wrangler.toml", "package.json"} { + path := filepath.Join(dir, name) + info, err := os.Stat(path) + require.NoError(t, err, "expected %s to exist", name) + assert.True(t, info.Size() > 0, "expected %s to be non-empty", name) + } +} + +// --- validateSourceDir tests --- + +func TestValidateSourceDir_Valid(t *testing.T) { + dir := createFakeWorkerSourceDir(t) + err := validateSourceDir(dir) + require.NoError(t, err) +} + +func TestValidateSourceDir_MissingDir(t *testing.T) { + err := validateSourceDir("/nonexistent") + require.Error(t, err) + assert.Contains(t, err.Error(), "source-dir") +} + +func TestValidateSourceDir_MissingFile(t *testing.T) { + dir := t.TempDir() + // Create only some required files. + os.MkdirAll(filepath.Join(dir, "src"), 0o755) + os.WriteFile(filepath.Join(dir, "src/index.ts"), []byte("//ts"), 0o644) + os.WriteFile(filepath.Join(dir, "wrangler.toml"), []byte("name = \"test\""), 0o644) + // Missing package.json. + + err := validateSourceDir(dir) + require.Error(t, err) + assert.Contains(t, err.Error(), "package.json") +} + +// --- parseWorkerURL tests --- + +func TestParseWorkerURL(t *testing.T) { + tests := []struct { + name string + output string + expect string + }{ + { + "standard output", + "Published test-mint (0.5s)\nhttps://test-mint.workers.dev", + "https://test-mint.workers.dev", + }, + { + "with trailing punctuation", + "Deployed to https://my-worker.workers.dev.", + "https://my-worker.workers.dev", + }, + { + "no url in output", + "Some other output\nwithout a URL", + "", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := parseWorkerURL(tc.output, "test-mint") + assert.Equal(t, tc.expect, result) + }) + } +} + +// --- writeVersionTS tests --- + +func TestWriteVersionTS(t *testing.T) { + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, "src"), 0o755) + + err := writeVersionTS(dir, "2.0.0", "abc123") + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(dir, "src", "version.ts")) + require.NoError(t, err) + assert.Contains(t, string(data), `export const FULLSEND_VERSION = "2.0.0"`) + assert.Contains(t, string(data), `export const FULLSEND_COMMIT = "abc123"`) + assert.Contains(t, string(data), "Generated at deploy time") +} + +func TestWriteVersionTS_EmptyValues(t *testing.T) { + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, "src"), 0o755) + + err := writeVersionTS(dir, "", "") + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(dir, "src", "version.ts")) + require.NoError(t, err) + assert.Contains(t, string(data), `export const FULLSEND_VERSION = ""`) + assert.Contains(t, string(data), `export const FULLSEND_COMMIT = ""`) +} + +func TestWriteVersionTS_CreatesSrcDir(t *testing.T) { + dir := t.TempDir() + // Don't create src/ — writeVersionTS should create it. + + err := writeVersionTS(dir, "1.0.0", "fff") + require.NoError(t, err) + + _, err = os.Stat(filepath.Join(dir, "src", "version.ts")) + require.NoError(t, err) +} + +// --- DefaultWorkerSourceDir tests --- + +func TestDefaultWorkerSourceDir(t *testing.T) { + dir := DefaultWorkerSourceDir() + assert.Equal(t, filepath.Join("internal", "dispatch", "cf", "workersrc"), dir) +} + +// --- EmbeddedWorkerSource tests --- + +func TestEmbeddedWorkerSource_ReturnsFS(t *testing.T) { + fsys := EmbeddedWorkerSource() + require.NotNil(t, fsys) + // Verify we can read a known file through the returned FS. + data, err := fs.ReadFile(fsys, "workersrc/src/index.ts") + require.NoError(t, err) + assert.NotEmpty(t, data) +} + +// --- NewLiveWranglerRunner tests --- + +func TestNewLiveWranglerRunner(t *testing.T) { + runner := NewLiveWranglerRunner("test-account-id") + require.NotNil(t, runner) + assert.Equal(t, "test-account-id", runner.AccountID) +} + +// --- validateSourceDir not-a-directory --- + +func TestValidateSourceDir_NotADirectory(t *testing.T) { + // Create a file (not a directory) and pass it as source dir. + f := filepath.Join(t.TempDir(), "notadir") + require.NoError(t, os.WriteFile(f, []byte("content"), 0o644)) + + err := validateSourceDir(f) + require.Error(t, err) + assert.Contains(t, err.Error(), "not a directory") +} + +// --- LiveWranglerRunner error path tests --- +// +// These tests exercise the command-construction and error-handling +// code paths in the LiveWranglerRunner methods. They use an already- +// cancelled context so the exec call fails immediately without +// hitting the network. + +func TestLiveWranglerRunner_Deploy_CommandError(t *testing.T) { + dir := t.TempDir() + runner := &LiveWranglerRunner{AccountID: "test-account"} + + // Cancel context immediately so exec fails without running wrangler. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + envVars := map[string]string{"KEY": "value"} + _, err := runner.Deploy(ctx, dir, "test-worker", false, envVars) + require.Error(t, err) + assert.Contains(t, err.Error(), "wrangler deploy failed") +} + +func TestLiveWranglerRunner_PutSecret_CommandError(t *testing.T) { + runner := &LiveWranglerRunner{AccountID: "test-account"} + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := runner.PutSecret(ctx, "test-worker", "MY_SECRET", []byte("secret-value")) + require.Error(t, err) + assert.Contains(t, err.Error(), "wrangler secret put failed") +} + +func TestLiveWranglerRunner_Delete_CommandError(t *testing.T) { + runner := &LiveWranglerRunner{AccountID: "test-account"} + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := runner.Delete(ctx, "test-worker") + require.Error(t, err) + assert.Contains(t, err.Error(), "wrangler delete failed") +} + +// --- helpers --- + +func createFakeWorkerSourceDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, "src"), 0o755) + os.WriteFile(filepath.Join(dir, "src/index.ts"), []byte("export default {}"), 0o644) + os.WriteFile(filepath.Join(dir, "wrangler.toml"), []byte("name = \"test\""), 0o644) + os.WriteFile(filepath.Join(dir, "package.json"), []byte("{}"), 0o644) + return dir +} diff --git a/internal/dispatch/cf/workersrc/src/index.ts b/internal/dispatch/cf/workersrc/src/index.ts index d5dd2e563f..70ab53bdb0 100644 --- a/internal/dispatch/cf/workersrc/src/index.ts +++ b/internal/dispatch/cf/workersrc/src/index.ts @@ -26,6 +26,12 @@ // copied into this directory at build time. The Go class it exports // bootstraps the Go runtime and provides the import object required // by the WASM binary. +// Deploy-time version constants. The CF provisioner generates this file +// (writeVersionTS) with the version/commit stamped at deploy time — +// mirroring how the GCF provisioner writes version.go into the zip. +// The values are compiled into the Worker bundle so they cannot diverge +// from the deployed code via admin changes to environment variables. +import { FULLSEND_VERSION, FULLSEND_COMMIT } from "./version"; import "../wasm_exec.js"; // ES module import of the compiled WASM binary. Wrangler handles this @@ -158,6 +164,11 @@ function buildWasmConfig(env: Env): string { AllowedWorkflowFiles: env.ALLOWED_WORKFLOW_FILES ?? "", PerRepoWIFRepos: env.PER_REPO_WIF_REPOS ?? "", CustomRolePermissions: env.CUSTOM_ROLE_PERMISSIONS ?? "", + // Version constants are imported from the generated version.ts file + // (written by writeVersionTS at deploy time) rather than read from + // env vars, so they cannot diverge from the deployed code. + Version: FULLSEND_VERSION, + Commit: FULLSEND_COMMIT, }); } diff --git a/internal/dispatch/cf/workersrc/src/version.ts b/internal/dispatch/cf/workersrc/src/version.ts new file mode 100644 index 0000000000..906e3198df --- /dev/null +++ b/internal/dispatch/cf/workersrc/src/version.ts @@ -0,0 +1,5 @@ +// Generated at deploy time by the CF provisioner. Do not edit. +// Stub checked into the repo so typecheck/CI work without a deploy. +// writeVersionTS overwrites this file with stamped values at deploy time. +export const FULLSEND_VERSION = ""; +export const FULLSEND_COMMIT = ""; diff --git a/internal/dispatch/gcf/mintsrc/mintcore/config.go.embed b/internal/dispatch/gcf/mintsrc/mintcore/config.go.embed index 16b9fadc50..35e456758f 100644 --- a/internal/dispatch/gcf/mintsrc/mintcore/config.go.embed +++ b/internal/dispatch/gcf/mintsrc/mintcore/config.go.embed @@ -35,6 +35,14 @@ type WorkerConfig struct { // CustomRolePermissions is a JSON-encoded map of custom role permissions. // Same format as the CUSTOM_ROLE_PERMISSIONS environment variable. CustomRolePermissions string + + // Version is the fullsend semver stamped on the deployed Worker. + // For WASM deployments this is injected via the config JSON since + // the binary is precompiled and cannot embed version at compile time. + Version string + + // Commit is the git SHA stamped on the deployed Worker. + Commit string } // ParseWorkerConfig parses a WorkerConfig and returns a Handler. @@ -51,6 +59,17 @@ func ParseWorkerConfig(cfg WorkerConfig, pemAccessor PEMAccessor, oidcVerifier O return nil, fmt.Errorf("AllowedOrgs is required") } + // Stamp version metadata from the config so that /health and /status + // report the deployed version. For GCF deploys this is compiled into + // the source (version.go); for WASM deploys it arrives at runtime via + // the config JSON because the binary is precompiled. + if cfg.Version != "" { + Version = cfg.Version + } + if cfg.Commit != "" { + Commit = cfg.Commit + } + if cfg.CustomRolePermissions != "" { var perms map[string]map[string]string if err := json.Unmarshal([]byte(cfg.CustomRolePermissions), &perms); err != nil { diff --git a/internal/mintcore/config.go b/internal/mintcore/config.go index 16b9fadc50..35e456758f 100644 --- a/internal/mintcore/config.go +++ b/internal/mintcore/config.go @@ -35,6 +35,14 @@ type WorkerConfig struct { // CustomRolePermissions is a JSON-encoded map of custom role permissions. // Same format as the CUSTOM_ROLE_PERMISSIONS environment variable. CustomRolePermissions string + + // Version is the fullsend semver stamped on the deployed Worker. + // For WASM deployments this is injected via the config JSON since + // the binary is precompiled and cannot embed version at compile time. + Version string + + // Commit is the git SHA stamped on the deployed Worker. + Commit string } // ParseWorkerConfig parses a WorkerConfig and returns a Handler. @@ -51,6 +59,17 @@ func ParseWorkerConfig(cfg WorkerConfig, pemAccessor PEMAccessor, oidcVerifier O return nil, fmt.Errorf("AllowedOrgs is required") } + // Stamp version metadata from the config so that /health and /status + // report the deployed version. For GCF deploys this is compiled into + // the source (version.go); for WASM deploys it arrives at runtime via + // the config JSON because the binary is precompiled. + if cfg.Version != "" { + Version = cfg.Version + } + if cfg.Commit != "" { + Commit = cfg.Commit + } + if cfg.CustomRolePermissions != "" { var perms map[string]map[string]string if err := json.Unmarshal([]byte(cfg.CustomRolePermissions), &perms); err != nil { diff --git a/skills/mint-enroll/SKILL.md b/skills/mint-enroll/SKILL.md index 70c483fd5d..8741e1e161 100644 --- a/skills/mint-enroll/SKILL.md +++ b/skills/mint-enroll/SKILL.md @@ -16,8 +16,9 @@ triggers: # Mint Service Enrollment Enroll a new GitHub org or per-repo into the fullsend token mint using the -`fullsend mint` CLI. The mint is a stateless GCP Cloud Function that exchanges -GitHub OIDC JWTs for scoped GitHub App installation tokens. +`fullsend mint` CLI. The mint is a stateless service (deployed on GCP Cloud +Function or Cloudflare Worker) that exchanges GitHub OIDC JWTs for scoped +GitHub App installation tokens. Follow these steps in order. Do not skip steps.