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>
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
// Package compose implements the "compose" source: a docker-compose stack
|
||||
// deployed as a single logical unit. Multiple service containers may
|
||||
// result; each becomes one row in the containers index keyed by service
|
||||
// name in Container.Role.
|
||||
package compose
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/stack"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
)
|
||||
|
||||
// Config is the per-workload source config blob. ComposeYAML is the
|
||||
// authoritative spec — either inline (manual / paste-in flow) or fetched
|
||||
// by a git trigger and stashed here on each deploy. ComposeProjectName
|
||||
// is the `-p` arg passed to docker compose; defaults to a stable
|
||||
// workload-derived value when blank.
|
||||
type Config struct {
|
||||
ComposeYAML string `json:"compose_yaml"`
|
||||
ComposeProjectName string `json:"compose_project_name"`
|
||||
}
|
||||
|
||||
type source struct{}
|
||||
|
||||
func init() { plugin.RegisterSource(&source{}) }
|
||||
|
||||
func (*source) Kind() string { return "compose" }
|
||||
|
||||
func (*source) SchemaSample() any {
|
||||
return Config{
|
||||
ComposeYAML: "services:\n web:\n image: nginx:alpine\n ports:\n - \"80\"\n",
|
||||
}
|
||||
}
|
||||
|
||||
func (*source) Validate(cfg json.RawMessage) error {
|
||||
var c Config
|
||||
if len(cfg) == 0 {
|
||||
return fmt.Errorf("compose source: config is required")
|
||||
}
|
||||
if err := json.Unmarshal(cfg, &c); err != nil {
|
||||
return fmt.Errorf("compose source: invalid json: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(c.ComposeYAML) == "" {
|
||||
return fmt.Errorf("compose source: compose_yaml is required")
|
||||
}
|
||||
spec, err := stack.Parse(c.ComposeYAML)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compose source: parse yaml: %w", err)
|
||||
}
|
||||
if err := stack.Validate(spec); err != nil {
|
||||
return fmt.Errorf("compose source: validate yaml: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deploy writes the compose YAML to a stable per-workload path, runs
|
||||
// `docker compose -p <project> up -d`, then syncs one Container row per
|
||||
// service. The workload ID is the natural compose project name unless
|
||||
// the user supplied one explicitly.
|
||||
func (*source) Deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plugin.DeploymentIntent) error {
|
||||
cfg, err := plugin.SourceConfigOf[Config](w)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compose source: decode config: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(cfg.ComposeYAML) == "" {
|
||||
return fmt.Errorf("compose source: workload %s has empty compose_yaml", w.ID)
|
||||
}
|
||||
|
||||
projectName := composeProjectName(cfg.ComposeProjectName, w)
|
||||
yamlPath, err := writeYAML(w.ID, cfg.ComposeYAML)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compose source: write yaml: %w", err)
|
||||
}
|
||||
|
||||
compose := stack.NewCompose("")
|
||||
out, err := compose.Up(ctx, projectName, yamlPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compose source: docker compose up: %w (output: %s)", err, truncate(out, 1024))
|
||||
}
|
||||
|
||||
if err := syncContainers(ctx, deps, compose, w, projectName, yamlPath); err != nil {
|
||||
// `up` succeeded but we could not enumerate the resulting
|
||||
// containers — surface the failure so the UI does not show an
|
||||
// empty containers index for a running stack.
|
||||
return fmt.Errorf("compose source: sync container rows: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Teardown runs `docker compose down --remove-orphans -v` and drops the
|
||||
// container rows. Idempotent: missing compose project is treated as
|
||||
// already-down. Volume removal is intentional — workload teardown is
|
||||
// destructive by design (matches `DeleteStack(removeVolumes=true)`).
|
||||
func (*source) Teardown(ctx context.Context, deps plugin.Deps, w plugin.Workload) error {
|
||||
cfg, _ := plugin.SourceConfigOf[Config](w)
|
||||
projectName := composeProjectName(cfg.ComposeProjectName, w)
|
||||
|
||||
compose := stack.NewCompose("")
|
||||
if _, err := compose.Down(ctx, projectName, true); err != nil {
|
||||
// Log but proceed — the DB rows must not be orphaned.
|
||||
slog.Warn("compose source: docker compose down", "workload", w.ID, "error", err)
|
||||
}
|
||||
|
||||
// Best-effort: remove the YAML scratch dir.
|
||||
_ = os.RemoveAll(workloadDir(w.ID))
|
||||
|
||||
rows, err := deps.Store.ListContainersByWorkload(w.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compose source: list containers: %w", err)
|
||||
}
|
||||
for _, c := range rows {
|
||||
if err := deps.Store.DeleteContainer(c.ID); err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||
slog.Warn("compose source: delete container row", "id", c.ID, "error", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reconcile refreshes the containers index from `docker compose ps`. If
|
||||
// the compose project is unknown to Docker, container rows are marked
|
||||
// missing so the UI flags them. The reconciler hits this on every tick
|
||||
// per workload, so the YAML is only rewritten when its content has
|
||||
// actually changed.
|
||||
func (*source) Reconcile(ctx context.Context, deps plugin.Deps, w plugin.Workload) error {
|
||||
cfg, err := plugin.SourceConfigOf[Config](w)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compose source: decode config: %w", err)
|
||||
}
|
||||
projectName := composeProjectName(cfg.ComposeProjectName, w)
|
||||
yamlPath, _ := writeYAMLIfChanged(w.ID, cfg.ComposeYAML)
|
||||
|
||||
compose := stack.NewCompose("")
|
||||
services, err := compose.Ps(ctx, projectName, yamlPath)
|
||||
if err != nil {
|
||||
// Likely no compose project running for this workload. Mark
|
||||
// existing rows missing so the UI surfaces it.
|
||||
rows, _ := deps.Store.ListContainersByWorkload(w.ID)
|
||||
for _, c := range rows {
|
||||
_ = deps.Store.UpdateContainerState(c.ID, "missing")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, svc := range services {
|
||||
state := svc.State
|
||||
if state == "" {
|
||||
state = svc.Status
|
||||
}
|
||||
upsertServiceRow(deps, w, svc, state)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncContainers shares its body with Reconcile minus the missing-row
|
||||
// fallback — Deploy expects compose ps to succeed since `up` just ran.
|
||||
func syncContainers(ctx context.Context, deps plugin.Deps, compose *stack.Compose, w plugin.Workload, projectName, yamlPath string) error {
|
||||
services, err := compose.Ps(ctx, projectName, yamlPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compose ps: %w", err)
|
||||
}
|
||||
for _, svc := range services {
|
||||
state := svc.State
|
||||
if state == "" {
|
||||
state = svc.Status
|
||||
}
|
||||
upsertServiceRow(deps, w, svc, state)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func upsertServiceRow(deps plugin.Deps, w plugin.Workload, svc stack.Service, state string) {
|
||||
role := svc.Service
|
||||
if role == "" {
|
||||
role = svc.Name
|
||||
}
|
||||
if err := deps.Store.UpsertContainer(store.Container{
|
||||
ID: w.ID + ":" + role,
|
||||
WorkloadID: w.ID,
|
||||
WorkloadKind: "compose",
|
||||
Role: role,
|
||||
ContainerID: "", // reconciler fills via `docker ps` label join
|
||||
Host: "local",
|
||||
State: state,
|
||||
LastSeenAt: store.Now(),
|
||||
}); err != nil {
|
||||
slog.Warn("compose source: upsert container row", "workload", w.ID, "service", role, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// composeProjectName returns the `-p` argument for docker compose. We
|
||||
// always derive a stable name from the workload (sanitized + truncated
|
||||
// ID) when the user did not set ComposeProjectName, so re-deploys of the
|
||||
// same workload reuse the same project.
|
||||
var projectNameSanitizer = regexp.MustCompile(`[^a-z0-9_-]`)
|
||||
|
||||
func composeProjectName(explicit string, w plugin.Workload) string {
|
||||
if explicit != "" {
|
||||
return explicit
|
||||
}
|
||||
name := strings.ToLower(w.Name)
|
||||
name = projectNameSanitizer.ReplaceAllString(name, "-")
|
||||
name = strings.Trim(name, "-")
|
||||
if name == "" {
|
||||
name = "wkl"
|
||||
}
|
||||
idShort := w.ID
|
||||
if len(idShort) > 8 {
|
||||
idShort = idShort[:8]
|
||||
}
|
||||
return fmt.Sprintf("tf-%s-%s", name, idShort)
|
||||
}
|
||||
|
||||
// workloadDir is the per-workload scratch directory for compose YAML.
|
||||
func workloadDir(workloadID string) string {
|
||||
return filepath.Join(os.TempDir(), "tinyforge-compose", workloadID)
|
||||
}
|
||||
|
||||
// writeYAML writes the current compose YAML to a stable path under the
|
||||
// workload's scratch dir. Returns the path. Each deploy overwrites the
|
||||
// file — there are no revisions at the source level (the workload row is
|
||||
// the single source of truth; git or registry triggers update SourceConfig).
|
||||
//
|
||||
// Permissions are owner-only (0o700 / 0o600) because the YAML often
|
||||
// contains environment-section secrets and the dir lives in shared /tmp.
|
||||
func writeYAML(workloadID, yamlText string) (string, error) {
|
||||
dir := workloadDir(workloadID)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := filepath.Join(dir, "compose.yml")
|
||||
if err := os.WriteFile(path, []byte(yamlText), 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// writeYAMLIfChanged is writeYAML minus the disk write when the existing
|
||||
// file already matches yamlText. Used by Reconcile, which runs per
|
||||
// workload per tick; redundant fsync churn was a measurable cost.
|
||||
func writeYAMLIfChanged(workloadID, yamlText string) (string, error) {
|
||||
dir := workloadDir(workloadID)
|
||||
path := filepath.Join(dir, "compose.yml")
|
||||
if existing, err := os.ReadFile(path); err == nil && string(existing) == yamlText {
|
||||
return path, nil
|
||||
}
|
||||
return writeYAML(workloadID, yamlText)
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "...(truncated)"
|
||||
}
|
||||
Reference in New Issue
Block a user