Files
tiny-forge/internal/workload/plugin/source.go
T
alexei.dolgolyov 8d6a527a2b refactor(workload): plugin architecture wave + apps UI + volume scopes
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>
2026-05-11 22:17:41 +03:00

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
}