Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/ADRs/0033-per-repo-installation-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ fullsend admin install <owner/repo> # 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)
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/admin/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
51 changes: 49 additions & 2 deletions internal/cli/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
105 changes: 105 additions & 0 deletions internal/cli/admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Loading