diff --git a/docs/ADRs/0033-per-repo-installation-mode.md b/docs/ADRs/0033-per-repo-installation-mode.md index 0f086c6db2..70661d8118 100644 --- a/docs/ADRs/0033-per-repo-installation-mode.md +++ b/docs/ADRs/0033-per-repo-installation-mode.md @@ -248,7 +248,7 @@ fullsend admin install # per-repo installation Per-repo flags: - `--inference-project` — GCP project for Vertex AI inference (required) - `--inference-region` — GCP region for Vertex AI inference (default: `global`) -- `--inference-wif-provider` — pre-existing WIF provider (auto-provisioned if omitted) +- `--inference-wif-provider` — full WIF provider resource name (`projects/{number}/locations/global/.../providers/{id}`); auto-provisioned if omitted Shared flags (valid for both per-org and per-repo): - `--mint-url` — token mint URL for OIDC token exchange (optional; auto-discovered from `--mint-project`/`--mint-region` if omitted) diff --git a/docs/guides/admin/installation.md b/docs/guides/admin/installation.md index b38903ba4b..4c55045e15 100644 --- a/docs/guides/admin/installation.md +++ b/docs/guides/admin/installation.md @@ -52,7 +52,7 @@ fullsend admin install "$ORG_NAME" \ --mint-project "$GCP_PROJECT" ``` -The installer automatically provisions [Workload Identity Federation (WIF)](https://cloud.google.com/iam/docs/workload-identity-federation) infrastructure (pool `fullsend-pool`, provider `github-oidc`, IAM bindings) in the inference project. WIF eliminates long-lived credentials — GitHub Actions exchange short-lived OIDC tokens for GCP access tokens. To use a pre-existing WIF provider instead, pass `--inference-wif-provider "$WIF_PROVIDER"` (see [Advanced: pre-configure WIF](#advanced-pre-configure-wif) below). +The installer automatically provisions [Workload Identity Federation (WIF)](https://cloud.google.com/iam/docs/workload-identity-federation) infrastructure (pool `fullsend-pool`, provider `github-oidc`, IAM bindings) in the inference project. WIF eliminates long-lived credentials — GitHub Actions exchange short-lived OIDC tokens for GCP access tokens. To use a pre-existing WIF provider instead, pass `--inference-wif-provider "$WIF_PROVIDER"` with the full resource name (`projects/{number}/locations/global/workloadIdentityPools/{pool}/providers/{id}`) — the CLI validates the format and skips auto-provisioning (see [Advanced: pre-configure WIF](#advanced-pre-configure-wif) below). `--mint-project` specifies the GCP project where the OIDC token mint Cloud Function is deployed. It can be the same project as `--inference-project` or a separate project. The installer automatically provisions a Cloud Function, WIF pool (`fullsend-pool`), WIF provider (`github-oidc`), and Secret Manager secrets in the mint project. A service account (`fullsend-mint`) is also created as the Cloud Function's runtime identity to access Secret Manager — this is internal infrastructure and does not require any admin setup. diff --git a/internal/cli/admin.go b/internal/cli/admin.go index 08355aab03..71e6b1279f 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -140,6 +140,24 @@ type perRepoInstallConfig struct { SkipMintCheck bool } +// wifProviderPattern validates the full WIF provider resource name format +// required by google-github-actions/auth@v3. +// GCP pool/provider IDs: 4-32 chars, [a-z0-9-], start with letter, no trailing hyphen. +var wifProviderPattern = regexp.MustCompile( + `^projects/\d+/locations/global/workloadIdentityPools/[a-z][a-z0-9-]{2,30}[a-z0-9]/providers/[a-z][a-z0-9-]{2,30}[a-z0-9]$`, +) + +func validateWIFProvider(raw string) error { + if !wifProviderPattern.MatchString(raw) { + return fmt.Errorf( + "--inference-wif-provider must be a full WIF provider resource name "+ + "(projects/{number}/locations/global/workloadIdentityPools/{pool}/providers/{id}), got %q", + raw, + ) + } + return nil +} + func validateMintURL(raw string) error { if err := validateMintURLHTTPS(raw); err != nil { return err @@ -222,7 +240,18 @@ Per-org mode (argument is an org name, e.g. "acme"): Per-repo mode (argument is owner/repo, e.g. "acme/widget"): Bootstraps a single repository with the shim workflow and .fullsend/ - configuration directory. No config repo or cross-repo dispatch needed.`, + configuration directory. No config repo or cross-repo dispatch needed. + +Inference authentication: + If --inference-project is provided without --inference-wif-provider, + fullsend auto-provisions WIF infrastructure in the GCP project + (requires project access with AI Platform permissions). + + If --inference-wif-provider is also provided with the full resource + name (projects/{number}/locations/global/workloadIdentityPools/{pool}/providers/{id}), + auto-provisioning is skipped and the value is used as-is. This is + useful when a GCP admin has already provisioned WIF and shared the + provider resource name.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { arg := args[0] @@ -311,6 +340,14 @@ Per-repo mode (argument is owner/repo, e.g. "acme/widget"): return fmt.Errorf("--inference-wif-provider and --inference-region require --inference-project to be set") } + // Validate WIF provider format when explicitly given. + if inferenceWIFProvider != "" { + if err := validateWIFProvider(inferenceWIFProvider); err != nil { + return err + } + printer.StepWarn("Using provided WIF provider value — skipping inference provider auto-provisioning") + } + // Auto-provision WIF when not explicitly given (idempotent: safe to re-run). if inferenceProject != "" && inferenceWIFProvider == "" { if dryRun { @@ -476,7 +513,7 @@ Per-repo mode (argument is owner/repo, e.g. "acme/widget"): cmd.Flags().BoolVar(&enrollNoneFlag, "enroll-none", false, "skip repository enrollment without prompting") cmd.Flags().StringVar(&inferenceProject, "inference-project", "", "GCP project ID for inference (Agent Platform)") cmd.Flags().StringVar(&inferenceRegion, "inference-region", "global", "GCP region for inference (default: global)") - cmd.Flags().StringVar(&inferenceWIFProvider, "inference-wif-provider", "", "WIF provider resource name (optional; auto-provisioned if omitted)") + cmd.Flags().StringVar(&inferenceWIFProvider, "inference-wif-provider", "", "full WIF provider resource name (projects/{number}/locations/global/workloadIdentityPools/{pool}/providers/{id}); skips auto-provisioning when set") cmd.Flags().StringVar(&mintProvider, "mint-provider", "gcf", "token mint provider (gcf)") cmd.Flags().StringVar(&mintProject, "mint-project", "", "cloud project for token mint (e.g. GCP project ID)") cmd.Flags().StringVar(&mintRegion, "mint-region", "us-central1", "cloud region for token mint") @@ -537,6 +574,12 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error { if inferenceProject == "" { return fmt.Errorf("--inference-project is required for per-repo installation") } + // Validate WIF provider format when explicitly given. + if inferenceWIFProvider != "" { + if err := validateWIFProvider(inferenceWIFProvider); err != nil { + return err + } + } roles, err := parseAgentRoles(agents) if err != nil { return err @@ -555,6 +598,10 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error { printer.Header("Installing per-repo fullsend for " + repoFullName) printer.Blank() + if inferenceWIFProvider != "" { + printer.StepWarn("Using provided WIF provider value — skipping inference provider auto-provisioning") + } + cfg := config.NewPerRepoConfig(roles) if err := cfg.Validate(); err != nil { return fmt.Errorf("invalid config: %w", err) diff --git a/internal/cli/admin_test.go b/internal/cli/admin_test.go index eac3aaea05..f9f32329b4 100644 --- a/internal/cli/admin_test.go +++ b/internal/cli/admin_test.go @@ -1373,3 +1373,108 @@ func TestValidateSkipMintCheck(t *testing.T) { require.Error(t, validateSkipMintCheck("http://example.com")) require.NoError(t, validateSkipMintCheck("https://mint.example.com/v1/token")) } + +func TestValidateWIFProvider_Valid(t *testing.T) { + valid := []string{ + "projects/123456789/locations/global/workloadIdentityPools/fullsend-pool/providers/gh-acme-widget", + "projects/999999999999/locations/global/workloadIdentityPools/my-pool-123/providers/my-provider-456", + "projects/1/locations/global/workloadIdentityPools/abcd/providers/efgh", + "projects/1/locations/global/workloadIdentityPools/a-very-long-pool-name-32-chars1/providers/a-very-long-prov-name-32-chars1", + } + for _, v := range valid { + t.Run(v, func(t *testing.T) { + require.NoError(t, validateWIFProvider(v)) + }) + } +} + +func TestValidateWIFProvider_Invalid(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"bare name", "standalone-fullsend"}, + {"missing projects prefix", "123/locations/global/workloadIdentityPools/pool/providers/prov"}, + {"partial path", "projects/123/locations/global/workloadIdentityPools/pool"}, + {"wrong location", "projects/123/locations/us-east1/workloadIdentityPools/pool/providers/prov"}, + {"non-numeric project", "projects/my-project/locations/global/workloadIdentityPools/pool/providers/prov"}, + {"empty string", ""}, + {"trailing slash", "projects/123/locations/global/workloadIdentityPools/pool/providers/prov/"}, + {"uppercase pool", "projects/123/locations/global/workloadIdentityPools/Pool/providers/prov"}, + {"pool too short (1 char)", "projects/123/locations/global/workloadIdentityPools/a/providers/abcd"}, + {"pool too short (3 chars)", "projects/123/locations/global/workloadIdentityPools/abc/providers/abcd"}, + {"provider too short (1 char)", "projects/123/locations/global/workloadIdentityPools/abcd/providers/a"}, + {"pool trailing hyphen", "projects/123/locations/global/workloadIdentityPools/abcd-/providers/abcd"}, + {"provider trailing hyphen", "projects/123/locations/global/workloadIdentityPools/abcd/providers/abcd-"}, + {"pool too long (33 chars)", "projects/123/locations/global/workloadIdentityPools/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/providers/abcd"}, + {"provider too long (33 chars)", "projects/123/locations/global/workloadIdentityPools/abcd/providers/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := validateWIFProvider(tc.input) + require.Error(t, err) + assert.Contains(t, err.Error(), "--inference-wif-provider must be a full WIF provider resource name") + }) + } +} + +func TestInstallCmd_PerOrgRejectsInvalidWIFProvider(t *testing.T) { + cmd := newRootCmd() + cmd.SetArgs([]string{"admin", "install", "acme", + "--dry-run", + "--inference-project", "my-project", + "--inference-wif-provider", "standalone-fullsend"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "--inference-wif-provider must be a full WIF provider resource name") +} + +func TestInstallCmd_PerRepoRejectsInvalidWIFProvider(t *testing.T) { + cmd := newRootCmd() + cmd.SetArgs([]string{"admin", "install", "acme/widget", + "--mint-url", "https://mint-test-abc123.run.app", + "--inference-project", "my-project", + "--inference-wif-provider", "just-a-name"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "--inference-wif-provider must be a full WIF provider resource name") +} + +func TestInstallCmd_PerOrgAcceptsValidWIFProvider(t *testing.T) { + cmd := newRootCmd() + cmd.SetArgs([]string{"admin", "install", "acme", + "--dry-run", + "--enroll-none", + "--inference-project", "my-project", + "--inference-wif-provider", "projects/123456789/locations/global/workloadIdentityPools/fullsend-pool/providers/github-oidc"}) + err := cmd.Execute() + // Per-org dry-run hits live GitHub API for repo listing; expect a downstream + // error but NOT a WIF validation error — proving validation passed. + if err != nil { + assert.NotContains(t, err.Error(), "--inference-wif-provider must be") + } +} + +func TestInstallCmd_PerRepoAcceptsValidWIFProvider(t *testing.T) { + cmd := newRootCmd() + cmd.SetArgs([]string{"admin", "install", "acme/widget", + "--mint-url", "https://mint-test-abc123.run.app", + "--inference-project", "my-project", + "--inference-wif-provider", "projects/123456789/locations/global/workloadIdentityPools/fullsend-pool/providers/github-oidc", + "--dry-run"}) + err := cmd.Execute() + require.NoError(t, err) +} + +func TestInstallCmd_SkipMintCheckStillValidatesWIFProvider(t *testing.T) { + cmd := newRootCmd() + cmd.SetArgs([]string{"admin", "install", "acme", + "--dry-run", + "--skip-mint-check", + "--mint-url", "https://mint.example.com/v1/token", + "--inference-project", "my-project", + "--inference-wif-provider", "standalone-fullsend"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "--inference-wif-provider must be a full WIF provider resource name") +}