Skip to content
Merged
122 changes: 117 additions & 5 deletions docs/guides/admin/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,93 @@ This guide walks through installing fullsend in a GitHub organization and enroll

- **GCP project** with the [Vertex AI API](https://console.cloud.google.com/apis/library/aiplatform.googleapis.com) enabled

## 1. Create a GCP service account key
The default region is `global`. For a list of all available regions, see the [Vertex AI documentation](https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude#regions).

## 1. Set up GCP authentication

Fullsend supports two methods for authenticating to Vertex AI. **Workload Identity Federation (WIF) is recommended** — it eliminates long-lived credentials entirely.

### Option A: Workload Identity Federation (recommended)

WIF lets GitHub Actions exchange short-lived OIDC tokens for GCP access tokens. No service account keys are stored.

**1a. Create a service account**

```bash
export GCP_PROJECT="<gcp-project>"
export ORG_NAME="<org-name>"

gcloud iam service-accounts create fullsend-agent \
--display-name="Fullsend agent inference" \
--project="$GCP_PROJECT"

gcloud projects add-iam-policy-binding "$GCP_PROJECT" \
--member="serviceAccount:fullsend-agent@$GCP_PROJECT.iam.gserviceaccount.com" \
--role="roles/aiplatform.user" \
--condition=None
```

**1b. Create a Workload Identity Pool and OIDC Provider**

```bash
gcloud iam workload-identity-pools create github-actions \
--location=global \
--display-name="GitHub Actions" \
--project="$GCP_PROJECT"

gcloud iam workload-identity-pools providers create-oidc github \
--location=global \
--workload-identity-pool=github-actions \
--issuer-uri="https://token.actions.githubusercontent.com" \
--attribute-mapping="google.subject=assertion.sub,attribute.repository_owner=assertion.repository_owner,attribute.repository=assertion.repository" \
--attribute-condition="assertion.repository_owner == '$ORG_NAME'" \
--project="$GCP_PROJECT"
```

The `attribute-condition` restricts which GitHub Actions workflows can exchange OIDC tokens for GCP credentials.

- **Org-wide** (`repository_owner`): any repo in the org can authenticate. Simpler to maintain but means a compromised or misconfigured workflow in *any* repo could obtain Vertex AI credentials.
- **Repo-scoped** (`repository`): only the `.fullsend` repo can authenticate. Limits blast radius — recommended for orgs where not all repos are equally trusted.

For repo-scoped access, replace the `attribute-condition` above with:

```bash
--attribute-condition="assertion.repository == '$ORG_NAME/.fullsend'"
```

If you choose repo-scoped access, also update the `--member` in step 1c to match:

```bash
--member="principalSet://iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/github-actions/attribute.repository/$ORG_NAME/.fullsend"
```

**1c. Grant the service account impersonation permission**

```bash
export PROJECT_NUMBER=$(gcloud projects describe "$GCP_PROJECT" --format='value(projectNumber)')

gcloud iam service-accounts add-iam-policy-binding \
"fullsend-agent@$GCP_PROJECT.iam.gserviceaccount.com" \
--role="roles/iam.workloadIdentityUser" \
--member="principalSet://iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/github-actions/attribute.repository_owner/$ORG_NAME" \
--project="$GCP_PROJECT"
```

**1d. Note the WIF provider resource name**

```bash
export WIF_PROVIDER="projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/github-actions/providers/github"
export WIF_SA_EMAIL="fullsend-agent@$GCP_PROJECT.iam.gserviceaccount.com"
```

### Option B: Service account key (legacy)

Create a service account with the `Vertex AI User` role and download its key:

```bash
export GCP_PROJECT="<gcp-project>"
export ORG_NAME="<org-name>"
export REPO_NAME="<repo-name>"
# gh repo create "$ORG_NAME/$REPO_NAME" --public

gcloud iam service-accounts create "$ORG_NAME" \
--display-name="Fullsend for $ORG_NAME" \
--project="$GCP_PROJECT"
Expand All @@ -39,8 +117,6 @@ gcloud iam service-accounts keys create sa-key.json \
--iam-account="$ORG_NAME@$GCP_PROJECT.iam.gserviceaccount.com"
```

Available regions for Claude on Vertex AI include `us-east5`, `europe-west1`, and `asia-southeast1`. Check the [Vertex AI documentation](https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude#regions) for the latest list.

## 2. Run the installer

The installer is interactive. It will open multiple browser windows to create and install a GitHub App for each agent role. Follow the prompts in each window to complete the app setup.
Expand All @@ -50,7 +126,24 @@ Near the end, the installer opens a browser to create a fine-grained personal ac
If the installer fails partway through, run `fullsend admin uninstall "$ORG_NAME"` to clean up before retrying. You will need to
refresh the permissions to add `delete_repo`: `gh auth refresh -s delete_repo`.

**With WIF (recommended):**

```bash
export REPO_NAME="<repo-name>"

fullsend admin install "$ORG_NAME" \
--repo "$REPO_NAME" \
--gcp-project "$GCP_PROJECT" \
--gcp-region global \
--gcp-wif-provider "$WIF_PROVIDER" \
--gcp-wif-sa-email "$WIF_SA_EMAIL"
```

**With SA key (legacy):**

```bash
export REPO_NAME="<repo-name>"

fullsend admin install "$ORG_NAME" \
--repo "$REPO_NAME" \
--gcp-project "$GCP_PROJECT" \
Expand All @@ -59,6 +152,25 @@ fullsend admin install "$ORG_NAME" \
rm sa-key.json
```

### Migrating from SA key to WIF

If you already have fullsend installed with a service account key:

1. Create the WIF resources (steps 1a–1d in Option A above)
2. Re-run the installer with WIF flags (the installer updates secrets in-place):
```bash
fullsend admin install "$ORG_NAME" \
--repo "$REPO_NAME" \
--skip-app-setup \
--gcp-project "$GCP_PROJECT" \
--gcp-region global \
--gcp-wif-provider "$WIF_PROVIDER" \
--gcp-wif-sa-email "$WIF_SA_EMAIL"
```
3. Verify a workflow run succeeds with WIF auth (check for "Authenticated using Workload Identity Federation" in the auth step output)
4. Delete the old SA key: `gcloud iam service-accounts keys delete <KEY_ID> --iam-account=...`
5. Remove the `FULLSEND_GCP_SA_KEY_JSON` secret from the `.fullsend` repo settings

**Note**: the `--repo` flag can be repeated to onboard multiple repositories.

## 3. Merge enrollment PRs
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,15 @@ All of the following are **repository-level** Actions secrets and variables on *
|----------|----------------------------------------|-------|-------|
| Secret | `FULLSEND_<ROLE>_APP_PRIVATE_KEY` | PEM text of the GitHub App private key | secrets |
| Variable | `FULLSEND_<ROLE>_CLIENT_ID` | GitHub App Client ID (e.g. `Iv23_...`), per [GitHub recommendation](https://github.blog/changelog/2024-05-01-github-apps-can-now-use-the-client-id-to-fetch-installation-tokens/) | secrets |
| Secret | `FULLSEND_GCP_SA_KEY_JSON` | GCP service account key JSON (when inference provider is `vertex`) | inference |
| Secret | `FULLSEND_GCP_SA_KEY_JSON` | GCP service account key JSON (SA key mode only) | inference |
| Secret | `FULLSEND_GCP_WIF_PROVIDER` | Full WIF provider resource name (WIF mode only) | inference |
| Secret | `FULLSEND_GCP_WIF_SA_EMAIL` | Service account email for WIF impersonation (WIF mode only) | inference |
| Secret | `FULLSEND_GCP_PROJECT_ID` | GCP project identifier (when inference provider is `vertex`) | inference |
| Variable | `FULLSEND_GCP_REGION` | GCP region for Vertex AI (e.g. `us-central1`) | inference |
| Variable | `FULLSEND_GCP_REGION` | GCP region for Vertex AI (e.g. `us-east5`) | inference |

- `<ROLE>` is the agent role in **ASCII uppercase** (e.g. `FULLSEND_TRIAGE_APP_PRIVATE_KEY`).
- For each role processed in install, if PEM is non-empty, the implementation **must** create/update the secret; if PEM is empty (reuse path), the implementation **must** skip writing that role’s secret. The Client ID variable is **always** written (even on reuse) to ensure it stays current.
- Inference secrets are only created when an inference provider is configured in `config.yaml` (see [ADR 0011](../adr-0011-org-config-yaml/SPEC.md)). When `inference.provider` is `vertex`, the implementation **must** store both `FULLSEND_GCP_SA_KEY_JSON` and `FULLSEND_GCP_PROJECT_ID`.
- Inference secrets are only created when an inference provider is configured in `config.yaml` (see [ADR 0011](../adr-0011-org-config-yaml/SPEC.md)). When `inference.provider` is `vertex`, the implementation **must** store `FULLSEND_GCP_PROJECT_ID` and either the SA key secret (`FULLSEND_GCP_SA_KEY_JSON`) or the WIF secrets (`FULLSEND_GCP_WIF_PROVIDER` and `FULLSEND_GCP_WIF_SA_EMAIL`). The two auth modes are mutually exclusive — WIF secrets and the SA key secret **must not** coexist. The WIF provider name and SA email are stored as secrets (not variables) so their values are masked in GitHub Actions logs.

## 6. Analyze / health semantics for the secrets layer

Expand Down
76 changes: 51 additions & 25 deletions internal/cli/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ func newInstallCmd() *cobra.Command {
var gcpRegion string
var gcpServiceAccount string
var gcpCredentialsFile string
var gcpWIFProvider string
var gcpWIFSAEmail string

cmd := &cobra.Command{
Use: "install <org>",
Expand Down Expand Up @@ -120,40 +122,55 @@ func newInstallCmd() *cobra.Command {
}

// Validate GCP flag dependencies.
if gcpProject == "" && (gcpServiceAccount != "" || gcpCredentialsFile != "" || gcpRegion != "") {
return fmt.Errorf("--gcp-service-account, --gcp-credentials-file, and --gcp-region require --gcp-project to be set")
if gcpProject == "" && (gcpServiceAccount != "" || gcpCredentialsFile != "" || gcpRegion != "" || gcpWIFProvider != "" || gcpWIFSAEmail != "") {
return fmt.Errorf("--gcp-service-account, --gcp-credentials-file, --gcp-wif-provider, --gcp-wif-sa-email, and --gcp-region require --gcp-project to be set")
}
if gcpProject != "" && gcpRegion == "" {
return fmt.Errorf("--gcp-region is required when --gcp-project is set")
}
if gcpWIFProvider != "" && gcpCredentialsFile != "" {
return fmt.Errorf("--gcp-wif-provider and --gcp-credentials-file are mutually exclusive: use WIF or SA key, not both")
}
if gcpWIFProvider != "" && gcpServiceAccount != "" {
return fmt.Errorf("--gcp-wif-provider and --gcp-service-account are mutually exclusive")
}
if (gcpWIFProvider != "") != (gcpWIFSAEmail != "") {
return fmt.Errorf("--gcp-wif-provider and --gcp-wif-sa-email must be provided together")
}

// Build inference provider from GCP flags.
var inferenceProvider inference.Provider
var inferenceProviderName string
if gcpProject != "" {
vcfg := vertex.Config{ProjectID: gcpProject, Region: gcpRegion, ServiceAccountName: gcpServiceAccount}
if gcpCredentialsFile != "" {
info, statErr := os.Lstat(gcpCredentialsFile)
if statErr != nil {
return fmt.Errorf("checking credentials file: %w", statErr)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("credentials file %s must be a regular file", gcpCredentialsFile)
}
credData, readErr := os.ReadFile(gcpCredentialsFile)
if readErr != nil {
return fmt.Errorf("reading credentials file: %w", readErr)
}
// Zero credential bytes when done to limit exposure in memory.
defer func() {
for i := range credData {
credData[i] = 0
vcfg := vertex.Config{ProjectID: gcpProject, Region: gcpRegion}
if gcpWIFProvider != "" {
vcfg.Mode = vertex.AuthModeWIF
vcfg.WIFProvider = gcpWIFProvider
vcfg.WIFServiceAccount = gcpWIFSAEmail
} else {
vcfg.ServiceAccountName = gcpServiceAccount
if gcpCredentialsFile != "" {
info, statErr := os.Lstat(gcpCredentialsFile)
if statErr != nil {
return fmt.Errorf("checking credentials file: %w", statErr)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("credentials file %s must be a regular file", gcpCredentialsFile)
}
credData, readErr := os.ReadFile(gcpCredentialsFile)
if readErr != nil {
return fmt.Errorf("reading credentials file: %w", readErr)
}
}()
if err := validateCredentialJSON(credData); err != nil {
return err
defer func() {
for i := range credData {
credData[i] = 0
}
}()
if err := validateCredentialJSON(credData); err != nil {
return err
}
vcfg.CredentialJSON = credData
}
vcfg.CredentialJSON = credData
}
inferenceProvider = vertex.New(vcfg, vertex.NewLiveGCPClient())
inferenceProviderName = "vertex"
Expand Down Expand Up @@ -189,6 +206,8 @@ func newInstallCmd() *cobra.Command {
cmd.Flags().StringVar(&gcpRegion, "gcp-region", "", "GCP region for Vertex AI (e.g. global, required with --gcp-project)")
cmd.Flags().StringVar(&gcpServiceAccount, "gcp-service-account", "", "existing GCP service account name (optional, used with --gcp-project)")
cmd.Flags().StringVar(&gcpCredentialsFile, "gcp-credentials-file", "", "path to pre-made GCP service account key JSON (optional, used with --gcp-project)")
cmd.Flags().StringVar(&gcpWIFProvider, "gcp-wif-provider", "", "full Workload Identity Federation provider resource name (e.g. projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL/providers/PROVIDER)")
cmd.Flags().StringVar(&gcpWIFSAEmail, "gcp-wif-sa-email", "", "GCP service account email for WIF impersonation (required with --gcp-wif-provider)")

return cmd
}
Expand Down Expand Up @@ -602,10 +621,17 @@ func runAnalyze(ctx context.Context, client forge.Client, printer *ui.Printer, o
return fmt.Errorf("getting authenticated user: %w", err)
}

// Detect inference provider from existing config.
// Detect inference provider and auth mode from existing config.
var inferenceProvider inference.Provider
if providerName := loadExistingInferenceProvider(ctx, client, org); providerName != "" {
inferenceProvider = vertex.NewAnalyzeOnly()
mode := vertex.AuthModeSAKey
wifExists, err := client.RepoSecretExists(ctx, org, forge.ConfigRepoName, vertex.SecretWIFProvider)
if err != nil {
printer.StepWarn(fmt.Sprintf("Could not check WIF secret: %v (defaulting to SA key mode)", err))
} else if wifExists {
mode = vertex.AuthModeWIF
}
inferenceProvider = vertex.NewAnalyzeOnly(mode)
}

stack := buildLayerStack(org, client, cfg, printer, user, hasPrivate, nil, agentCreds, nil, inferenceProvider, false, nil, nil)
Expand Down
6 changes: 6 additions & 0 deletions internal/cli/admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ func TestInstallCmd_Flags(t *testing.T) {
vendorBinaryFlag := cmd.Flags().Lookup("vendor-fullsend-binary")
require.NotNil(t, vendorBinaryFlag, "expected --vendor-fullsend-binary flag")
assert.Equal(t, "false", vendorBinaryFlag.DefValue)

wifProviderFlag := cmd.Flags().Lookup("gcp-wif-provider")
require.NotNil(t, wifProviderFlag, "expected --gcp-wif-provider flag")

wifSAEmailFlag := cmd.Flags().Lookup("gcp-wif-sa-email")
require.NotNil(t, wifSAEmailFlag, "expected --gcp-wif-sa-email flag")
}

func TestUninstallCmd_RequiresOrg(t *testing.T) {
Expand Down
8 changes: 8 additions & 0 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -635,8 +635,16 @@ func bootstrapEnv(sshConfigPath, sandboxName, repoDir string, h *harness.Harness
for _, hf := range h.HostFiles {
hostPath := os.ExpandEnv(hf.Src)
if hostPath == "" {
if hf.Optional {
continue
}
return fmt.Errorf("host_files: src %q expanded to empty string", hf.Src)
}
if hf.Optional {
if _, err := os.Stat(hostPath); err != nil {
continue
}
}

if hf.Expand {
// Read file, expand ${VAR} in content, write expanded version.
Expand Down
10 changes: 7 additions & 3 deletions internal/harness/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ var (
// Use this for env files that contain variable references which must be resolved
// on the host (because the sandbox does not have those variables set).
type HostFile struct {
Src string `yaml:"src"` // host path (may use ${VAR} expansion)
Dest string `yaml:"dest"` // destination path inside the sandbox
Expand bool `yaml:"expand,omitempty"` // expand ${VAR} in file content before copying
Src string `yaml:"src"` // host path (may use ${VAR} expansion)
Dest string `yaml:"dest"` // destination path inside the sandbox
Expand bool `yaml:"expand,omitempty"` // expand ${VAR} in file content before copying
Optional bool `yaml:"optional,omitempty"` // skip if src path is missing or expands to empty
}

// ProviderDef is a declarative definition of an OpenShell provider. Files in
Expand Down Expand Up @@ -367,6 +368,9 @@ func (h *Harness) ValidateRunnerEnvWith(expander func(string) string) error {
}
}
for i, hf := range h.HostFiles {
if hf.Optional {
continue
}
if err := checkVarRefs(fmt.Sprintf("host_files[%d].src", i), hf.Src); err != nil {
return err
}
Expand Down
Loading
Loading