Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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/0006-ordered-layer-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Installing fullsend into an org involves multiple concerns with ordering depende

Each installation concern is a `Layer` implementing `Install`, `Uninstall`, and `Analyze`. Layers are composed into an ordered `Stack`. Install runs layers forward; uninstall runs them in reverse; analyze runs them forward and collects reports.

The current stack order is: config-repo → workflows → secrets → dispatch-token → enrollment.
The current stack order is: config-repo → workflows → secrets → inference → dispatch-token → enrollment.

Each layer is idempotent — re-running install skips already-completed work. Uninstall collects all errors rather than stopping on the first, so partial teardown still makes progress. Each layer declares the OAuth scopes it needs via `RequiredScopes`, enabling a preflight check that fails early when the token lacks required permissions.

Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Infrastructure platform choice and configuration are specified in the adopting o
**Decided:**

- Forge abstraction: all forge operations go through the `forge.Client` interface, keeping the rest of the codebase forge-agnostic ([ADR 0005](ADRs/0005-forge-abstraction-layer.md)).
- Installation model: ordered layer stack (install forward, uninstall reverse, analyze for status reporting) with idempotent operations ([ADR 0006](ADRs/0006-ordered-layer-model.md)).
- Installation model: ordered layer stack (install forward, uninstall reverse, analyze for status reporting) with idempotent operations. Current stack: config-repo → workflows → secrets → inference → dispatch-token → enrollment ([ADR 0006](ADRs/0006-ordered-layer-model.md)).
- Cross-repo dispatch: `workflow_dispatch` with an org-level dispatch token replaces `workflow_call`, keeping App PEM secrets in the config repo ([ADR 0008](ADRs/0008-workflow-dispatch-for-cross-repo-dispatch.md)).
- Shim workflow security: `pull_request_target` prevents PR authors from modifying the shim to exfiltrate the dispatch token ([ADR 0009](ADRs/0009-pull-request-target-in-shim-workflows.md)).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@ Schema version of this document. For this SPEC, the value MUST be exactly `1`.

Dispatch backend for agent work. The only defined value in v1 is `github-actions`.

### `inference` (mapping, optional)

Configures the inference provider used by agents for LLM API access.

#### `inference.provider` (string, required when `inference` is present)

Inference backend for agent LLM calls. Defined values in v1:

- `vertex` — Google Cloud Vertex AI

When `inference` is present and `provider` is `vertex`, the install process provisions GCP service account credentials and stores them as repository secrets on `.fullsend` (see [ADR 0014](../adr-0014-github-apps-and-secrets/SPEC.md) §5).

### `defaults` (mapping, required)

Organization-wide defaults applied to repositories unless overridden elsewhere in this file.
Expand Down Expand Up @@ -84,6 +96,7 @@ A document is **valid v1** if and only if:
3. `dispatch.platform` is `github-actions`.
4. `defaults.max_implementation_retries` ≥ 0.
5. Every entry in `defaults.roles` is one of the four defined role literals.
6. If `inference` is present, `inference.provider` is one of the defined provider literals (`vertex`).

Per-repo `roles`, when present, are not checked by the v1 reference validator; tooling SHOULD still restrict values to the same role literals for consistency.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@
}
}
},
"inference": {
"type": "object",
"additionalProperties": false,
"required": ["provider"],
"properties": {
"provider": {
"type": "string",
"enum": ["vertex"]
}
}
},
"defaults": {
"type": "object",
"additionalProperties": false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,16 @@ For each role, the setup runner (`appsetup.Setup.Run`):

All of the following are **repository-level** Actions secrets and variables on **`.fullsend`** in the target organization.

| Kind | Name pattern | Value |
|----------|----------------------------------------|-------|
| Secret | `FULLSEND_<ROLE>_APP_PRIVATE_KEY` | PEM text of the GitHub App private key |
| Variable | `FULLSEND_<ROLE>_APP_ID` | Decimal string of the GitHub App numeric ID |
| Kind | Name pattern | Value | Layer |
|----------|----------------------------------------|-------|-------|
| Secret | `FULLSEND_<ROLE>_APP_PRIVATE_KEY` | PEM text of the GitHub App private key | secrets |
| Variable | `FULLSEND_<ROLE>_APP_ID` | Decimal string of the GitHub App numeric ID | secrets |
| Secret | `FULLSEND_GCP_SA_KEY_JSON` | GCP service account key JSON (when inference provider is `vertex`) | inference |
| Secret | `FULLSEND_GCP_PROJECT_ID` | GCP project identifier (when inference provider is `vertex`) | 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 and variable as above; if PEM is empty (reuse path), the implementation **must** skip writing that role’s secret/variable.
- 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`.

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

Expand Down
283 changes: 283 additions & 0 deletions docs/plans/vertex-inference-provisioning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
# Plan: Inference Provider Credential Provisioning (Vertex AI)

## Problem

Agents running in GitHub Actions need credentials to call an inference API (currently GCP Vertex AI). During `fullsend admin install`, we need to:

1. Provision or accept GCP service account credentials
2. Upload the credential JSON as `FULLSEND_GCP_SA_KEY_JSON` repo secret on `.fullsend` (NOT org-scoped)
3. Upload the GCP project ID as `FULLSEND_GCP_PROJECT_ID` repo secret on `.fullsend`
4. Record the chosen inference provider in `config.yaml`

This must be coded behind an abstract interface so we can support other inference providers (e.g., Bedrock, OpenAI) in the future.

## Three Modes of Operation

| Mode | Input | Actions |
|------|-------|---------|
| 1. Minimal | GCP project ID only | Create service account → create key → upload key + project ID |
| 2. Existing SA | GCP project ID + SA name | Verify SA exists → create key → upload key + project ID |
| 3. Pre-made key | GCP project ID + key JSON | Upload key + project ID |

## Config Change

Add `inference` section to `config.yaml`:

```yaml
version: "1"
dispatch:
platform: github-actions
inference:
provider: vertex
defaults:
# ...
```

## Implementation Plan

### Phase 1: Abstractions and Config

#### 1a. `internal/config/config.go` — Add inference config

- Add `InferenceConfig` struct with `Provider string` field.
- Add `InferenceConfig` field to `OrgConfig`.
- Add validation: provider must be one of `ValidProviders()` (initially just `"vertex"`).
- Update `NewOrgConfig` to accept an inference provider parameter.
- Update `config_test.go` with new validation cases.

#### 1b. `internal/inference/inference.go` — Provider interface

New package `internal/inference/` with:

```go
// Provider provisions inference credentials during install.
type Provider interface {
// Name returns the provider identifier (e.g. "vertex").
Name() string

// Provision acquires credentials and returns secrets to store.
// Returns a map of secret-name → secret-value pairs to store
// as repo secrets on .fullsend.
Provision(ctx context.Context) (map[string]string, error)

// Validate checks that stored credentials are functional (for Analyze).
Validate(ctx context.Context) error
}
```

#### 1c. `internal/inference/vertex/vertex.go` — Vertex implementation

New package `internal/inference/vertex/` with:

```go
type Config struct {
ProjectID string // required: GCP project ID
ServiceAccountName string // optional: existing SA name (mode 2)
CredentialJSON string // optional: pre-made key JSON (mode 3)
}

type Vertex struct {
cfg Config
gcpAPI GCPClient // interface for testability
}
```

**GCPClient interface** (for mocking in unit tests):

```go
type GCPClient interface {
// GetServiceAccount checks that a service account exists.
GetServiceAccount(ctx context.Context, projectID, saName string) error

// CreateServiceAccount creates a new service account.
CreateServiceAccount(ctx context.Context, projectID, saName, displayName string) error

// CreateServiceAccountKey generates a new JSON key for a service account.
CreateServiceAccountKey(ctx context.Context, projectID, saEmail string) ([]byte, error)
}
```

**LiveGCPClient**: Real implementation using GCP IAM REST API (or `google.golang.org/api/iam/v1`).

**Provision logic:**

```
if cfg.CredentialJSON != "" {
// Mode 3: key provided directly
return {"FULLSEND_GCP_SA_KEY_JSON": cfg.CredentialJSON, "FULLSEND_GCP_PROJECT_ID": cfg.ProjectID}
}

saName := cfg.ServiceAccountName
if saName == "" {
// Mode 1: create a new service account
saName = "fullsend-agent"
gcpAPI.CreateServiceAccount(ctx, cfg.ProjectID, saName, "Fullsend agent inference")
} else {
// Mode 2: verify existing SA
gcpAPI.GetServiceAccount(ctx, cfg.ProjectID, saName)
}

// Create key for the SA (modes 1 and 2)
saEmail := saName + "@" + cfg.ProjectID + ".iam.gserviceaccount.com"
keyJSON := gcpAPI.CreateServiceAccountKey(ctx, cfg.ProjectID, saEmail)
return {"FULLSEND_GCP_SA_KEY_JSON": keyJSON, "FULLSEND_GCP_PROJECT_ID": cfg.ProjectID}
```

### Phase 2: Layer

#### 2a. `internal/layers/inference.go` — InferenceLayer

New layer that runs after SecretsLayer (position 4, before DispatchTokenLayer):

```go
type InferenceLayer struct {
org string
client forge.Client
provider inference.Provider
ui *ui.Printer
}
```

- **Install**: Calls `provider.Provision(ctx)` to get secrets, then stores each as a repo secret on `.fullsend` via `client.CreateRepoSecret()`.
- **Uninstall**: No-op (secrets deleted with repo, same as SecretsLayer).
- **Analyze**: Checks that expected secrets exist (`FULLSEND_GCP_SA_KEY_JSON`, `FULLSEND_GCP_PROJECT_ID` for vertex).
- **RequiredScopes**: `["repo"]` for install/analyze.

**Layer ordering** (updated):

1. ConfigRepoLayer
2. WorkflowsLayer
3. SecretsLayer (agent app keys)
4. **InferenceLayer** (inference provider credentials) ← NEW
5. DispatchTokenLayer
6. EnrollmentLayer

Rationale: InferenceLayer needs `.fullsend` repo to exist (created by ConfigRepoLayer) and stores repo-level secrets (like SecretsLayer). It must run before EnrollmentLayer since enrolled repos will need these secrets available.

#### 2b. Unit tests: `internal/layers/inference_test.go`

Test all three modes using a fake GCPClient and forge.FakeClient:
- Mode 1: no SA name, no key → creates SA, creates key, uploads secrets
- Mode 2: SA name provided → verifies SA, creates key, uploads secrets
- Mode 3: key provided → uploads secrets directly (no GCP calls)
- Error cases: SA not found, key creation failure, secret upload failure
- Analyze: all present, none present, partial

### Phase 3: CLI Integration

#### 3a. `internal/cli/admin.go` — New flags and wiring

Add flags to `install` command:
- `--gcp-project` (string): GCP project ID
- `--gcp-service-account` (string): existing SA name (optional)
- `--gcp-credentials-file` (string): path to pre-made key JSON file (optional)

The flags determine which mode is used. Validation:
- If `--gcp-credentials-file` is set, `--gcp-service-account` is ignored (mode 3).
- `--gcp-project` is required when any GCP flag is provided.

Wire the inference provider into `buildLayerStack()` — add `inference.Provider` parameter.

The inference provider choice (`vertex`) gets written into `config.yaml` via the `InferenceConfig` field on `OrgConfig`.

#### 3b. Update `buildLayerStack` and all call sites

Add the InferenceLayer to the stack between SecretsLayer and DispatchTokenLayer. Update:
- `buildLayerStack()` in `admin.go`
- `runDryRun()` — build with a no-op/analyze-only provider
- `runUninstall()` — include InferenceLayer (no-op uninstall)
- `runAnalyze()` — include InferenceLayer for status checking
- `buildTestLayerStack()` in e2e tests

### Phase 4: Tests

#### 4a. Unit tests for `internal/inference/vertex/`

- `vertex_test.go`: Test Provision() for all 3 modes with a fake GCPClient.
- Test SA name defaulting, email construction, error handling.

#### 4b. Unit tests for `internal/layers/inference_test.go`

- Follow existing pattern from `secrets_test.go`.
- Use `forge.FakeClient` for secret storage verification.
- Use a fake inference.Provider that returns canned secrets.

#### 4c. Unit tests for `internal/config/config_test.go`

- Validate inference provider field parsing and validation.
- Test marshal/unmarshal round-trip with inference config.

#### 4d. E2E tests

**In CI**, the `E2E_HALFSEND_VERTEX_KEY` secret contains a pre-made GCP credential JSON. The e2e test will use **mode 3** (key provided directly), since:
- We can't create/delete service accounts in CI (would need org-level GCP permissions).
- A pre-created SA with a pre-made key is the safe, idempotent approach.

**E2E changes:**

- `e2e/admin/testutil.go`: Add `E2E_HALFSEND_VERTEX_KEY` env var loading. Also add `E2E_FULLSEND_GCP_PROJECT_ID` (or derive from the key JSON).
- `e2e/admin/admin_test.go`:
- In `runFullInstall()`: Create a vertex provider in mode 3 using the env var, add InferenceLayer to the test stack.
- In `verifyInstalled()`: Assert `FULLSEND_GCP_SA_KEY_JSON` and `FULLSEND_GCP_PROJECT_ID` repo secrets exist.
- In `verifyNotInstalled()`: Assert secrets are gone (deleted with repo).
- In `buildTestLayerStack()`: Accept and include inference provider/layer.
- The e2e test should `t.Skip` the inference portion if `E2E_HALFSEND_VERTEX_KEY` is not set (so existing CI doesn't break until the secret is wired into the workflow).

**Makefile**: Add `E2E_HALFSEND_VERTEX_KEY` to the env vars passed through to `go test` in the `e2e-test` target.

### Phase 5: Documentation Updates

#### 5a. `docs/ADRs/0006-ordered-layer-model.md` — Update layer stack ordering

This is the canonical ADR defining the layer model. The Consequences section lists the current stack as `config-repo → workflows → secrets → dispatch-token → enrollment`. Update to include InferenceLayer at position 4:

`config-repo → workflows → secrets → inference → dispatch-token → enrollment`

#### 5b. `docs/architecture.md` — Update architecture overview

References the layer stack ordering inline. Update the installation model description to reflect the new 6-layer stack.

#### 5c. `docs/normative/admin-install/v1/adr-0011-org-config-yaml/` — Extend config schema

- `SPEC.md`: Add `inference` as a new root-level key with `provider` field. Document that `provider` must be one of the valid providers (initially `vertex`).
- `config.schema.json`: Add `inference` object to the JSON schema with `provider` enum property.

#### 5d. `docs/normative/admin-install/v1/adr-0014-github-apps-and-secrets/SPEC.md` — Extend credential surface

Currently documents only GitHub App secrets (`FULLSEND_<ROLE>_APP_PRIVATE_KEY`, `FULLSEND_<ROLE>_APP_ID`). Add a new section or extend the secrets table to cover inference provider secrets stored on `.fullsend`:

- `FULLSEND_GCP_SA_KEY_JSON` — GCP service account key JSON (repo secret)
- `FULLSEND_GCP_PROJECT_ID` — GCP project identifier (repo secret)

Note: these are repo-level secrets on `.fullsend`, not org-level, consistent with the existing credential isolation pattern.

## File Summary

| File | Action |
|------|--------|
| `internal/config/config.go` | Modify — add InferenceConfig |
| `internal/config/config_test.go` | Modify — add inference validation tests |
| `internal/inference/inference.go` | Create — Provider interface |
| `internal/inference/vertex/vertex.go` | Create — Vertex provider + GCPClient interface |
| `internal/inference/vertex/vertex_test.go` | Create — Unit tests |
| `internal/inference/vertex/gcp.go` | Create — LiveGCPClient (real GCP API calls) |
| `internal/layers/inference.go` | Create — InferenceLayer |
| `internal/layers/inference_test.go` | Create — Unit tests |
| `internal/cli/admin.go` | Modify — flags, wiring, buildLayerStack |
| `internal/forge/fake.go` | No change needed (already supports repo secrets) |
| `e2e/admin/testutil.go` | Modify — add vertex key env var |
| `e2e/admin/admin_test.go` | Modify — add inference to install/verify |
| `Makefile` | Modify — pass E2E_HALFSEND_VERTEX_KEY |
| `docs/ADRs/0006-ordered-layer-model.md` | Modify — add InferenceLayer to stack ordering |
| `docs/architecture.md` | Modify — update layer stack reference |
| `docs/normative/.../adr-0011-.../SPEC.md` | Modify — add `inference` section |
| `docs/normative/.../adr-0011-.../config.schema.json` | Modify — add `inference` to schema |
| `docs/normative/.../adr-0014-.../SPEC.md` | Modify — add inference secrets to credential surface |

## Open Questions

1. **SA naming convention**: Should mode 1 use `fullsend-agent` as the default SA name, or something org-specific like `fullsend-{org}`?
2. **Key rotation**: Should we handle the case where the SA already has 10 keys (GCP limit)? Could list and delete oldest.
3. **GCP auth for SA creation**: ~~Should we use Application Default Credentials, or require a separate flag?~~ **Decided:** Use Application Default Credentials via `golang.org/x/oauth2/google.FindDefaultCredentials()`. This eliminates the PATH-hijack risk of shelling out to `gcloud` and is the standard Go approach.
4. **Vertex AI API enablement**: Should we check/enable the Vertex AI API on the project, or just document it as a prerequisite?
5. **Multiple inference providers**: The config supports `provider: vertex` but should we plan for `provider: bedrock` shape now, or keep the interface minimal and extend later?
Loading
Loading