8d6a527a2b
Completes the workload-first refactor's plugin layer:
- internal/workload/plugin/ — Source/Trigger plugin contract,
registry, types (Workload, DeploymentIntent, InboundEvent,
PublicFace). Self-registering init() pattern + blank-import
in cmd/server/main.go.
- Source plugins: image (blue-green with multi-face proxy routing),
compose, static. Trigger plugins: registry, git, manual.
- internal/deployer/dispatch.go — DispatchPlugin/Teardown/Reconcile
seam routing the legacy deployer through plugins.
- internal/api/workload_*.go — REST surface: workloads, env,
volumes, chain (parent/children), promote-from. hooks.go
serves /api/hooks/kinds/{kind}/schema for the wizard.
- internal/store: workload_env (encrypt-at-rest secrets) and
workload_volumes tables, keyed on workload_id.
- cmd/server/static_backend.go — phantom-row adapter delegating
the static source plugin to the legacy staticsite.Manager
(deleted at hard cutover once the static inline port lands).
- web/src/routes/apps/ — /apps list + /apps/new wizard +
/apps/[id] detail with kind-aware compose / image / static
forms (Advanced JSON toggle), env panel, volumes panel,
webhook panel, chain panel, manual deploy.
Volume scope generalization (v2 resolver):
- internal/volume.ResolveWorkloadPath (workload-keyed, sits
next to legacy ResolvePath). Honors all VolumeScope values:
absolute, ephemeral, instance, stage, project, project_named,
named. internal/workload/plugin/source/image/image.go
computeMounts wires settings + imageTag through. Coverage in
internal/volume/resolver_test.go (portable Linux/Windows via
t.TempDir).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
115 lines
3.6 KiB
Go
115 lines
3.6 KiB
Go
package plugin
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sync"
|
|
)
|
|
|
|
// Source is the contract for one deployable shape (image, compose, static,
|
|
// ...). Implementations are stateless: every method receives Deps so the
|
|
// same value can serve concurrent deploys safely.
|
|
//
|
|
// A Source owns the full lifecycle of its containers — it is expected to
|
|
// reconcile rows in the containers index, register/deregister proxy
|
|
// routes via Deps.Proxy, and manage DNS via Deps.DNS. The deployer
|
|
// pipeline only chooses the right Source and feeds it a DeploymentIntent.
|
|
type Source interface {
|
|
// Kind is the registration key (e.g. "image", "compose", "static").
|
|
Kind() string
|
|
|
|
// Validate type-checks a raw config blob before it is persisted.
|
|
// Return a user-friendly error — the message is shown in the UI.
|
|
Validate(cfg json.RawMessage) error
|
|
|
|
// Deploy executes one deployment of w using intent. Whether this is a
|
|
// fresh start, an update, or a no-op is the Source's call: e.g. an
|
|
// image source short-circuits if the requested tag already runs.
|
|
Deploy(ctx context.Context, deps Deps, w Workload, intent DeploymentIntent) error
|
|
|
|
// Teardown removes everything Deploy created (containers, proxy
|
|
// routes, DNS, source-specific state). Idempotent.
|
|
Teardown(ctx context.Context, deps Deps, w Workload) error
|
|
|
|
// Reconcile brings the containers index in sync with reality. Called
|
|
// by the periodic reconciler — must be cheap when nothing changed.
|
|
Reconcile(ctx context.Context, deps Deps, w Workload) error
|
|
}
|
|
|
|
var (
|
|
sourcesMu sync.RWMutex
|
|
sources = map[string]Source{}
|
|
)
|
|
|
|
// RegisterSource installs s under s.Kind(). Panics on duplicate
|
|
// registration: that always indicates a bug in init() ordering, not a
|
|
// recoverable runtime condition.
|
|
func RegisterSource(s Source) {
|
|
sourcesMu.Lock()
|
|
defer sourcesMu.Unlock()
|
|
k := s.Kind()
|
|
if _, dup := sources[k]; dup {
|
|
panic(fmt.Sprintf("plugin: source %q already registered", k))
|
|
}
|
|
sources[k] = s
|
|
}
|
|
|
|
// GetSource returns the Source registered for kind, or an error mentioning
|
|
// the kind that was missing — useful when a workload row references a
|
|
// kind whose package was not blank-imported.
|
|
func GetSource(kind string) (Source, error) {
|
|
sourcesMu.RLock()
|
|
defer sourcesMu.RUnlock()
|
|
s, ok := sources[kind]
|
|
if !ok {
|
|
return nil, fmt.Errorf("plugin: no source registered for kind %q", kind)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// Schemaer is the optional interface a Source or Trigger may implement
|
|
// to surface a sample config blob. The /api/hooks/kinds/{kind}/schema
|
|
// endpoint uses this so frontends can render kind-aware forms without
|
|
// hardcoding samples per call-site. Plugins that don't implement it
|
|
// produce an empty object on the wire.
|
|
type Schemaer interface {
|
|
SchemaSample() any
|
|
}
|
|
|
|
// SchemaSampleFor returns the typed sample value declared by the plugin
|
|
// registered under kind, or nil if no sample is published.
|
|
func SchemaSampleFor(kind string) (any, bool) {
|
|
sourcesMu.RLock()
|
|
if s, ok := sources[kind]; ok {
|
|
sourcesMu.RUnlock()
|
|
if sm, ok := s.(Schemaer); ok {
|
|
return sm.SchemaSample(), true
|
|
}
|
|
return nil, true
|
|
}
|
|
sourcesMu.RUnlock()
|
|
triggersMu.RLock()
|
|
defer triggersMu.RUnlock()
|
|
if t, ok := triggers[kind]; ok {
|
|
if sm, ok := t.(Schemaer); ok {
|
|
return sm.SchemaSample(), true
|
|
}
|
|
return nil, true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// SourceKinds returns all registered source kinds, sorted for stable
|
|
// listing in /api/workloads/source-kinds.
|
|
func SourceKinds() []string {
|
|
sourcesMu.RLock()
|
|
defer sourcesMu.RUnlock()
|
|
out := make([]string, 0, len(sources))
|
|
for k := range sources {
|
|
out = append(out, k)
|
|
}
|
|
sortStrings(out)
|
|
return out
|
|
}
|