diff --git a/docs/guides/admin/github-setup.md b/docs/guides/admin/github-setup.md index bb6c566268..2f77c92721 100644 --- a/docs/guides/admin/github-setup.md +++ b/docs/guides/admin/github-setup.md @@ -36,7 +36,7 @@ fullsend separates infrastructure management into distinct roles. A single perso The typical workflow: a GCP admin runs `mint deploy` (one-time), `mint enroll` (once per new org or repo), and `inference provision` (to create WIF and grant Agent Platform access), then hands off the mint URL and WIF provider resource name to a GitHub maintainer who runs `github setup`. For users of the fullsend-hosted mint, `mint deploy` is already done — only `mint enroll` and `inference provision` are needed for new orgs (planned to be simplified in a future release). -**Ordering flexibility:** GCP operations (`mint deploy`, `mint enroll`, `inference provision`) are pure GCP — they do not interact with GitHub and do not require the GitHub Apps to be installed. `mint enroll` copies app IDs from the mint's existing configuration (defaulting to `--source-org=fullsend-ai`), not from the target org's GitHub installations. The GitHub Apps must be installed on the target org before **agents can run**, but the timing relative to GCP setup is flexible: +**Ordering flexibility:** GCP operations (`mint deploy`, `mint enroll`, `inference provision`) are pure GCP — they do not interact with GitHub and do not require the GitHub Apps to be installed. `mint enroll` copies app IDs from the mint's existing configuration (defaulting to `--app-set=fullsend-ai`), not from the target org's GitHub installations. The GitHub Apps must be installed on the target org before **agents can run**, but the timing relative to GCP setup is flexible: - **Per-org mode** — `github setup ` handles app installation interactively (opens a browser for each role), so a single org owner can run it without pre-installing apps. - **Per-org with `--skip-app-setup`** — an org owner must [pre-install the apps](#default-fullsend-ai-app-set-installation-urls) before running setup. diff --git a/docs/guides/admin/installation.md b/docs/guides/admin/installation.md index 3804ea2fdd..65225e467f 100644 --- a/docs/guides/admin/installation.md +++ b/docs/guides/admin/installation.md @@ -182,7 +182,7 @@ fullsend admin install "$ADDITIONAL_ORG" \ --mint-project "$GCP_PROJECT" ``` -The installer auto-detects shared public apps by matching installed app IDs against the mint's `ROLE_APP_IDS`. It copies PEM secrets from the source org to the new org's scoped key and records the actual app slug in `config.yaml`, so subsequent operations find the correct app regardless of naming convention. +The installer auto-detects shared public apps by matching installed app IDs against the mint's `ROLE_APP_IDS`. It copies PEM secrets from the app set to the new org's scoped key and records the actual app slug in `config.yaml`, so subsequent operations find the correct app regardless of naming convention. If the public apps were created with a custom `--app-set`, pass the same value so the CLI uses the correct slug prefix for convention-based lookups: diff --git a/internal/cli/mint.go b/internal/cli/mint.go index 1b1bd350a6..a9bcf05537 100644 --- a/internal/cli/mint.go +++ b/internal/cli/mint.go @@ -14,6 +14,7 @@ import ( "github.com/spf13/cobra" "golang.org/x/term" + "github.com/fullsend-ai/fullsend/internal/appsetup" "github.com/fullsend-ai/fullsend/internal/config" "github.com/fullsend-ai/fullsend/internal/dispatch/gcf" "github.com/fullsend-ai/fullsend/internal/ui" @@ -159,7 +160,7 @@ func newMintDeployCmd() *cobra.Command { func newMintEnrollCmd() *cobra.Command { var project string var region string - var sourceOrg string + var appSet string var roleAppIDs string var roles string var dryRun bool @@ -170,7 +171,7 @@ func newMintEnrollCmd() *cobra.Command { Long: `Performs full enrollment of an organization or per-repo into an existing mint. Per-org enrollment (fullsend mint enroll acme): - - Copies PEM secrets from the source org + - Copies PEM secrets from the app set - Registers the org in ALLOWED_ORGS and ROLE_APP_IDS - Re-derives ALLOWED_ROLES @@ -204,16 +205,19 @@ Per-repo enrollment (fullsend mint enroll acme/widget): printer.Blank() if strings.Contains(arg, "/") { - return runMintEnrollRepo(ctx, printer, arg, project, region, sourceOrg, roleAppIDs, roleList, dryRun) + return runMintEnrollRepo(ctx, printer, arg, project, region, appSet, roleAppIDs, roleList, dryRun) } - return runMintEnrollOrg(ctx, printer, arg, project, region, sourceOrg, roleAppIDs, roleList, dryRun) + return runMintEnrollOrg(ctx, printer, arg, project, region, appSet, roleAppIDs, roleList, dryRun) }, } cmd.Flags().StringVar(&project, "project", "", "GCP project ID (required)") cmd.Flags().StringVar(®ion, "region", "us-central1", "GCP region") - cmd.Flags().StringVar(&sourceOrg, "source-org", "fullsend-ai", "org to copy PEMs and app IDs from") - cmd.Flags().StringVar(&roleAppIDs, "role-app-ids", "", "explicit JSON map of role app IDs (overrides --source-org)") + cmd.Flags().StringVar(&appSet, "app-set", appsetup.DefaultAppSet, "app set to copy PEMs and app IDs from") + cmd.Flags().StringVar(&appSet, "source-org", appsetup.DefaultAppSet, "deprecated: use --app-set instead") + cmd.Flags().MarkDeprecated("source-org", "use --app-set instead") + cmd.Flags().MarkHidden("source-org") + cmd.Flags().StringVar(&roleAppIDs, "role-app-ids", "", "explicit JSON map of role app IDs (overrides --app-set)") cmd.Flags().StringVar(&roles, "roles", strings.Join(defaultMintRoles(), ","), "comma-separated roles to enroll") cmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview changes without making them") @@ -240,20 +244,20 @@ func parseAndResolveRoles(rolesStr string) ([]string, error) { return resolved, nil } -func runMintEnrollOrg(ctx context.Context, printer *ui.Printer, org, project, region, sourceOrg, roleAppIDsJSON string, roleList []string, dryRun bool) error { +func runMintEnrollOrg(ctx context.Context, printer *ui.Printer, org, project, region, appSet, roleAppIDsJSON string, roleList []string, dryRun bool) error { org = strings.ToLower(org) - sourceOrg = strings.ToLower(sourceOrg) + appSet = strings.ToLower(appSet) if err := validateOrgName(org); err != nil { return err } if org == gcf.PlaceholderOrg { return fmt.Errorf("cannot enroll reserved placeholder org %q", org) } - if err := validateOrgName(sourceOrg); err != nil { - return fmt.Errorf("invalid --source-org: %w", err) + if err := appsetup.ValidateAppSet(appSet); err != nil { + return fmt.Errorf("invalid --app-set: %w", err) } - if org == sourceOrg { - return fmt.Errorf("target org %q is the same as --source-org; nothing to enroll", org) + if org == appSet { + return fmt.Errorf("target org %q is the same as --app-set; nothing to enroll", org) } printer.Header("Enrolling org " + org + " in mint") @@ -276,7 +280,7 @@ func runMintEnrollOrg(ctx context.Context, printer *ui.Printer, org, project, re printer.StepDone(fmt.Sprintf("Found mint at %s", discovery.URL)) // Step 2: Resolve role->app-id mappings. - appIDs, err := resolveEnrollAppIDs(roleAppIDsJSON, discovery.RoleAppIDs, sourceOrg, org, roleList) + appIDs, err := resolveEnrollAppIDs(roleAppIDsJSON, discovery.RoleAppIDs, appSet, org, roleList) if err != nil { return fmt.Errorf("resolving app IDs: %w", err) } @@ -292,13 +296,13 @@ func runMintEnrollOrg(ctx context.Context, printer *ui.Printer, org, project, re } } printer.StepInfo(fmt.Sprintf(" Would add %s to ALLOWED_ORGS", org)) - printer.StepInfo(fmt.Sprintf(" Would copy PEMs from %s for %d roles", sourceOrg, len(roleList))) + printer.StepInfo(fmt.Sprintf(" Would copy PEMs from %s for %d roles", appSet, len(roleList))) printer.Blank() printer.StepInfo("To grant Agent Platform access, run 'fullsend inference provision' separately") return nil } - // Step 3: Copy PEM secrets from source org. + // Step 3: Copy PEM secrets from app set. for _, role := range roleList { exists, existsErr := provisioner.SecretExists(ctx, org, role) if existsErr != nil { @@ -308,8 +312,8 @@ func runMintEnrollOrg(ctx context.Context, printer *ui.Printer, org, project, re printer.StepDone(fmt.Sprintf("PEM exists: %s/%s", org, role)) continue } - printer.StepStart(fmt.Sprintf("Copying PEM for %s/%s from %s", org, role, sourceOrg)) - if err := provisioner.CopyAgentPEM(ctx, sourceOrg, org, role); err != nil { + printer.StepStart(fmt.Sprintf("Copying PEM for %s/%s from %s", org, role, appSet)) + if err := provisioner.CopyAgentPEM(ctx, appSet, org, role); err != nil { printer.StepFail(fmt.Sprintf("Failed to copy PEM for %s", role)) return fmt.Errorf("copying PEM for %s/%s: %w", org, role, err) } @@ -336,10 +340,10 @@ func runMintEnrollOrg(ctx context.Context, printer *ui.Printer, org, project, re return nil } -func runMintEnrollRepo(ctx context.Context, printer *ui.Printer, repoFullName, project, region, sourceOrg, roleAppIDsJSON string, roleList []string, dryRun bool) error { - sourceOrg = strings.ToLower(sourceOrg) - if err := validateOrgName(sourceOrg); err != nil { - return fmt.Errorf("invalid --source-org: %w", err) +func runMintEnrollRepo(ctx context.Context, printer *ui.Printer, repoFullName, project, region, appSet, roleAppIDsJSON string, roleList []string, dryRun bool) error { + appSet = strings.ToLower(appSet) + if err := appsetup.ValidateAppSet(appSet); err != nil { + return fmt.Errorf("invalid --app-set: %w", err) } repoFullName = strings.ToLower(repoFullName) parts := strings.SplitN(repoFullName, "/", 2) @@ -378,7 +382,7 @@ func runMintEnrollRepo(ctx context.Context, printer *ui.Printer, repoFullName, p printer.StepDone(fmt.Sprintf("Found mint at %s", discovery.URL)) // Step 2: Resolve role->app-id mappings. - appIDs, err := resolveEnrollAppIDs(roleAppIDsJSON, discovery.RoleAppIDs, sourceOrg, owner, roleList) + appIDs, err := resolveEnrollAppIDs(roleAppIDsJSON, discovery.RoleAppIDs, appSet, owner, roleList) if err != nil { return fmt.Errorf("resolving app IDs: %w", err) } @@ -394,7 +398,7 @@ func runMintEnrollRepo(ctx context.Context, printer *ui.Printer, repoFullName, p } } printer.StepInfo(fmt.Sprintf(" Would add %s to ALLOWED_ORGS", owner)) - printer.StepInfo(fmt.Sprintf(" Would copy PEMs from %s for %d roles", sourceOrg, len(roleList))) + printer.StepInfo(fmt.Sprintf(" Would copy PEMs from %s for %d roles", appSet, len(roleList))) printer.StepInfo(fmt.Sprintf(" Would add %s to PER_REPO_WIF_REPOS", repoFullName)) printer.StepInfo(fmt.Sprintf(" Would create WIF provider: %s", gcf.BuildRepoProviderID(owner, repo))) return nil @@ -410,8 +414,8 @@ func runMintEnrollRepo(ctx context.Context, printer *ui.Printer, repoFullName, p printer.StepDone(fmt.Sprintf("PEM exists: %s/%s", owner, role)) continue } - printer.StepStart(fmt.Sprintf("Copying PEM for %s/%s from %s", owner, role, sourceOrg)) - if err := provisioner.CopyAgentPEM(ctx, sourceOrg, owner, role); err != nil { + printer.StepStart(fmt.Sprintf("Copying PEM for %s/%s from %s", owner, role, appSet)) + if err := provisioner.CopyAgentPEM(ctx, appSet, owner, role); err != nil { printer.StepFail(fmt.Sprintf("Failed to copy PEM for %s", role)) return fmt.Errorf("copying PEM for %s/%s: %w", owner, role, err) } @@ -456,8 +460,8 @@ func runMintEnrollRepo(ctx context.Context, printer *ui.Printer, repoFullName, p // resolveEnrollAppIDs builds the org-scoped ROLE_APP_IDS map for enrollment. // If roleAppIDsJSON is provided, it is used directly. Otherwise, app IDs are -// resolved from the existing mint's ROLE_APP_IDS using the source org. -func resolveEnrollAppIDs(roleAppIDsJSON string, existingIDs map[string]string, sourceOrg, targetOrg string, roleList []string) (map[string]string, error) { +// resolved from the existing mint's ROLE_APP_IDS using the app set. +func resolveEnrollAppIDs(roleAppIDsJSON string, existingIDs map[string]string, appSet, targetOrg string, roleList []string) (map[string]string, error) { result := make(map[string]string, len(roleList)) if roleAppIDsJSON != "" { @@ -508,7 +512,7 @@ func resolveEnrollAppIDs(roleAppIDsJSON string, existingIDs map[string]string, s return result, nil } - // Resolve from existing ROLE_APP_IDS using the source org. + // Resolve from existing ROLE_APP_IDS using the app set. if len(existingIDs) == 0 { return nil, fmt.Errorf("no existing ROLE_APP_IDS found in mint — use --role-app-ids to provide explicitly") } @@ -521,11 +525,11 @@ func resolveEnrollAppIDs(roleAppIDsJSON string, existingIDs map[string]string, s continue } - // Look up the source org's app ID for this role. - sourceKey := sourceOrg + "/" + role + // Look up the app set's app ID for this role. + sourceKey := appSet + "/" + role appID, ok := existingIDs[sourceKey] if !ok { - return nil, fmt.Errorf("role %q not found in source org %q's ROLE_APP_IDS — use --role-app-ids to provide explicitly", role, sourceOrg) + return nil, fmt.Errorf("role %q not found in app set %q's ROLE_APP_IDS — use --role-app-ids to provide explicitly", role, appSet) } result[targetKey] = appID } diff --git a/internal/cli/mint_test.go b/internal/cli/mint_test.go index 053cfb3066..d31981d0f4 100644 --- a/internal/cli/mint_test.go +++ b/internal/cli/mint_test.go @@ -107,9 +107,15 @@ func TestMintEnrollCmd_Flags(t *testing.T) { require.NotNil(t, regionFlag, "expected --region flag") assert.Equal(t, "us-central1", regionFlag.DefValue) + appSetFlag := cmd.Flags().Lookup("app-set") + require.NotNil(t, appSetFlag, "expected --app-set flag") + assert.Equal(t, "fullsend-ai", appSetFlag.DefValue) + sourceOrgFlag := cmd.Flags().Lookup("source-org") - require.NotNil(t, sourceOrgFlag, "expected --source-org flag") + require.NotNil(t, sourceOrgFlag, "expected deprecated --source-org alias") assert.Equal(t, "fullsend-ai", sourceOrgFlag.DefValue) + assert.True(t, sourceOrgFlag.Hidden, "--source-org should be hidden") + assert.NotEmpty(t, sourceOrgFlag.Deprecated, "--source-org should have a deprecation message") roleAppIDsFlag := cmd.Flags().Lookup("role-app-ids") require.NotNil(t, roleAppIDsFlag, "expected --role-app-ids flag") @@ -273,7 +279,7 @@ func TestResolveEnrollAppIDs_ExplicitJSON(t *testing.T) { result, err := resolveEnrollAppIDs( `{"coder":"111","triage":"222"}`, nil, - "source-org", + "my-app-set", "target-org", []string{"coder", "triage"}, ) @@ -286,7 +292,7 @@ func TestResolveEnrollAppIDs_ExplicitJSON_InvalidJSON(t *testing.T) { _, err := resolveEnrollAppIDs( `{invalid`, nil, - "source-org", + "my-app-set", "target-org", []string{"coder"}, ) @@ -294,15 +300,15 @@ func TestResolveEnrollAppIDs_ExplicitJSON_InvalidJSON(t *testing.T) { assert.Contains(t, err.Error(), "parsing --role-app-ids") } -func TestResolveEnrollAppIDs_FromSourceOrg(t *testing.T) { +func TestResolveEnrollAppIDs_FromAppSet(t *testing.T) { existing := map[string]string{ - "source-org/coder": "111", - "source-org/triage": "222", + "my-app-set/coder": "111", + "my-app-set/triage": "222", } result, err := resolveEnrollAppIDs( "", existing, - "source-org", + "my-app-set", "target-org", []string{"coder", "triage"}, ) @@ -313,13 +319,13 @@ func TestResolveEnrollAppIDs_FromSourceOrg(t *testing.T) { func TestResolveEnrollAppIDs_TargetAlreadyRegistered(t *testing.T) { existing := map[string]string{ - "source-org/coder": "111", + "my-app-set/coder": "111", "target-org/coder": "999", } result, err := resolveEnrollAppIDs( "", existing, - "source-org", + "my-app-set", "target-org", []string{"coder"}, ) @@ -331,7 +337,7 @@ func TestResolveEnrollAppIDs_NoExistingIDs(t *testing.T) { _, err := resolveEnrollAppIDs( "", nil, - "source-org", + "my-app-set", "target-org", []string{"coder"}, ) @@ -339,20 +345,34 @@ func TestResolveEnrollAppIDs_NoExistingIDs(t *testing.T) { assert.Contains(t, err.Error(), "no existing ROLE_APP_IDS") } -func TestResolveEnrollAppIDs_RoleMissingFromSource(t *testing.T) { +func TestResolveEnrollAppIDs_RoleMissingFromAppSet(t *testing.T) { existing := map[string]string{ - "source-org/coder": "111", + "my-app-set/coder": "111", } _, err := resolveEnrollAppIDs( "", existing, - "source-org", + "my-app-set", "target-org", []string{"coder", "unknown-role"}, ) require.Error(t, err) assert.Contains(t, err.Error(), "unknown-role") - assert.Contains(t, err.Error(), "not found in source org") + assert.Contains(t, err.Error(), "not found in app set") +} + +// Covers per-repo enrollment where owner == appSet (e.g., fullsend-ai/repo --app-set=fullsend-ai). +// The org-level path blocks this case; repo-level allows it because the org owns the apps. +func TestResolveEnrollAppIDs_SelfEnroll(t *testing.T) { + result, err := resolveEnrollAppIDs( + "", + map[string]string{"my-app-set/coder": "111"}, + "my-app-set", + "my-app-set", + []string{"coder"}, + ) + require.NoError(t, err) + assert.Equal(t, "111", result["my-app-set/coder"], "self-enroll should reuse existing entry") } // --- confirmUnenroll tests --- diff --git a/skills/mint-enroll/SKILL.md b/skills/mint-enroll/SKILL.md index 7f9e30432e..1aa7ce084b 100644 --- a/skills/mint-enroll/SKILL.md +++ b/skills/mint-enroll/SKILL.md @@ -33,7 +33,7 @@ GCP_PROJECT="" MINT_FUNCTION="fullsend-mint" MINT_REGION="us-central1" WIF_POOL="fullsend-pool" -SOURCE_ORG="fullsend-ai" +APP_SET="fullsend-ai" SA_EMAIL="fullsend-mint@${GCP_PROJECT}.iam.gserviceaccount.com" GCP_PROJECT_NUMBER=$(gcloud projects describe "${GCP_PROJECT}" --format="value(projectNumber)") \ @@ -83,7 +83,7 @@ The fullsend-ai org maintains public GitHub Apps shared across orgs. | prioritize | fullsend-ai-prioritize | | PEM keys are tied to the app, not the org. Enrolling a new org copies PEMs -from a source org (e.g., `fullsend-ai`). +from the app set (e.g., `fullsend-ai`). Apps must be installed on the target org before the mint can produce tokens. An org admin installs via `https://github.com/apps/{slug}/installations/new` @@ -222,7 +222,7 @@ gcloud iam workload-identity-pools providers list \ ### 4. Copy PEM secrets -For shared apps, copy the PEM from the source org. Pipes directly to +For shared apps, copy the PEM from the app set. Pipes directly to avoid holding PEM material in shell variables. ```bash @@ -241,7 +241,7 @@ for ROLE in $(echo "${ROLES}" | tr ',' ' '); do echo "WARN: ${SECRET_ID} exists but has no active versions — re-adding" fi - SOURCE_SECRET="fullsend-${SOURCE_ORG}--${ROLE}-app-pem" + SOURCE_SECRET="fullsend-${APP_SET}--${ROLE}-app-pem" if ! gcloud secrets describe "${SOURCE_SECRET}" --project="${GCP_PROJECT}" >/dev/null 2>&1; then echo "ERROR: source secret ${SOURCE_SECRET} not found" >&2 @@ -340,9 +340,9 @@ else NEW_ALLOWED_ORGS="${CURRENT_ALLOWED_ORGS},${ORG}" fi -# Merge new entries into ROLE_APP_IDS (pin lookup to SOURCE_ORG) +# Merge new entries into ROLE_APP_IDS (pin lookup to APP_SET) NEW_ROLE_APP_IDS=$(echo "${CURRENT_ROLE_APP_IDS}" | \ - ORG="${ORG}" ROLES="${ROLES}" SOURCE_ORG="${SOURCE_ORG}" python3 -c " + ORG="${ORG}" ROLES="${ROLES}" APP_SET="${APP_SET}" python3 -c " import json, sys, os raw = sys.stdin.read().strip() try: @@ -352,10 +352,10 @@ except json.JSONDecodeError as e: sys.exit(1) org = os.environ['ORG'].lower() roles_csv = os.environ['ROLES'] -source_org = os.environ['SOURCE_ORG'].lower() +app_set = os.environ['APP_SET'].lower() for role in (r.strip() for r in roles_csv.split(',')): key = f'{org}/{role}' - source_key = f'{source_org}/{role}' + source_key = f'{app_set}/{role}' if key in data: print(f'SKIP: {key} already exists', file=sys.stderr) elif source_key in data: