Documentation
¶
Overview ¶
internal/controld/agentvault.go
internal/controld/api.go
internal/controld/attach.go
internal/controld/auth.go
Package controld is the self-hosted Rainier control plane: the HTTP and WebSocket surface, GitHub login, the secrets vault, and the runner plane, composed over the portable application services in github.com/tokencanopy/rainier/controlapp behind the frozen public github.com/tokencanopy/rainier/control contract.
The split is deliberate and the tests hold it: session lifecycle, in-pool placement, runner reconciliation and event application, and attach and workspace orchestration live once, in controlapp, and this package reaches them only through the four services New composes. What this package owns is the host side of each port (adapt_*.go) — the pool resolver over the one installation pool, the GitHub-role rule as the authorizer and attachment policy, the vault and connectors as launch material, the runner plane (github.com/tokencanopy/rainier/runnerplane) as the transport, the dial-back pairing as the attach broker — while the three repository ports are the store's own, implemented natively over its workspace-keyed rows and read off it by compose(). Plus everything with no portable counterpart: request decoding and JSON rendering, GitHub login and tokens, secrets and credentials, the sandbox's upward credential-mint RPC, and the setup_done snapshot arm.
The store is read directly, never for a decision, in a handful of places: an environment referenced by name, the missing-secret and missing-credential preflights at create, a pinned runner's free slots for a session's queue_reason, an environment's snapshot runner for its view, the count behind an in-use refusal, the session behind a runner's setup_done or credential_rejected, and the owner check and readiness wait that precede a terminal's WebSocket upgrade. The direct writes that remain are the runner heartbeat and disconnect flags, the environment snapshot after setup_done, credential status, secrets, users, and tokens — transport bookkeeping and host policy, never lifecycle.
internal/controld/memstore.go
internal/controld/runners.go
internal/controld/seal.go
internal/controld/srpc.go
Package controld defines controld's host-owned domain types and its Store persistence interface: the host's own persistence (identity, the vault, and the lookups the control ports lack) and the three control repository ports. Sessions, environments, and runners are control's types, not this package's. internal/controld/memstore.go and pgstore both implement Store, and both must pass controlapp/repotest and internal/controld/storetest unchanged — those suites, not this file's comments, are the source of truth for exact semantics.
internal/controld/vault.go
Index ¶
- Constants
- Variables
- func HashToken(tok string) string
- func NewEnvironmentID() string
- func NewSessionID() string
- func NewToken() (token, hash string)
- func NewUserID() string
- func Open(key [32]byte, ciphertext, nonce []byte) ([]byte, error)
- func OpenAAD(key [32]byte, ciphertext, nonce, aad []byte) ([]byte, error)
- func ParseSecretsKey(hexKey string) ([32]byte, error)
- func Seal(key [32]byte, plaintext []byte) (ciphertext, nonce []byte, err error)
- func SealAAD(key [32]byte, plaintext, aad []byte) (ciphertext, nonce []byte, err error)
- func SetupHash(image, setup string) string
- func SnapshotCheckpoint(ref string) control.Checkpoint
- type AgentCredential
- type AgentCredentialRows
- type AgentVault
- func (v *AgentVault) ConditionalRevokeAgentCredentials(ctx context.Context, user control.ActorID, provider string, expected uint64) (uint64, error)
- func (v *AgentVault) FetchAgentCredentials(ctx context.Context, user control.ActorID, provider string) (controlapp.AgentCredentialSet, error)
- func (v *AgentVault) ListAgentCredentials(ctx context.Context, user control.ActorID) ([]controlapp.AgentCredentialStatus, error)
- func (v *AgentVault) PutAgentCredentials(ctx context.Context, user control.ActorID, provider string, ...) (uint64, error)
- func (v *AgentVault) RevokeAgentCredentials(ctx context.Context, user control.ActorID, provider string) (uint64, error)
- type Config
- type Credential
- type HostStore
- type MemStore
- type Repositories
- type SecretMeta
- type Server
- type Store
- type User
Constants ¶
const ( CredentialValid = "valid" CredentialNeedsRefresh = "needs_refresh" )
Credential status vocabulary. A credential is either usable as far as anyone knows (CredentialValid) or known to have been rejected by the provider (CredentialNeedsRefresh) — there is no third state, because the vault never calls the provider to find out: it mints optimistically and only ever learns from an observed failure.
Variables ¶
var ErrCredentialMissing = errors.New(`no github credential — run: rainier login`)
ErrCredentialMissing is the other refusal: no credential at all for this user. It is deliberately distinct from ErrCredentialNeedsRefresh because the action differs — a first login, not a refresh — and a caller that conflated them would tell a user to refresh something they never had.
var ErrCredentialNeedsRefresh = errors.New(`github credential needs refresh — run: rainier login --refresh github`)
ErrCredentialNeedsRefresh is what a mint refuses with when the stored credential has been observed to fail: the value is still there, but something rejected it, so using it again would just fail again. The message names the exact command that fixes it — every named-action error in this plan says this same string.
var ErrDispatchTimeout = fmt.Errorf("timed out with the connection still live: %w", ErrRunnerUnreachable)
ErrDispatchTimeout is the one no-answer outcome that is NOT evidence the command went undelivered: OpTimeout elapsed while the control connection was still live, so the runner has the command and may well have executed it — controld just didn't hear back in time (a cold image pull routinely outlasts a 60s OpTimeout).
It wraps ErrRunnerUnreachable rather than standing alone, deliberately, so that every existing errors.Is(err, ErrRunnerUnreachable) call site keeps its behavior and only callers that ask specifically see the distinction:
- api.go's destroy/suspend/resume/snapshot handlers (4 sites) keep answering 502 `runner_unreachable`. That is the honest answer for a timeout too — controld cannot confirm the op either way, and the row is left exactly as the store had it, so the client's retry is against unchanged state. Splitting a second client-visible code here would tell the caller nothing it could act on differently.
- sched.go's dispatchCreate is the one caller that must branch: a requeue on a delivered-but-unconfirmed create is what births a duplicate container. See its doc comment.
var ErrRunnerUnreachable = errors.New("runner unreachable")
ErrRunnerUnreachable is what every dispatch that never produced an answer wraps: the runner has no control connection, the connection died before the result arrived, or OpTimeout elapsed first. Callers (the API in a later task) map it to the `runner_unreachable` error code — from the client's point of view those three are the same fact, "the runner did not answer", and the message text carries which one it was.
Functions ¶
func HashToken ¶
HashToken returns the hex-encoded SHA-256 hash of tok, as stored by InsertToken and looked up by UserByToken.
func NewEnvironmentID ¶
func NewEnvironmentID() string
NewEnvironmentID returns a fresh environment id: "env_" + 32 lowercase hex chars (16 random bytes).
func NewSessionID ¶
func NewSessionID() string
NewSessionID returns a fresh session id: "sess_" + 32 lowercase hex chars (16 random bytes).
func NewToken ¶
func NewToken() (token, hash string)
NewToken returns a fresh bearer token ("rnr_" + 64 lowercase hex chars, 32 random bytes) and the hex-encoded SHA-256 hash that InsertToken should store in its place — the raw token is never persisted.
func NewUserID ¶
func NewUserID() string
NewUserID returns a fresh user id: "usr_" + 32 lowercase hex chars (16 random bytes).
func Open ¶
Open decrypts and authenticates a value sealed by Seal. Every failure — the wrong key, a flipped ciphertext bit, a swapped or wrong-sized nonce — is errSecretAuth, and no failure ever returns partial plaintext: GCM authenticates before this function has anything to hand back.
func OpenAAD ¶ added in v0.0.3
OpenAAD is Open for a value sealed by SealAAD: it authenticates aad along with the ciphertext, so the WRONG aad is indistinguishable from the wrong key or a tampered byte — one flat errSecretAuth, and never partial plaintext.
func ParseSecretsKey ¶
ParseSecretsKey parses the operator-supplied RAINIER_SECRETS_KEY: exactly 64 lowercase-or-uppercase hex characters, decoding to the 32-byte AES-256 key. Its errors are printed at startup and must therefore never echo the key material itself — they say what was wrong with the shape, not what was supplied.
An all-zero key is rejected too. It is a fine 32 bytes as far as hex is concerned, but Config's zero value is how "no key configured" is represented, so accepting it would produce the one failure an operator cannot debug: a key that was definitely set, and a server that insists no key was set.
func Seal ¶
Seal encrypts plaintext under key with AES-256-GCM, returning the ciphertext (which carries GCM's authentication tag) and the fresh 12-byte nonce it was sealed with. Both are opaque bytes to the store.
The nonce is drawn from crypto/rand on every call and never reused: a repeated nonce under one GCM key is a total break, not a degradation, so this function has no "caller supplies the nonce" variant to misuse.
func SealAAD ¶ added in v0.0.3
SealAAD is Seal with additional authenticated data: aad is not encrypted and not stored, but the ciphertext will not open without exactly the same bytes. It is how a sealed row is BOUND to the identity it was sealed for — the agent vault binds (user, provider, version), so a row copied to another person, another provider, or another version is not a credential, it is noise that fails authentication (agentvault.go).
A nil aad is identical to Seal, which is why the two share this implementation rather than growing a second one: a value sealed before this function existed still opens, and always will.
func SetupHash ¶
SetupHash returns the identity of an environment's build inputs: the hex-encoded SHA-256 of image + "\x00" + setup. The NUL separator keeps the pair unambiguous, so no image/setup split can collide with another. A snapshot is a cache keyed by this hash: it is usable exactly while the environment's SetupHash still equals the hash it was built from.
func SnapshotCheckpoint ¶
func SnapshotCheckpoint(ref string) control.Checkpoint
SnapshotCheckpoint is the control spelling of a self-hosted environment snapshot — a runner-built image ref, the one format this build has.
Types ¶
type AgentCredential ¶ added in v0.0.3
type AgentCredential struct {
UserID, Provider string
Ciphertext, Nonce []byte
Version uint64
Revoked bool
LastRevokedVersion uint64
UpdatedAt time.Time
}
AgentCredential is one person's sealed credential set for one coding agent, keyed by (UserID, Provider) — one row per person per agent, and no workspace anywhere in it. That is the whole shape of "log in once and every workspace has it": the projection into workspaces happens above the store, at delivery, where membership is re-checked.
Ciphertext/Nonce are opaque here exactly as a Credential's are: the store never interprets them, and sealing happens above it (agentvault.go). Version counts puts and is part of what the ciphertext is sealed AGAINST, so a row whose version was edited no longer opens — see PutAgentCredential for the contract that keeps the two in step. No method on this type, and no error any store returns about one, may carry a credential value.
type AgentCredentialRows ¶ added in v0.0.3
type AgentCredentialRows interface {
// GetAgentCredential returns the row, or control.ErrNotFound.
GetAgentCredential(ctx context.Context, userID, provider string) (AgentCredential, error)
// PutAgentCredential inserts or replaces the row and returns the stored
// version.
//
// c.Version is the version the CALLER has already sealed c.Ciphertext
// for, and the write succeeds only when it is exactly one more than the
// stored row's (or when there is no stored row). Anything else is
// control.ErrConflict and nothing changes. That guard is what makes the
// stored version always the version the ciphertext was sealed against,
// under concurrency: two sessions of one person racing each other cannot
// both land on the same version, and the loser re-reads, re-seals, and
// tries again rather than storing a blob that will never open.
//
// A c.Version of 0 is control.ErrInvalid: versions start at 1, and 0 is
// the wire's word for "there is no set".
PutAgentCredential(ctx context.Context, c AgentCredential) (version uint64, err error)
// RevokeAgentCredential replaces the row with a tombstone whose monotonic
// version fences every put that began before logout. The fence remains in
// LastRevokedVersion after a later login. It returns the new version;
// revoking an absent row creates version 1.
RevokeAgentCredential(ctx context.Context, userID, provider string) (uint64, error)
ConditionalRevokeAgentCredential(ctx context.Context, userID, provider string, expected uint64) (uint64, error)
// ListAgentCredentials returns userID's rows ordered by provider, with
// Ciphertext and Nonce left NIL. A listing renders a status — provider,
// version, timestamp — so it never reads the sealed bytes at all, which
// is a stronger guarantee than clearing them afterwards would be.
ListAgentCredentials(ctx context.Context, userID string) ([]AgentCredential, error)
}
AgentCredentialRows is the slice of a store an agent vault needs: four methods over one table, and nothing else. It is named because the vault takes it rather than the whole HostStore — custody has no business reaching a bearer token or a session row, and a port sized to the job is how that stays true.
type AgentVault ¶ added in v0.0.3
type AgentVault struct {
// contains filtered or unexported fields
}
AgentVault is the self-hosted controlapp.AgentCredentialStore: plaintext file maps at its edges, sealed rows behind it.
func NewAgentVault ¶ added in v0.0.3
func NewAgentVault(rows AgentCredentialRows, key [32]byte) *AgentVault
NewAgentVault builds the store over rows and the fleet secrets key. It takes the narrow four-method port rather than the whole store: custody has no business reaching a bearer token, a secret, or a session row.
func (*AgentVault) ConditionalRevokeAgentCredentials ¶ added in v0.0.10
func (*AgentVault) FetchAgentCredentials ¶ added in v0.0.3
func (v *AgentVault) FetchAgentCredentials(ctx context.Context, user control.ActorID, provider string) (controlapp.AgentCredentialSet, error)
FetchAgentCredentials opens userID's set for provider. A set nobody has put is version 0 with no files. A revoke is a positive version with no files so it can fence earlier puts. Both mean "not logged in" and let the agent start.
A row that will not open is NOT reported as version 0. That would silently tell a person who is logged in that they are not, and would hide exactly the operator-visible condition (a changed fleet key, an edited row) worth seeing.
func (*AgentVault) ListAgentCredentials ¶ added in v0.0.3
func (v *AgentVault) ListAgentCredentials(ctx context.Context, user control.ActorID) ([]controlapp.AgentCredentialStatus, error)
ListAgentCredentials renders one status per stored set. It never opens a row — the store's listing does not even read the sealed columns — so there is no path here on which a listing could produce a byte of a credential.
func (*AgentVault) PutAgentCredentials ¶ added in v0.0.3
func (v *AgentVault) PutAgentCredentials(ctx context.Context, user control.ActorID, provider string, files map[string][]byte, expected uint64) (uint64, error)
PutAgentCredentials seals files for the version the row is about to have and stores them, returning that version. See this file's header for why the read, the seal, and the write are one compare-and-set loop rather than three independent steps.
func (*AgentVault) RevokeAgentCredentials ¶ added in v0.0.3
func (v *AgentVault) RevokeAgentCredentials(ctx context.Context, user control.ActorID, provider string) (uint64, error)
RevokeAgentCredentials replaces the set with a monotonic tombstone. The tombstone holds no credential bytes and fences puts that began before it.
type Config ¶
type Config struct {
// RunnerToken is the fleet-wide bearer every runnerd presents on
// /v0/runners/connect (and, later, on the attach-back dial).
RunnerToken string
// SecretsKey is the AES-256 key team secrets are sealed under at rest
// (seal.go), parsed from RAINIER_SECRETS_KEY by ParseSecretsKey. It is
// required and has no default: a zero key means "unconfigured", and New
// refuses to start rather than seal every team secret under a key of
// zeros. Losing it loses secret values and nothing else.
SecretsKey [32]byte
// Admins and Members are GitHub logins; a login in neither list cannot
// log in at all (fail closed — an empty allowlist admits nobody).
Admins []string
Members []string
// GitHubAPIBase is the base URL of GitHub's API, overridden in tests.
GitHubAPIBase string
// ExternalURL is the http(s) base URL that both clients and runners
// reach this replica at. The attach plane derives the runner's
// dial-back ws URL from it, so it must be absolute and routable from
// the fleet — not "localhost" on a multi-host deployment.
ExternalURL string
// OpTimeout is the budget for one dispatch round-trip to a runner.
OpTimeout time.Duration
// AttachWait bounds how long WS /v0/sessions/{id}/attach waits for the
// session to reach `running` before answering 503 session_not_ready.
// Zero means defaultAttachWait; tests shorten it.
AttachWait time.Duration
// AttachPairTTL bounds how long a parked client socket waits for its
// runner's dial-back before controld closes it. Zero means
// defaultAttachPairTTL; tests shorten it.
AttachPairTTL time.Duration
// MaxTransferBytes is the most this replica relays in one file transfer,
// either direction; zero means workspace.MaxBytes; tests lower it.
MaxTransferBytes int64
}
Config is controld's startup configuration. RunnerToken, ExternalURL, and SecretsKey are required; the rest default.
type Credential ¶
type Credential struct {
UserID, Provider string
Ciphertext, Nonce []byte // sealed access token
RefreshCiphertext, RefreshNonce []byte // nullable; unused in v0
Status string // CredentialValid | CredentialNeedsRefresh
Scopes string // informational; what the provider said it granted
ObtainedAt time.Time
ExpiresAt *time.Time // nil when the provider named no expiry
LastVerifiedAt time.Time
LastUsedAt time.Time
UpdatedAt time.Time
}
Credential is one user's sealed access credential for one provider, keyed by (UserID, Provider).
The store never interprets the sealed bytes: Ciphertext/Nonce (and the refresh pair, unused in v0) are opaque here exactly as a secret's are, and sealing happens above the store. No method on this type, and no error any store returns about one, may carry a credential value — a stack trace or a log line naming the token would defeat the vault entirely.
type HostStore ¶
type HostStore interface {
// EnsureWorkspace makes ws exist; idempotent. New calls it for the
// installation workspace, the repository contract suite for its two.
EnsureWorkspace(ctx context.Context, ws control.WorkspaceID) error
UpsertUser(ctx context.Context, githubID int64, login, role string) (User, error)
InsertToken(ctx context.Context, userID, tokenHash string) error
UserByToken(ctx context.Context, tokenHash string) (User, error)
GetUser(ctx context.Context, id string) (User, error)
PutSecret(ctx context.Context, name string, ciphertext, nonce []byte) error
ListSecrets(ctx context.Context) ([]SecretMeta, error)
GetSecret(ctx context.Context, name string) (ciphertext, nonce []byte, err error)
DeleteSecret(ctx context.Context, name string) error
UpsertCredential(ctx context.Context, c Credential) error
GetCredential(ctx context.Context, userID, provider string) (Credential, error)
SetCredentialStatus(ctx context.Context, userID, provider, status string) error
TouchCredentialUsed(ctx context.Context, userID, provider string) error
ListCredentials(ctx context.Context, userID string) ([]Credential, error)
// The agent credential rows, in the same vault the git credential lives
// in and under the same discipline. They are embedded rather than listed
// so the narrow port and the wide one cannot drift apart.
AgentCredentialRows
// EnvironmentByName resolves a name inside ws to the id the service is
// then asked for. The name index is a locator, never authority: the
// caller still fetches through the service, which authorizes.
EnvironmentByName(ctx context.Context, ws control.WorkspaceID, name string) (control.EnvironmentID, error)
// SnapshotRunner names the runner that built id's cached snapshot, ""
// when there is none — stale or not, because the wire has always shown
// the column. It decides nothing.
SnapshotRunner(ctx context.Context, ws control.WorkspaceID, id control.EnvironmentID) (control.RunnerID, error)
// SnapshotHolder names the runner holding the snapshot with ref in ws,
// "" when no environment's current cache has it. A cache whose hash no
// longer matches its environment's setup hash is stale and holds
// nothing, which is the difference between this and SnapshotRunner: this
// one is asked where a checkpoint can boot, and a stale image is not an
// answer to that. An empty workspace or ref is control.ErrInvalid.
SnapshotHolder(ctx context.Context, ws control.WorkspaceID, ref string) (control.RunnerID, error)
// NextRunnerGeneration opens a new generation for id in pool and returns
// it: 1 for a runner never seen, else one more than stored. It is the
// only writer of the generation the fleet repository fences on.
NextRunnerGeneration(ctx context.Context, pool control.PoolID, id control.RunnerID) (uint64, error)
}
HostStore is the persistence the self-hosted host owns beside the control repositories: identity (users, bearer tokens), the vault (secrets, credentials), and four lookups the control ports deliberately have no method for. Like the ports, every method here answers with the control sentinel set — control.ErrNotFound for a lookup that finds nothing, control.ErrConflict for a name already held — and never leaks SQL, a DSN, or a row's contents in an error.
type MemStore ¶
type MemStore interface {
Store
// Events returns a copy of every event recorded so far, in the order it
// was recorded. It exists for tests above the store: nothing in the
// process consumes an event, and the in-memory store is the durable home
// of nothing, so keeping them costs only what a test then reads back.
Events() []control.Event
}
MemStore is the shape the in-memory store has: Store itself, plus the one window no durable store owes anybody. It stays as its own name because every test helper is written in terms of it.
func NewMemStore ¶
func NewMemStore() MemStore
NewMemStore returns a fresh in-memory store with the installation workspace already provisioned, so a store from this source is usable before New re-asserts it.
type Repositories ¶
type Repositories interface {
Sessions() control.SessionRepository
Environments() control.EnvironmentRepository
Fleet() control.FleetRepository
}
Repositories is the three control repository ports a store offers over its own rows. Each accessor is a view, not a copy: the sessions Sessions() creates are the sessions Fleet() places, which is what makes one store satisfy the whole repository contract (controlapp/repotest).
type SecretMeta ¶
SecretMeta is everything about a stored secret except the secret: its name and timestamps. Listing secrets returns these, so no listing path can leak ciphertext — only GetSecret hands the bytes back.
type Server ¶
type Server struct {
// contains filtered or unexported fields
}
Server is controld: the HTTP/WebSocket surface, the runner plane, and (as later tasks land) the scheduler and attach plane. Its zero value is not usable — construct it with New.
func (*Server) Handler ¶
Handler returns controld's full HTTP surface: the runner control endpoint and attach dial-back, the auth exchange, the sessions/runners client API, and the client attach plane, all wrapped in the shared middleware chain (request id, nosniff, no-store on GET — see withMiddleware). Paths no route claims 404.
type Store ¶
type Store interface {
HostStore
control.UnitOfWork
control.EventRecorder
Repositories
}
Store is controld's persistence interface: the host's own persistence (HostStore), the host's atomicity and event record (control.UnitOfWork and control.EventRecorder), and the three control repository ports (Repositories). There is no third surface — sessions, environments, and runners exist once, as control types, and every caller reaches them through the ports.
The unit of work and the recorder are on the store rather than beside it because they are the same write: an event is durable exactly when the mutation it describes is, which only the thing holding the transaction can promise. memstore (this package) and pgstore both implement it; storetest.RunHost pins the host half's semantics, controlapp/repotest the ports'.