Add `provisionhook.EnvMutator` extension point so out-of-tree plugins (e.g. github-app-auth, vault-secrets) can inject or override env vars right before container Start, without forking core or piling more provider-specific code into the handlers package. WorkspaceHandler gains an optional `envMutators *provisionhook.Registry` wired in via SetEnvMutators during boot. The hook fires after built-in secret loads + per-agent git identity, so plugins can both read what's already there and override anything they own (GIT_AUTHOR_*, GITHUB_TOKEN). A nil registry is a no-op via Registry.Run's nil-receiver branch — keeps the hot path a single nil compare and means existing flows stay green even with zero plugins registered. Mutator failure aborts provisioning and marks the workspace failed with the wrapped error in last_sample_error. Failing fast surfaces the cause to the operator instead of letting an agent boot into opaque "git push 401" loops it can never recover from on its own. Tests cover ordered execution, chained env visibility, first-error abort, nil-receiver no-op, nil-mutator drop, registration order, and concurrent register-vs-run safety (-race clean). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
138 lines
4.8 KiB
Go
138 lines
4.8 KiB
Go
// Package provisionhook is the public extension point that lets external
|
|
// plugins mutate the env map a workspace container will boot with, just
|
|
// before the provisioner calls Start(cfg).
|
|
//
|
|
// The package lives under pkg/ (not internal/) because plugins import it
|
|
// from outside this Go module. Anything outside pkg/ is core-only.
|
|
//
|
|
// # Why this exists
|
|
//
|
|
// Auth providers (GitHub App tokens, GitLab tokens, Bitbucket app
|
|
// passwords, internal PAT vaults), secret managers (Vault, AWS Secrets
|
|
// Manager, GCP Secret Manager), per-tenant config injectors, and
|
|
// observability sidecars all want to write env vars into the workspace
|
|
// container before it starts. Each is an OPTIONAL concern that only some
|
|
// deployments need. Hardcoding any of them in the platform binary
|
|
// violates the "core stays small, capabilities are plugins" principle
|
|
// (CEO 2026-04-16, after the monorepo → 44 sub-repos split).
|
|
//
|
|
// # Plugin shape
|
|
//
|
|
// A plugin implements EnvMutator and registers an instance with a
|
|
// Registry at platform startup. The provisioner calls Run(...) on the
|
|
// registry before each workspace container starts.
|
|
//
|
|
// Plugins live in their own Go modules + repos (e.g.
|
|
// github.com/Molecule-AI/molecule-ai-plugin-github-app-auth). Each
|
|
// plugin ships its own cmd/server/main.go that imports core's startup
|
|
// function + registers the plugin's mutator. Operators deploy the
|
|
// plugin binary instead of core's vanilla cmd/server when they want
|
|
// the plugin's behaviour.
|
|
//
|
|
// # Failure handling
|
|
//
|
|
// MutateEnv returning a non-nil error aborts the provision (workspace
|
|
// is marked 'failed', container never starts). Plugins should fail open
|
|
// on transient external-service errors (log + return nil) so a flaky
|
|
// upstream doesn't block agent provisioning. Reserve errors for hard
|
|
// config bugs that the operator must fix.
|
|
//
|
|
// # Concurrency
|
|
//
|
|
// Registry is safe for concurrent registration + execution. MutateEnv
|
|
// implementations should be safe to call from goroutines (the
|
|
// provisioner runs each workspace's provision in its own goroutine).
|
|
package provisionhook
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
)
|
|
|
|
// EnvMutator is implemented by plugins that want to inject env vars
|
|
// into a workspace container at provision time.
|
|
//
|
|
// - Name returns a stable identifier for logging / metrics. Should
|
|
// match the plugin's repo / module name (e.g. "github-app-auth").
|
|
// - MutateEnv receives the workspace ID, the create payload, and a
|
|
// mutable env map. It can read existing values, add new ones, or
|
|
// overwrite as needed. Mutations are visible to subsequent
|
|
// mutators in the chain (registration order).
|
|
type EnvMutator interface {
|
|
Name() string
|
|
MutateEnv(ctx context.Context, workspaceID string, env map[string]string) error
|
|
}
|
|
|
|
// Registry holds the ordered list of EnvMutator instances the
|
|
// provisioner runs before each workspace boot. Safe for concurrent
|
|
// registration + execution.
|
|
type Registry struct {
|
|
mu sync.RWMutex
|
|
mutators []EnvMutator
|
|
}
|
|
|
|
// NewRegistry returns an empty registry. The platform creates one at
|
|
// startup; plugins call Register on it.
|
|
func NewRegistry() *Registry {
|
|
return &Registry{}
|
|
}
|
|
|
|
// Register adds a mutator to the chain. Mutators run in registration
|
|
// order. Registering the same instance twice is allowed (it'll run
|
|
// twice) — the registry doesn't dedupe; that's the caller's
|
|
// responsibility if dedup matters.
|
|
func (r *Registry) Register(m EnvMutator) {
|
|
if m == nil {
|
|
return
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.mutators = append(r.mutators, m)
|
|
}
|
|
|
|
// Len reports how many mutators are registered. Used by the platform's
|
|
// boot log so operators can see which extension hooks are wired.
|
|
func (r *Registry) Len() int {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
return len(r.mutators)
|
|
}
|
|
|
|
// Names returns the names of registered mutators in registration order.
|
|
// Used by the boot log so operators can grep for which plugins are
|
|
// active.
|
|
func (r *Registry) Names() []string {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
names := make([]string, len(r.mutators))
|
|
for i, m := range r.mutators {
|
|
names[i] = m.Name()
|
|
}
|
|
return names
|
|
}
|
|
|
|
// Run calls every registered mutator in order. The first one to return
|
|
// a non-nil error aborts the chain — subsequent mutators do NOT run,
|
|
// and the error is returned to the caller (which marks the workspace
|
|
// failed).
|
|
//
|
|
// A nil registry is a no-op (returns nil) so the provisioner doesn't
|
|
// have to nil-check before calling.
|
|
func (r *Registry) Run(ctx context.Context, workspaceID string, env map[string]string) error {
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
r.mu.RLock()
|
|
mutators := make([]EnvMutator, len(r.mutators))
|
|
copy(mutators, r.mutators)
|
|
r.mu.RUnlock()
|
|
|
|
for _, m := range mutators {
|
|
if err := m.MutateEnv(ctx, workspaceID, env); err != nil {
|
|
return fmt.Errorf("provisionhook %q: %w", m.Name(), err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|