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)"
|
||||
}
|
||||
@@ -0,0 +1,740 @@
|
||||
// Package image implements the "image" source: a single container pulled
|
||||
// from a registry. This is the canonical CI-driven shape — the registry
|
||||
// trigger feeds it new tags, and Deploy reconciles the running container
|
||||
// to match the requested tag.
|
||||
package image
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/moby/moby/api/types/mount"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/crypto"
|
||||
"github.com/alexei/tinyforge/internal/docker"
|
||||
"github.com/alexei/tinyforge/internal/proxy"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/volume"
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
)
|
||||
|
||||
// Config is the per-workload source config blob. Mirrors the deployment
|
||||
// fields that used to live on the projects + stages tables, less anything
|
||||
// that is now a Workload-level concern (notification config, webhook
|
||||
// secrets, public_face, group/parent).
|
||||
type Config struct {
|
||||
Image string `json:"image"` // fully-qualified, e.g. registry.example.com/owner/app
|
||||
RegistryName string `json:"registry_name"` // FK by name into registries table; "" = public/no auth
|
||||
Port int `json:"port"` // container's primary exposed port
|
||||
Healthcheck string `json:"healthcheck"` // HTTP path, e.g. "/healthz"; "" disables
|
||||
Env map[string]string `json:"env"` // injected as container env
|
||||
Volumes []VolumeMount `json:"volumes"`
|
||||
CpuLimit float64 `json:"cpu_limit"` // CPU cores; 0 = unlimited
|
||||
MemoryLimit int `json:"memory_limit"` // megabytes; 0 = unlimited
|
||||
DefaultTag string `json:"default_tag"` // tag used when intent.Reference is empty
|
||||
MaxInstances int `json:"max_instances"` // simultaneous containers to keep; 0/1 = strict blue-green
|
||||
}
|
||||
|
||||
// VolumeMount mirrors the existing store.Volume scope shape but as a flat
|
||||
// per-workload list. Future absolute / named-volume scopes can extend
|
||||
// this without schema changes.
|
||||
type VolumeMount struct {
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
Scope string `json:"scope"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type source struct{}
|
||||
|
||||
func init() { plugin.RegisterSource(&source{}) }
|
||||
|
||||
func (*source) Kind() string { return "image" }
|
||||
|
||||
// SchemaSample returns a populated example of Config so the frontend can
|
||||
// render kind-aware forms without hardcoding samples per call-site. Each
|
||||
// Source / Trigger exposes the same hook via plugin.SourceSchemaer /
|
||||
// plugin.TriggerSchemaer below.
|
||||
func (*source) SchemaSample() any {
|
||||
return Config{
|
||||
Image: "registry.example.com/owner/app",
|
||||
Port: 8080,
|
||||
Healthcheck: "/healthz",
|
||||
Env: map[string]string{},
|
||||
Volumes: []VolumeMount{},
|
||||
DefaultTag: "latest",
|
||||
MaxInstances: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func (*source) Validate(cfg json.RawMessage) error {
|
||||
var c Config
|
||||
if len(cfg) == 0 {
|
||||
return fmt.Errorf("image source: config is required")
|
||||
}
|
||||
if err := json.Unmarshal(cfg, &c); err != nil {
|
||||
return fmt.Errorf("image source: invalid json: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(c.Image) == "" {
|
||||
return fmt.Errorf("image source: image is required")
|
||||
}
|
||||
if c.Port < 0 || c.Port > 65535 {
|
||||
return fmt.Errorf("image source: port must be 0-65535")
|
||||
}
|
||||
for i, v := range c.Volumes {
|
||||
if strings.TrimSpace(v.Target) == "" {
|
||||
return fmt.Errorf("image source: volumes[%d].target is required", i)
|
||||
}
|
||||
if v.Scope == "" {
|
||||
return fmt.Errorf("image source: volumes[%d].scope is required", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deploy executes a blue-green deploy of w against the image tag implied
|
||||
// by intent. The flow:
|
||||
//
|
||||
// 1. Short-circuit if an existing container for this workload is already
|
||||
// running the requested ImageRef (duplicate webhook deliveries).
|
||||
// 2. Pull image, ensure network.
|
||||
// 3. Create + start a NEW container with a unique-per-deploy name (the
|
||||
// old container keeps serving traffic).
|
||||
// 4. Optional in-network healthcheck. Failure rolls back the new
|
||||
// container only — the old container is untouched.
|
||||
// 5. Register / update each public face's proxy route to point at the
|
||||
// new container.
|
||||
// 6. Enforce cfg.MaxInstances (default 1) by removing the oldest
|
||||
// surplus containers belonging to this workload. With MaxInstances=1
|
||||
// this is the "green" cutover — old container is removed only AFTER
|
||||
// the new face is live.
|
||||
//
|
||||
// Any failure between create and face-registration rolls back the new
|
||||
// container + its row; old serving state is preserved.
|
||||
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("image source: decode config: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(cfg.Image) == "" {
|
||||
return fmt.Errorf("image source: workload %s has empty image", w.ID)
|
||||
}
|
||||
|
||||
tag := intent.Reference
|
||||
if tag == "" {
|
||||
tag = cfg.DefaultTag
|
||||
}
|
||||
if tag == "" {
|
||||
tag = "latest"
|
||||
}
|
||||
imageRef := cfg.Image + ":" + tag
|
||||
|
||||
settings, err := deps.Store.GetSettings()
|
||||
if err != nil {
|
||||
return fmt.Errorf("image source: load settings: %w", err)
|
||||
}
|
||||
if settings.Network == "" {
|
||||
return fmt.Errorf("image source: settings.network is required")
|
||||
}
|
||||
|
||||
existing, err := deps.Store.ListContainersByWorkload(w.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("image source: list existing containers: %w", err)
|
||||
}
|
||||
|
||||
// Idempotency: if a container is already running the requested
|
||||
// ImageRef, short-circuit. Saves a pull + churn on duplicate webhook
|
||||
// deliveries (Gitea retries on flaky 5xx, etc.).
|
||||
for _, c := range existing {
|
||||
if c.ImageRef == imageRef && c.State == "running" && c.ContainerID != "" {
|
||||
if running, err := deps.Docker.IsContainerRunning(ctx, c.ContainerID); err == nil && running {
|
||||
slog.Info("image source: deploy skipped — already running",
|
||||
"workload", w.ID, "image", imageRef, "trigger", intent.Reason)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
authConfig, err := buildRegistryAuth(deps, cfg.RegistryName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("image source: %w", err)
|
||||
}
|
||||
if err := deps.Docker.PullImage(ctx, cfg.Image, tag, authConfig); err != nil {
|
||||
slog.Warn("image source: pull failed", "image", imageRef, "error", err)
|
||||
return fmt.Errorf("image source: pull %s failed", imageRef)
|
||||
}
|
||||
|
||||
networkID, err := deps.Docker.EnsureNetwork(ctx, settings.Network)
|
||||
if err != nil {
|
||||
return fmt.Errorf("image source: ensure network: %w", err)
|
||||
}
|
||||
|
||||
// Unique-per-deploy name so the new container can run alongside the
|
||||
// old one. The suffix is monotonic ms; collisions are not a real
|
||||
// concern for human-driven or webhook-driven deploys.
|
||||
containerName := buildContainerName(w.Name, w.ID, tag, time.Now())
|
||||
|
||||
cc := docker.ContainerConfig{
|
||||
Name: containerName,
|
||||
Image: imageRef,
|
||||
Env: buildEnv(deps, w, cfg),
|
||||
ExposedPorts: []string{fmt.Sprintf("%d/tcp", cfg.Port)},
|
||||
NetworkName: settings.Network,
|
||||
NetworkID: networkID,
|
||||
WorkloadID: w.ID,
|
||||
WorkloadKind: "image",
|
||||
Role: "image",
|
||||
Mounts: computeMounts(deps, w, cfg, tag, settings),
|
||||
CpuLimit: cfg.CpuLimit,
|
||||
MemoryLimit: cfg.MemoryLimit,
|
||||
}
|
||||
|
||||
// Per-face proxy labels (Traefik picks these up; NPM ignores them).
|
||||
primary := primaryFace(w.PublicFaces)
|
||||
for _, face := range w.PublicFaces {
|
||||
if !faceEnabled(face) {
|
||||
continue
|
||||
}
|
||||
port := face.TargetPort
|
||||
if port == 0 {
|
||||
port = cfg.Port
|
||||
}
|
||||
fqdn := fqdnFor(face, settings.Domain)
|
||||
if labels := deps.Proxy.ContainerLabels(fqdn, port); labels != nil {
|
||||
if cc.Labels == nil {
|
||||
cc.Labels = map[string]string{}
|
||||
}
|
||||
for k, v := range labels {
|
||||
cc.Labels[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dockerID, err := deps.Docker.CreateContainer(ctx, cc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("image source: create container: %w", err)
|
||||
}
|
||||
|
||||
row := store.Container{
|
||||
WorkloadID: w.ID,
|
||||
WorkloadKind: "image",
|
||||
Role: "image",
|
||||
ContainerID: dockerID,
|
||||
ImageRef: imageRef,
|
||||
ImageTag: tag,
|
||||
Host: "local",
|
||||
State: "stopped",
|
||||
Port: cfg.Port,
|
||||
Subdomain: primary.Subdomain,
|
||||
}
|
||||
created, err := deps.Store.CreateContainer(row)
|
||||
if err != nil {
|
||||
_ = deps.Docker.RemoveContainer(ctx, dockerID, true)
|
||||
return fmt.Errorf("image source: persist container row: %w", err)
|
||||
}
|
||||
|
||||
// Cleanup helper: roll back only the NEW container we just created.
|
||||
// Old containers are left running so a failed deploy is non-disruptive.
|
||||
rollbackNew := func(reason string, src error) error {
|
||||
_ = deps.Docker.RemoveContainer(ctx, dockerID, true)
|
||||
if delErr := deps.Store.DeleteContainer(created.ID); delErr != nil && !errors.Is(delErr, store.ErrNotFound) {
|
||||
slog.Warn("image source: rollback delete row",
|
||||
"workload", w.ID, "row", created.ID, "stage", reason, "error", delErr)
|
||||
}
|
||||
return fmt.Errorf("image source: %s: %w", reason, src)
|
||||
}
|
||||
|
||||
if err := deps.Docker.StartContainer(ctx, dockerID); err != nil {
|
||||
return rollbackNew("start container", err)
|
||||
}
|
||||
if err := deps.Store.UpdateContainerState(created.ID, "running"); err != nil {
|
||||
slog.Warn("image source: update container state", "workload", w.ID, "error", err)
|
||||
}
|
||||
|
||||
// Optional in-network healthcheck. Failure rolls back the new
|
||||
// container; the old one keeps serving via its existing proxy face.
|
||||
if cfg.Healthcheck != "" && deps.Health != nil {
|
||||
healthURL := fmt.Sprintf("http://%s:%d%s", containerName, cfg.Port, cfg.Healthcheck)
|
||||
if err := deps.Health.Check(ctx, healthURL); err != nil {
|
||||
return rollbackNew(fmt.Sprintf("health check %s", healthURL), err)
|
||||
}
|
||||
}
|
||||
|
||||
// Switch each public face to the new container. ConfigureRoute is
|
||||
// upsert-style at the proxy provider, so the old route is replaced
|
||||
// in-place by FQDN — no traffic gap. Per-face route IDs are
|
||||
// collected and stored on the container row's extra_json so Teardown
|
||||
// can drop every route (not just the primary).
|
||||
faceRoutes := map[string]string{} // fqdn → routeID
|
||||
for i, face := range w.PublicFaces {
|
||||
if !faceEnabled(face) {
|
||||
continue
|
||||
}
|
||||
port := face.TargetPort
|
||||
if port == 0 {
|
||||
port = cfg.Port
|
||||
}
|
||||
fqdn := fqdnFor(face, settings.Domain)
|
||||
|
||||
forwardHost := containerName
|
||||
forwardPort := port
|
||||
if settings.NpmRemote && settings.ProxyProvider == "npm" {
|
||||
if settings.ServerIP == "" {
|
||||
return rollbackNew("configure proxy", fmt.Errorf("NPM remote mode requires settings.server_ip"))
|
||||
}
|
||||
forwardHost = settings.ServerIP
|
||||
hostPort, err := deps.Docker.InspectContainerPort(ctx, dockerID, fmt.Sprintf("%d/tcp", port))
|
||||
if err != nil {
|
||||
return rollbackNew("inspect host port", err)
|
||||
}
|
||||
forwardPort = int(hostPort)
|
||||
}
|
||||
|
||||
accessListID := settings.NpmAccessListID
|
||||
if face.AccessListID > 0 {
|
||||
accessListID = face.AccessListID
|
||||
}
|
||||
|
||||
routeID, err := deps.Proxy.ConfigureRoute(ctx, fqdn, forwardHost, forwardPort, proxy.RouteOptions{
|
||||
SSLCertificateID: settings.SSLCertificateID,
|
||||
AccessListID: accessListID,
|
||||
})
|
||||
if err != nil {
|
||||
// Roll back any face routes we've already configured this
|
||||
// deploy so a partial failure doesn't leak orphan rules at
|
||||
// the proxy provider.
|
||||
for prevFQDN, prevRouteID := range faceRoutes {
|
||||
_ = prevFQDN
|
||||
if dErr := deps.Proxy.DeleteRoute(ctx, prevRouteID); dErr != nil {
|
||||
slog.Warn("image source: rollback proxy route",
|
||||
"workload", w.ID, "route", prevRouteID, "error", dErr)
|
||||
}
|
||||
}
|
||||
return rollbackNew(fmt.Sprintf("configure proxy face[%d]", i), err)
|
||||
}
|
||||
faceRoutes[fqdn] = routeID
|
||||
|
||||
if i == 0 {
|
||||
created.ProxyRouteID = routeID
|
||||
created.Subdomain = face.Subdomain
|
||||
}
|
||||
|
||||
// Best-effort DNS. Skipped under wildcard DNS (deps.DNS == nil).
|
||||
if deps.DNS != nil && settings.PublicIP != "" {
|
||||
if _, err := deps.DNS.EnsureRecord(ctx, fqdn, settings.PublicIP); err != nil {
|
||||
slog.Warn("image source: ensure DNS", "fqdn", fqdn, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the per-face route map on the container row so Teardown
|
||||
// and the next blue-green redeploy can find every configured face.
|
||||
if len(faceRoutes) > 0 {
|
||||
extra := containerExtra{ProxyRoutes: faceRoutes}
|
||||
if b, err := json.Marshal(extra); err == nil {
|
||||
created.ExtraJSON = string(b)
|
||||
}
|
||||
}
|
||||
if err := deps.Store.UpdateContainer(created); err != nil {
|
||||
slog.Warn("image source: update container with routes", "workload", w.ID, "error", err)
|
||||
}
|
||||
|
||||
// Now the new container is live behind the proxy. Enforce
|
||||
// MaxInstances by removing oldest surplus rows (which includes the
|
||||
// pre-deploy "blue" container when MaxInstances=1).
|
||||
maxInstances := cfg.MaxInstances
|
||||
if maxInstances <= 0 {
|
||||
maxInstances = 1
|
||||
}
|
||||
enforceMaxInstances(ctx, deps, w, created.ID, maxInstances)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// enforceMaxInstances trims older containers down to `keep` total for this
|
||||
// workload, preserving the just-deployed row (justDeployedRowID) at the
|
||||
// top. Best-effort: failures are logged, not propagated — the new deploy
|
||||
// already succeeded and we don't want to roll it back because cleanup of
|
||||
// an old container hiccupped.
|
||||
func enforceMaxInstances(ctx context.Context, deps plugin.Deps, w plugin.Workload, justDeployedRowID string, keep int) {
|
||||
rows, err := deps.Store.ListContainersByWorkload(w.ID)
|
||||
if err != nil {
|
||||
slog.Warn("image source: list for max-instances", "workload", w.ID, "error", err)
|
||||
return
|
||||
}
|
||||
// Sort newest first by CreatedAt, with the just-deployed row pinned
|
||||
// at index 0 regardless of clock skew.
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if rows[i].ID == justDeployedRowID {
|
||||
return true
|
||||
}
|
||||
if rows[j].ID == justDeployedRowID {
|
||||
return false
|
||||
}
|
||||
return rows[i].CreatedAt > rows[j].CreatedAt
|
||||
})
|
||||
if len(rows) <= keep {
|
||||
return
|
||||
}
|
||||
for _, victim := range rows[keep:] {
|
||||
if victim.ID == justDeployedRowID {
|
||||
continue
|
||||
}
|
||||
if victim.ContainerID != "" {
|
||||
if err := deps.Docker.RemoveContainer(ctx, victim.ContainerID, true); err != nil {
|
||||
slog.Warn("image source: remove old container",
|
||||
"workload", w.ID, "container", victim.ContainerID, "error", err)
|
||||
}
|
||||
}
|
||||
// The proxy route was already replaced by ConfigureRoute earlier
|
||||
// (same FQDN, new target). The old route ID, if any, is still
|
||||
// valid in the proxy provider's DB but now points at a removed
|
||||
// container. Delete it to keep the proxy clean. Best-effort.
|
||||
if victim.ProxyRouteID != "" && victim.ProxyRouteID != findCurrentRouteID(rows, justDeployedRowID) {
|
||||
if err := deps.Proxy.DeleteRoute(ctx, victim.ProxyRouteID); err != nil {
|
||||
slog.Warn("image source: delete old proxy route",
|
||||
"workload", w.ID, "route", victim.ProxyRouteID, "error", err)
|
||||
}
|
||||
}
|
||||
if err := deps.Store.DeleteContainer(victim.ID); err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||
slog.Warn("image source: delete old container row",
|
||||
"workload", w.ID, "row", victim.ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// findCurrentRouteID returns the route ID stored on the just-deployed
|
||||
// row, so we don't accidentally delete the live face.
|
||||
func findCurrentRouteID(rows []store.Container, justDeployedRowID string) string {
|
||||
for _, r := range rows {
|
||||
if r.ID == justDeployedRowID {
|
||||
return r.ProxyRouteID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Teardown stops and removes every container, proxy route, and DNS
|
||||
// record owned by this workload. Idempotent. Reads extra_json off each
|
||||
// row so non-primary face routes are cleaned up too — without this a
|
||||
// multi-face workload would leak every face beyond the primary at
|
||||
// delete-time.
|
||||
func (*source) Teardown(ctx context.Context, deps plugin.Deps, w plugin.Workload) error {
|
||||
rows, err := deps.Store.ListContainersByWorkload(w.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("image source: list containers: %w", err)
|
||||
}
|
||||
settings, _ := deps.Store.GetSettings()
|
||||
|
||||
for _, c := range rows {
|
||||
if c.ContainerID != "" {
|
||||
if err := deps.Docker.RemoveContainer(ctx, c.ContainerID, true); err != nil {
|
||||
slog.Warn("image source: remove docker container", "workload", w.ID, "container", c.ContainerID, "error", err)
|
||||
}
|
||||
}
|
||||
// Collect every route to delete: the primary (c.ProxyRouteID)
|
||||
// plus any extras stashed under extra_json.proxy_routes. Dedup
|
||||
// because the primary is also re-listed in the extras map.
|
||||
toDelete := map[string]string{} // fqdn → routeID
|
||||
if c.ProxyRouteID != "" {
|
||||
toDelete[c.Subdomain] = c.ProxyRouteID // key is opaque; we only iterate values
|
||||
}
|
||||
if c.ExtraJSON != "" && c.ExtraJSON != "{}" {
|
||||
var ex containerExtra
|
||||
if jErr := json.Unmarshal([]byte(c.ExtraJSON), &ex); jErr == nil {
|
||||
for fqdn, rid := range ex.ProxyRoutes {
|
||||
toDelete[fqdn] = rid
|
||||
}
|
||||
}
|
||||
}
|
||||
seenRoute := map[string]struct{}{}
|
||||
for _, rid := range toDelete {
|
||||
if _, dup := seenRoute[rid]; dup {
|
||||
continue
|
||||
}
|
||||
seenRoute[rid] = struct{}{}
|
||||
if err := deps.Proxy.DeleteRoute(ctx, rid); err != nil {
|
||||
slog.Warn("image source: delete proxy route",
|
||||
"workload", w.ID, "route", rid, "error", err)
|
||||
}
|
||||
}
|
||||
if deps.DNS != nil && c.Subdomain != "" && settings.Domain != "" {
|
||||
fqdn := c.Subdomain + "." + settings.Domain
|
||||
if err := deps.DNS.DeleteRecord(ctx, fqdn); err != nil {
|
||||
slog.Warn("image source: delete DNS", "fqdn", fqdn, "error", err)
|
||||
}
|
||||
}
|
||||
if err := deps.Store.DeleteContainer(c.ID); err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||
slog.Warn("image source: delete container row", "id", c.ID, "error", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// containerExtra is the shape stored under container.extra_json by the
|
||||
// image source. Kept versionless on purpose — additive only, unknown
|
||||
// keys must be ignored by older deployers reading rows written by newer
|
||||
// ones.
|
||||
type containerExtra struct {
|
||||
ProxyRoutes map[string]string `json:"proxy_routes,omitempty"`
|
||||
}
|
||||
|
||||
// Reconcile syncs the containers index for this workload with reality.
|
||||
// MVP: just refreshes State from Docker. Future versions can re-deploy
|
||||
// when the running container disagrees with the desired source config.
|
||||
func (*source) Reconcile(ctx context.Context, deps plugin.Deps, w plugin.Workload) error {
|
||||
rows, err := deps.Store.ListContainersByWorkload(w.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("image source: list containers: %w", err)
|
||||
}
|
||||
for _, c := range rows {
|
||||
if c.ContainerID == "" {
|
||||
continue
|
||||
}
|
||||
running, err := deps.Docker.IsContainerRunning(ctx, c.ContainerID)
|
||||
if err != nil {
|
||||
// Most likely "no such container" — mark as missing so the UI
|
||||
// surfaces it and the next deploy recreates.
|
||||
if err := deps.Store.UpdateContainerState(c.ID, "missing"); err != nil {
|
||||
slog.Warn("image source: mark missing", "id", c.ID, "error", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
desired := "running"
|
||||
if !running {
|
||||
desired = "stopped"
|
||||
}
|
||||
if c.State != desired {
|
||||
if err := deps.Store.UpdateContainerState(c.ID, desired); err != nil {
|
||||
slog.Warn("image source: state sync", "id", c.ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildRegistryAuth returns a Docker registry auth string for the named
|
||||
// registry, or "" when no auth is configured. Username is taken from
|
||||
// reg.Owner when present; falls back to the token for registries that
|
||||
// accept token-as-username (Docker Hub PATs, GHCR, etc.).
|
||||
func buildRegistryAuth(deps plugin.Deps, registryName string) (string, error) {
|
||||
if registryName == "" {
|
||||
return "", nil
|
||||
}
|
||||
reg, err := deps.Store.GetRegistryByName(registryName)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get registry %s: %w", registryName, err)
|
||||
}
|
||||
if reg.Token == "" {
|
||||
return "", nil
|
||||
}
|
||||
token, err := crypto.Decrypt(deps.EncKey, reg.Token)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decrypt registry token: %w", err)
|
||||
}
|
||||
username := reg.Owner
|
||||
if username == "" {
|
||||
username = token
|
||||
}
|
||||
return docker.EncodeRegistryAuth(username, token, reg.URL)
|
||||
}
|
||||
|
||||
// buildEnv flattens cfg.Env plus the workload_env overrides into the
|
||||
// KEY=VALUE list Docker expects. workload_env wins on key conflict and
|
||||
// encrypted rows are decrypted lazily so plaintext never lives in the
|
||||
// store output. If a decrypt fails the value is skipped with a warning —
|
||||
// failing the whole deploy because one rotated key bricked one env entry
|
||||
// would be a worse outcome than the missing variable.
|
||||
func buildEnv(deps plugin.Deps, w plugin.Workload, cfg Config) []string {
|
||||
merged := make(map[string]string, len(cfg.Env))
|
||||
for k, v := range cfg.Env {
|
||||
merged[k] = v
|
||||
}
|
||||
overrides, err := deps.Store.ListWorkloadEnv(w.ID)
|
||||
if err != nil {
|
||||
slog.Warn("image source: list workload env", "workload", w.ID, "error", err)
|
||||
} else {
|
||||
for _, e := range overrides {
|
||||
value := e.Value
|
||||
if e.Encrypted {
|
||||
decrypted, err := crypto.Decrypt(deps.EncKey, e.Value)
|
||||
if err != nil {
|
||||
slog.Warn("image source: decrypt env value",
|
||||
"workload", w.ID, "key", e.Key, "error", err)
|
||||
continue
|
||||
}
|
||||
value = decrypted
|
||||
}
|
||||
merged[e.Key] = value
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(merged))
|
||||
for k, v := range merged {
|
||||
out = append(out, k+"="+v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// computeMounts resolves a workload's VolumeMounts into mount.Mount
|
||||
// values. Both inline `cfg.Volumes` and persisted `workload_volumes` are
|
||||
// considered — persisted rows win on target conflict so the operator's
|
||||
// last UI-side edit takes precedence over whatever shipped with the
|
||||
// config blob.
|
||||
//
|
||||
// All VolumeScope values are honored:
|
||||
//
|
||||
// - absolute → host bind (validated against settings.AllowedVolumePaths)
|
||||
// - ephemeral → tmpfs (no host path)
|
||||
// - instance → per-tag dir under <workload>/instance-<tag>/<source>
|
||||
// - stage → shared per-workload dir (alias of project)
|
||||
// - project → shared per-workload dir
|
||||
// - project_named → workload-scoped Docker named volume
|
||||
// - named → globally-scoped Docker named volume
|
||||
//
|
||||
// Volumes with empty target or unresolvable scope are skipped with a
|
||||
// warning rather than failing the whole deploy — a misconfigured volume
|
||||
// should not brick an otherwise-valid CI push.
|
||||
func computeMounts(deps plugin.Deps, w plugin.Workload, cfg Config, imageTag string, settings store.Settings) []mount.Mount {
|
||||
byTarget := map[string]VolumeMount{}
|
||||
for _, v := range cfg.Volumes {
|
||||
if v.Target == "" {
|
||||
continue
|
||||
}
|
||||
byTarget[v.Target] = v
|
||||
}
|
||||
if persisted, err := deps.Store.ListWorkloadVolumes(w.ID); err == nil {
|
||||
for _, p := range persisted {
|
||||
byTarget[p.Target] = VolumeMount{
|
||||
Source: p.Source,
|
||||
Target: p.Target,
|
||||
Scope: p.Scope,
|
||||
Name: p.Name,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
slog.Warn("image source: list workload volumes", "workload", w.ID, "error", err)
|
||||
}
|
||||
|
||||
params := volume.ResolveWorkloadParams{
|
||||
BasePath: settings.BaseVolumePath,
|
||||
WorkloadID: w.ID,
|
||||
WorkloadName: w.Name,
|
||||
ImageTag: imageTag,
|
||||
AllowedVolumePaths: settings.AllowedVolumePaths,
|
||||
}
|
||||
|
||||
out := make([]mount.Mount, 0, len(byTarget))
|
||||
for _, v := range byTarget {
|
||||
if v.Target == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
switch v.Scope {
|
||||
case string(store.VolumeScopeEphemeral):
|
||||
out = append(out, mount.Mount{Type: mount.TypeTmpfs, Target: v.Target})
|
||||
continue
|
||||
case string(store.VolumeScopeNamed), string(store.VolumeScopeProjectNamed):
|
||||
// Docker named volumes use the volume name as Source. We
|
||||
// scope project_named entries to the workload by prefixing
|
||||
// the name so two workloads can both claim "data" without
|
||||
// sharing storage.
|
||||
name := v.Name
|
||||
if name == "" {
|
||||
slog.Warn("image source: named volume missing name",
|
||||
"workload", w.ID, "target", v.Target)
|
||||
continue
|
||||
}
|
||||
if v.Scope == string(store.VolumeScopeProjectNamed) {
|
||||
name = workloadNamedVolume(w, name)
|
||||
}
|
||||
out = append(out, mount.Mount{Type: mount.TypeVolume, Source: name, Target: v.Target})
|
||||
continue
|
||||
}
|
||||
|
||||
// Everything else resolves to a host path (absolute, instance,
|
||||
// stage, project). Empty source on absolute is invalid; for the
|
||||
// others "source" is the per-scope subdirectory.
|
||||
wv := store.WorkloadVolume{
|
||||
Source: v.Source,
|
||||
Target: v.Target,
|
||||
Scope: v.Scope,
|
||||
Name: v.Name,
|
||||
}
|
||||
path, err := volume.ResolveWorkloadPath(wv, params)
|
||||
if err != nil {
|
||||
slog.Warn("image source: resolve volume",
|
||||
"workload", w.ID, "target", v.Target, "scope", v.Scope, "error", err)
|
||||
continue
|
||||
}
|
||||
out = append(out, mount.Mount{Type: mount.TypeBind, Source: path, Target: v.Target})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// workloadNamedVolume builds the Docker volume name for a project_named
|
||||
// mount. The "tf-" prefix and short-id suffix keep volumes from one
|
||||
// workload separate from another's, even when they share a logical
|
||||
// volume name.
|
||||
func workloadNamedVolume(w plugin.Workload, name string) string {
|
||||
idShort := w.ID
|
||||
if len(idShort) > 8 {
|
||||
idShort = idShort[:8]
|
||||
}
|
||||
clean := strings.Trim(nameSanitizer.ReplaceAllString(name, "-"), "-")
|
||||
return "tf-" + idShort + "-" + clean
|
||||
}
|
||||
|
||||
// buildContainerName generates a deterministic container name keyed on
|
||||
// workload + tag. The scheme intentionally diverges from the legacy
|
||||
// "dw-{project}-{stage}-{tag}" scheme so plugin-managed containers are
|
||||
// trivially distinguishable in `docker ps`.
|
||||
var nameSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_.-]`)
|
||||
|
||||
func buildContainerName(workloadName, workloadID, tag string, ts time.Time) string {
|
||||
clean := func(s string) string {
|
||||
return strings.Trim(nameSanitizer.ReplaceAllString(s, "-"), "-")
|
||||
}
|
||||
idShort := workloadID
|
||||
if len(idShort) > 8 {
|
||||
idShort = idShort[:8]
|
||||
}
|
||||
// Suffix is a millisecond-resolution monotonic stamp so two deploys
|
||||
// can never collide on container name (blue-green needs the new
|
||||
// container to start while the old one is still bound to the same
|
||||
// "tf-name-id-tag" prefix).
|
||||
suffix := fmt.Sprintf("%x", ts.UnixMilli())
|
||||
return fmt.Sprintf("tf-%s-%s-%s-%s", clean(workloadName), idShort, clean(tag), suffix)
|
||||
}
|
||||
|
||||
// faceEnabled is true for any face that should yield a proxy route. A
|
||||
// face with empty subdomain AND empty domain is treated as disabled.
|
||||
func faceEnabled(f plugin.PublicFace) bool {
|
||||
return f.Subdomain != "" || f.Domain != ""
|
||||
}
|
||||
|
||||
func fqdnFor(f plugin.PublicFace, defaultDomain string) string {
|
||||
domain := f.Domain
|
||||
if domain == "" {
|
||||
domain = defaultDomain
|
||||
}
|
||||
if f.Subdomain == "" {
|
||||
return domain
|
||||
}
|
||||
return f.Subdomain + "." + domain
|
||||
}
|
||||
|
||||
func primaryFace(faces []plugin.PublicFace) plugin.PublicFace {
|
||||
for _, f := range faces {
|
||||
if faceEnabled(f) {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return plugin.PublicFace{}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
)
|
||||
|
||||
func TestBuildContainerName(t *testing.T) {
|
||||
ts := time.Unix(1700000000, 0)
|
||||
name := buildContainerName("My App", "abcd1234-5678-1234-abcd-deadbeef0000", "v1.2.3", ts)
|
||||
|
||||
if !strings.HasPrefix(name, "tf-My-App-abcd1234-v1.2.3-") {
|
||||
t.Errorf("name=%q lost expected prefix", name)
|
||||
}
|
||||
if strings.Contains(name, " ") {
|
||||
t.Errorf("name=%q contains space — sanitizer regressed", name)
|
||||
}
|
||||
if strings.Contains(name, "/") {
|
||||
t.Errorf("name=%q contains slash — sanitizer regressed", name)
|
||||
}
|
||||
// Suffix is monotonic ms hex — two adjacent timestamps must produce
|
||||
// different names so blue-green can run two containers side-by-side.
|
||||
other := buildContainerName("My App", "abcd1234-5678-1234-abcd-deadbeef0000", "v1.2.3", ts.Add(time.Millisecond))
|
||||
if other == name {
|
||||
t.Errorf("expected distinct names across timestamps, got %q twice", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildContainerNameShortID(t *testing.T) {
|
||||
// Workload IDs shorter than 8 chars must not panic on slicing.
|
||||
name := buildContainerName("app", "ab", "tag", time.Unix(1700000000, 0))
|
||||
if !strings.HasPrefix(name, "tf-app-ab-tag-") {
|
||||
t.Errorf("unexpected short-ID name: %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFaceEnabled(t *testing.T) {
|
||||
cases := []struct {
|
||||
face plugin.PublicFace
|
||||
want bool
|
||||
}{
|
||||
{plugin.PublicFace{}, false},
|
||||
{plugin.PublicFace{Subdomain: "api"}, true},
|
||||
{plugin.PublicFace{Domain: "example.com"}, true},
|
||||
{plugin.PublicFace{Subdomain: "api", Domain: "example.com"}, true},
|
||||
}
|
||||
for i, tc := range cases {
|
||||
if got := faceEnabled(tc.face); got != tc.want {
|
||||
t.Errorf("case %d face=%+v: got %v want %v", i, tc.face, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFqdnFor(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
face plugin.PublicFace
|
||||
defDom string
|
||||
want string
|
||||
}{
|
||||
{"subdomain + face domain", plugin.PublicFace{Subdomain: "api", Domain: "example.com"}, "default.io", "api.example.com"},
|
||||
{"subdomain inherits default", plugin.PublicFace{Subdomain: "api"}, "default.io", "api.default.io"},
|
||||
{"root domain only", plugin.PublicFace{Domain: "example.com"}, "default.io", "example.com"},
|
||||
{"root of default", plugin.PublicFace{}, "default.io", "default.io"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := fqdnFor(tc.face, tc.defDom); got != tc.want {
|
||||
t.Errorf("fqdnFor: got %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrimaryFace(t *testing.T) {
|
||||
t.Run("returns first enabled", func(t *testing.T) {
|
||||
faces := []plugin.PublicFace{
|
||||
{}, // disabled
|
||||
{Subdomain: "api"}, // first enabled
|
||||
{Domain: "second.example.com"},
|
||||
}
|
||||
got := primaryFace(faces)
|
||||
if got.Subdomain != "api" {
|
||||
t.Errorf("expected first enabled, got %+v", got)
|
||||
}
|
||||
})
|
||||
t.Run("empty when none enabled", func(t *testing.T) {
|
||||
got := primaryFace([]plugin.PublicFace{{}, {}})
|
||||
if got.Subdomain != "" || got.Domain != "" {
|
||||
t.Errorf("expected zero face, got %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
src := &source{}
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty rejected", "", true},
|
||||
{"missing image rejected", `{"port":8080}`, true},
|
||||
{"valid minimal", `{"image":"owner/app","port":8080}`, false},
|
||||
{"port out of range", `{"image":"x","port":99999}`, true},
|
||||
{"volume missing target rejected", `{"image":"x","volumes":[{"source":"/a","scope":"absolute"}]}`, true},
|
||||
{"volume missing scope rejected", `{"image":"x","volumes":[{"source":"/a","target":"/b"}]}`, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := src.Validate([]byte(tc.body))
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("Validate(%q) err=%v want err=%v", tc.body, err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// Package static implements the "static" source: a git-folder-backed
|
||||
// deployable that can serve plain files or run a Deno backend. Builds an
|
||||
// image from the cloned folder and runs one container.
|
||||
//
|
||||
// The full deploy pipeline lives in internal/staticsite (git providers,
|
||||
// markdown rendering, Dockerfile codegen, Deno scaffolding, image build,
|
||||
// proxy registration) and is wired in via a function variable so that
|
||||
// neither this package nor staticsite has to depend on the other.
|
||||
//
|
||||
// cmd/server/main.go (or any caller with access to both packages)
|
||||
// populates DeployFn / TeardownFn / ReconcileFn at startup; until then,
|
||||
// Source methods return an explicit error so misconfiguration surfaces
|
||||
// loudly instead of silently failing.
|
||||
package static
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
)
|
||||
|
||||
// Config is the per-workload source config blob. Mirrors the fields that
|
||||
// used to live on the static_sites table, less anything moved to Workload
|
||||
// (notification config, webhook secrets, public_face).
|
||||
type Config struct {
|
||||
Provider string `json:"provider"` // "gitea" | "github" | "gitlab"; "" = autodetect
|
||||
BaseURL string `json:"base_url"` // e.g. https://git.example.com
|
||||
RepoOwner string `json:"repo_owner"`
|
||||
RepoName string `json:"repo_name"`
|
||||
Branch string `json:"branch"`
|
||||
FolderPath string `json:"folder_path"` // path within repo
|
||||
AccessToken string `json:"access_token"` // encrypted; optional for public repos
|
||||
Mode string `json:"mode"` // "static" | "deno"
|
||||
RenderMarkdown bool `json:"render_markdown"`
|
||||
StorageEnabled bool `json:"storage_enabled"`
|
||||
StorageLimitMB int `json:"storage_limit_mb"`
|
||||
}
|
||||
|
||||
// Backend captures the deploy lifecycle of a static site. main.go wires
|
||||
// an implementation that adapts internal/staticsite.Manager to this
|
||||
// interface; the plugin contract sees only this shape so it stays
|
||||
// independent of any specific manager type.
|
||||
type Backend interface {
|
||||
Deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plugin.DeploymentIntent) error
|
||||
Teardown(ctx context.Context, deps plugin.Deps, w plugin.Workload) error
|
||||
Reconcile(ctx context.Context, deps plugin.Deps, w plugin.Workload) error
|
||||
}
|
||||
|
||||
var (
|
||||
backendMu sync.RWMutex
|
||||
backend Backend
|
||||
backendSet atomic.Bool
|
||||
)
|
||||
|
||||
// SetBackend wires the staticsite-package adapter into this Source AND
|
||||
// registers the source with the plugin registry. MUST be called exactly
|
||||
// once from cmd/server/main.go before any plugin invocation. Subsequent
|
||||
// calls panic — a swapped backend at runtime is a trust-boundary
|
||||
// inversion (a future plugin loaded via blank import could replace
|
||||
// deploy/teardown logic that handles git tokens).
|
||||
func SetBackend(b Backend) {
|
||||
if !backendSet.CompareAndSwap(false, true) {
|
||||
panic("static: backend already wired (SetBackend may be called once)")
|
||||
}
|
||||
backendMu.Lock()
|
||||
backend = b
|
||||
backendMu.Unlock()
|
||||
plugin.RegisterSource(&source{})
|
||||
}
|
||||
|
||||
func currentBackend() (Backend, error) {
|
||||
backendMu.RLock()
|
||||
defer backendMu.RUnlock()
|
||||
if backend == nil {
|
||||
return nil, fmt.Errorf("static source: backend not wired; call static.SetBackend from main.go")
|
||||
}
|
||||
return backend, nil
|
||||
}
|
||||
|
||||
type source struct{}
|
||||
|
||||
// Static source registers itself only after SetBackend is called from
|
||||
// main.go. Eager init() registration would advertise "static" via
|
||||
// /api/hooks/kinds before there is anything to dispatch to — frontends
|
||||
// would render it in pickers and operators would hit "backend not wired"
|
||||
// at deploy time. Lazy registration keeps the kind invisible until it's
|
||||
// actually usable.
|
||||
|
||||
func (*source) Kind() string { return "static" }
|
||||
|
||||
func (*source) SchemaSample() any {
|
||||
return Config{
|
||||
Provider: "gitea",
|
||||
BaseURL: "https://git.example.com",
|
||||
RepoOwner: "owner",
|
||||
RepoName: "pages",
|
||||
Branch: "main",
|
||||
FolderPath: "",
|
||||
Mode: "static",
|
||||
}
|
||||
}
|
||||
|
||||
func (*source) Validate(cfg json.RawMessage) error {
|
||||
var c Config
|
||||
if len(cfg) == 0 {
|
||||
return fmt.Errorf("static source: config is required")
|
||||
}
|
||||
if err := json.Unmarshal(cfg, &c); err != nil {
|
||||
return fmt.Errorf("static source: invalid json: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(c.RepoOwner) == "" || strings.TrimSpace(c.RepoName) == "" {
|
||||
return fmt.Errorf("static source: repo_owner and repo_name are required")
|
||||
}
|
||||
if c.Mode != "" && c.Mode != "static" && c.Mode != "deno" {
|
||||
return fmt.Errorf("static source: mode must be \"static\" or \"deno\"")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*source) Deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plugin.DeploymentIntent) error {
|
||||
b, err := currentBackend()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Deploy(ctx, deps, w, intent)
|
||||
}
|
||||
|
||||
func (*source) Teardown(ctx context.Context, deps plugin.Deps, w plugin.Workload) error {
|
||||
b, err := currentBackend()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Teardown(ctx, deps, w)
|
||||
}
|
||||
|
||||
func (*source) Reconcile(ctx context.Context, deps plugin.Deps, w plugin.Workload) error {
|
||||
b, err := currentBackend()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Reconcile(ctx, deps, w)
|
||||
}
|
||||
Reference in New Issue
Block a user