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/guides/admin/github-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <org>` 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.
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 @@ -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:

Expand Down
66 changes: 35 additions & 31 deletions internal/cli/mint.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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(&region, "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")

Expand All @@ -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")
Expand All @@ -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)
}
Expand All @@ -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 {
Expand All @@ -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)
}
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
Expand All @@ -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)
}
Expand Down Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -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")
}
Expand All @@ -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
}
Expand Down
48 changes: 34 additions & 14 deletions internal/cli/mint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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"},
)
Expand All @@ -286,23 +292,23 @@ func TestResolveEnrollAppIDs_ExplicitJSON_InvalidJSON(t *testing.T) {
_, err := resolveEnrollAppIDs(
`{invalid`,
nil,
"source-org",
"my-app-set",
"target-org",
[]string{"coder"},
)
require.Error(t, err)
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"},
)
Expand All @@ -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"},
)
Expand All @@ -331,28 +337,42 @@ func TestResolveEnrollAppIDs_NoExistingIDs(t *testing.T) {
_, err := resolveEnrollAppIDs(
"",
nil,
"source-org",
"my-app-set",
"target-org",
[]string{"coder"},
)
require.Error(t, err)
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 ---
Expand Down
16 changes: 8 additions & 8 deletions skills/mint-enroll/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ GCP_PROJECT="<your-gcp-project-id>"
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)") \
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
Loading