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:
2026-05-11 22:17:41 +03:00
parent f42b21a2b9
commit 8d6a527a2b
41 changed files with 9482 additions and 18 deletions
+79
View File
@@ -0,0 +1,79 @@
// Package plugin defines the Source and Trigger contracts that decouple
// Tinyforge's deployer pipeline from any single deployable shape (image,
// compose, static, ...) or any single redeploy trigger (registry push,
// git push, manual, ...).
//
// A Workload is the unifying user-facing entity. It carries an opaque
// SourceConfig (interpreted by the matching Source) and an opaque
// TriggerConfig (interpreted by the matching Trigger). Both kinds are
// strings; lookup happens through the registries below.
//
// New deployable shapes or trigger types are added by:
// 1. Implementing Source or Trigger in a sub-package.
// 2. Calling Register (Source/Trigger) from that package's init().
// 3. Blank-importing the sub-package from cmd/ to pull the registration in.
//
// No code in this package or in the deployer/api layers needs to change
// when a new kind appears — the registry is the only seam.
package plugin
import (
"encoding/json"
"github.com/alexei/tinyforge/internal/dns"
"github.com/alexei/tinyforge/internal/docker"
"github.com/alexei/tinyforge/internal/events"
"github.com/alexei/tinyforge/internal/health"
"github.com/alexei/tinyforge/internal/notify"
"github.com/alexei/tinyforge/internal/proxy"
"github.com/alexei/tinyforge/internal/store"
)
// Deps is the bundle of services every Source or Trigger may need. Passed
// per-call so plugin implementations stay stateless and testable.
type Deps struct {
Store *store.Store
Docker *docker.Client
Proxy proxy.Provider
DNS dns.Provider // nil when wildcard DNS is active
Health *health.Checker
Notifier *notify.Notifier
Events EventPublisher
EncKey [32]byte // pass-through to crypto.Encrypt/Decrypt for config secrets
}
// EventPublisher matches the deployer's existing event-bus surface. Kept as
// a local interface so plugin/ does not pull events transitively into every
// caller.
type EventPublisher interface {
Publish(evt events.Event)
}
// Workload is the value-shape every plugin consumes. It is constructed by
// the store layer from the workloads row plus its decoded JSON blobs; the
// physical schema can evolve independently of this struct.
type Workload struct {
ID string
Name string
GroupID string // formerly app_id; "" = ungrouped
ParentWorkloadID string // for stage chains; "" = root
SourceKind string // "image" | "compose" | "static" | ...
SourceConfig json.RawMessage // shape determined by SourceKind
TriggerKind string // "registry" | "git" | "manual" | "cron" | ...
TriggerConfig json.RawMessage // shape determined by TriggerKind
PublicFaces []PublicFace // zero or more public routes
// Notification + webhook security live on the Workload itself rather
// than on per-kind tables so the rules are consistent across shapes.
NotificationURL string
NotificationSecret string
WebhookSecret string
WebhookSigningSecret string
WebhookRequireSignature bool
CreatedAt string
UpdatedAt string
}
+27
View File
@@ -0,0 +1,27 @@
package plugin
// AllSources returns a snapshot of every registered Source keyed by kind.
// Snapshot semantics: the caller may iterate freely without holding any
// lock. Mutating the returned map does not affect the registry.
func AllSources() map[string]Source {
sourcesMu.RLock()
defer sourcesMu.RUnlock()
out := make(map[string]Source, len(sources))
for k, v := range sources {
out[k] = v
}
return out
}
// AllTriggers returns a snapshot of every registered Trigger keyed by kind.
// Used by the single webhook ingress to fan an InboundEvent out across all
// triggers without per-call locking.
func AllTriggers() map[string]Trigger {
triggersMu.RLock()
defer triggersMu.RUnlock()
out := make(map[string]Trigger, len(triggers))
for k, v := range triggers {
out[k] = v
}
return out
}
+114
View File
@@ -0,0 +1,114 @@
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
}
@@ -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)
}
+74
View File
@@ -0,0 +1,74 @@
package plugin
import (
"context"
"encoding/json"
"fmt"
"sort"
"sync"
)
// Trigger is the contract for one redeploy signal source (registry push,
// git push, manual, cron, ...). A Trigger has one job: given an inbound
// event and a workload's TriggerConfig, decide whether a deploy should
// fire and shape the resulting DeploymentIntent.
//
// Triggers do not perform deploys themselves — they hand the intent back
// to the deployer, which routes it to the matching Source. This keeps
// the (M sources × N triggers) cross-product code-free.
type Trigger interface {
// Kind is the registration key (e.g. "registry", "git", "manual", "cron").
Kind() string
// Validate type-checks a raw trigger config blob before it is persisted.
Validate(cfg json.RawMessage) error
// Match decides whether evt fires a deploy of w. Returning (nil, nil)
// means "not interested, skip silently"; an error is reserved for
// configuration or signature problems the operator should see.
Match(ctx context.Context, deps Deps, w Workload, evt InboundEvent) (*DeploymentIntent, error)
}
var (
triggersMu sync.RWMutex
triggers = map[string]Trigger{}
)
// RegisterTrigger installs t under t.Kind(). Panics on duplicate
// registration (init-time bug, never a runtime condition).
func RegisterTrigger(t Trigger) {
triggersMu.Lock()
defer triggersMu.Unlock()
k := t.Kind()
if _, dup := triggers[k]; dup {
panic(fmt.Sprintf("plugin: trigger %q already registered", k))
}
triggers[k] = t
}
// GetTrigger returns the Trigger for kind. Errors carry the missing kind
// for diagnostics.
func GetTrigger(kind string) (Trigger, error) {
triggersMu.RLock()
defer triggersMu.RUnlock()
t, ok := triggers[kind]
if !ok {
return nil, fmt.Errorf("plugin: no trigger registered for kind %q", kind)
}
return t, nil
}
// TriggerKinds returns all registered trigger kinds, sorted.
func TriggerKinds() []string {
triggersMu.RLock()
defer triggersMu.RUnlock()
out := make([]string, 0, len(triggers))
for k := range triggers {
out = append(out, k)
}
sortStrings(out)
return out
}
// sortStrings is shared by SourceKinds / TriggerKinds.
func sortStrings(s []string) { sort.Strings(s) }
+123
View File
@@ -0,0 +1,123 @@
// Package git implements the "git" trigger: matches inbound git push or
// tag-create events from Gitea, GitHub, or GitLab against a repo + ref
// filter.
package git
import (
"context"
"encoding/json"
"fmt"
"path"
"strings"
"time"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// Config is the per-workload trigger config. Repo is "owner/name" (must
// match the event repo). Mode controls whether branch pushes or tag
// pushes fire the deploy. Branch is exact-matched when Mode=="push";
// TagPattern is glob-matched when Mode=="tag".
type Config struct {
Repo string `json:"repo"`
Mode string `json:"mode"` // "push" | "tag"
Branch string `json:"branch"`
TagPattern string `json:"tag_pattern"`
}
type trigger struct{}
func init() { plugin.RegisterTrigger(&trigger{}) }
func (*trigger) Kind() string { return "git" }
func (*trigger) SchemaSample() any {
return Config{
Repo: "owner/repo",
Mode: "push",
Branch: "main",
}
}
func (*trigger) Validate(cfg json.RawMessage) error {
var c Config
if len(cfg) == 0 {
return fmt.Errorf("git trigger: config is required")
}
if err := json.Unmarshal(cfg, &c); err != nil {
return fmt.Errorf("git trigger: invalid json: %w", err)
}
switch c.Mode {
case "push":
// Branch is optional ("" means any branch).
case "tag":
pattern := c.TagPattern
if pattern == "" {
pattern = "*"
}
if _, err := path.Match(pattern, "probe"); err != nil {
return fmt.Errorf("git trigger: invalid tag_pattern %q: %w", pattern, err)
}
default:
return fmt.Errorf("git trigger: mode must be \"push\" or \"tag\"")
}
return nil
}
func (*trigger) Match(ctx context.Context, deps plugin.Deps, w plugin.Workload, evt plugin.InboundEvent) (*plugin.DeploymentIntent, error) {
if evt.Git == nil {
return nil, nil
}
cfg, err := plugin.TriggerConfigOf[Config](w)
if err != nil {
return nil, fmt.Errorf("git trigger: decode config: %w", err)
}
if cfg.Repo != "" && !strings.EqualFold(cfg.Repo, evt.Git.Repo) {
return nil, nil
}
if !refMatches(cfg, evt.Git.Ref) {
return nil, nil
}
meta := map[string]string{
"repo": evt.Git.Repo,
"vendor": evt.Git.Vendor,
"ref": evt.Git.Ref,
"pusher": evt.Git.Pusher,
}
if evt.Git.Branch != "" {
meta["branch"] = evt.Git.Branch
}
if evt.Git.Tag != "" {
meta["tag"] = evt.Git.Tag
}
return &plugin.DeploymentIntent{
Reason: "git-push",
Reference: evt.Git.CommitSHA,
Metadata: meta,
TriggeredAt: time.Now().UTC(),
TriggeredBy: "git-webhook",
}, nil
}
func refMatches(cfg Config, ref string) bool {
switch cfg.Mode {
case "push":
branch, ok := strings.CutPrefix(ref, "refs/heads/")
if !ok {
return false
}
return cfg.Branch == "" || cfg.Branch == branch
case "tag":
tag, ok := strings.CutPrefix(ref, "refs/tags/")
if !ok {
return false
}
pattern := cfg.TagPattern
if pattern == "" {
pattern = "*"
}
matched, err := path.Match(pattern, tag)
return err == nil && matched
}
return false
}
@@ -0,0 +1,142 @@
package git
import (
"context"
"encoding/json"
"testing"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
func mustConfig(t *testing.T, c Config) json.RawMessage {
t.Helper()
b, err := json.Marshal(c)
if err != nil {
t.Fatalf("marshal config: %v", err)
}
return b
}
func TestValidate(t *testing.T) {
tr := &trigger{}
cases := []struct {
name string
cfg json.RawMessage
wantErr bool
}{
{"empty body rejected", nil, true},
{"missing mode rejected", mustConfig(t, Config{Repo: "owner/repo"}), true},
{"push mode valid", mustConfig(t, Config{Repo: "owner/repo", Mode: "push", Branch: "main"}), false},
{"push mode without branch (any-branch)", mustConfig(t, Config{Repo: "owner/repo", Mode: "push"}), false},
{"tag mode valid", mustConfig(t, Config{Repo: "owner/repo", Mode: "tag", TagPattern: "v*"}), false},
{"tag mode no pattern (wildcard fallback)", mustConfig(t, Config{Repo: "owner/repo", Mode: "tag"}), false},
{"tag mode bad glob", mustConfig(t, Config{Repo: "owner/repo", Mode: "tag", TagPattern: "v[oops"}), true},
{"unknown mode", mustConfig(t, Config{Repo: "owner/repo", Mode: "merge"}), true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tr.Validate(tc.cfg)
if (err != nil) != tc.wantErr {
t.Fatalf("Validate(%s) err=%v want err=%v", tc.name, err, tc.wantErr)
}
})
}
}
func TestRefMatches(t *testing.T) {
cases := []struct {
name string
cfg Config
ref string
want bool
}{
{"push main matches", Config{Mode: "push", Branch: "main"}, "refs/heads/main", true},
{"push main rejects other branch", Config{Mode: "push", Branch: "main"}, "refs/heads/dev", false},
{"push tag is rejected in push mode", Config{Mode: "push", Branch: "main"}, "refs/tags/v1.0.0", false},
{"push any-branch", Config{Mode: "push"}, "refs/heads/whatever", true},
{"tag mode v* matches v1.2.3", Config{Mode: "tag", TagPattern: "v*"}, "refs/tags/v1.2.3", true},
{"tag mode v* rejects latest", Config{Mode: "tag", TagPattern: "v*"}, "refs/tags/latest", false},
{"tag mode rejects heads ref", Config{Mode: "tag", TagPattern: "v*"}, "refs/heads/main", false},
{"tag mode empty pattern matches any tag", Config{Mode: "tag"}, "refs/tags/whatever", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := refMatches(tc.cfg, tc.ref); got != tc.want {
t.Errorf("refMatches(%+v, %q) = %v, want %v", tc.cfg, tc.ref, got, tc.want)
}
})
}
}
func TestMatch(t *testing.T) {
tr := &trigger{}
wl := plugin.Workload{
ID: "wkl-1",
TriggerConfig: mustConfig(t, Config{Repo: "Owner/Repo", Mode: "push", Branch: "main"}),
}
t.Run("wrong event kind", func(t *testing.T) {
evt := plugin.InboundEvent{Kind: "image-push"}
intent, err := tr.Match(context.Background(), plugin.Deps{}, wl, evt)
if err != nil || intent != nil {
t.Fatalf("expected nil intent, got intent=%v err=%v", intent, err)
}
})
t.Run("matching push fires intent with sha", func(t *testing.T) {
// Branch is populated by the webhook ingress alongside Ref; the
// trigger reads either independently. Set both here to mirror the
// real wire shape.
evt := plugin.InboundEvent{
Kind: "git-push",
Git: &plugin.GitEvent{
Repo: "owner/repo",
Ref: "refs/heads/main",
Branch: "main",
CommitSHA: "deadbeef",
Pusher: "alice",
},
}
intent, err := tr.Match(context.Background(), plugin.Deps{}, wl, evt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if intent == nil {
t.Fatal("expected non-nil intent")
}
if intent.Reference != "deadbeef" {
t.Errorf("intent.Reference = %q, want deadbeef", intent.Reference)
}
if intent.Reason != "git-push" {
t.Errorf("intent.Reason = %q, want git-push", intent.Reason)
}
if intent.Metadata["branch"] != "main" {
t.Errorf("expected branch=main in metadata, got %q", intent.Metadata["branch"])
}
})
t.Run("repo case-insensitive comparison", func(t *testing.T) {
evt := plugin.InboundEvent{
Kind: "git-push",
Git: &plugin.GitEvent{Repo: "OWNER/REPO", Ref: "refs/heads/main"},
}
intent, err := tr.Match(context.Background(), plugin.Deps{}, wl, evt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if intent == nil {
t.Fatal("expected case-insensitive repo match")
}
})
t.Run("wrong repo returns nil", func(t *testing.T) {
evt := plugin.InboundEvent{
Kind: "git-push",
Git: &plugin.GitEvent{Repo: "other/repo", Ref: "refs/heads/main"},
}
intent, err := tr.Match(context.Background(), plugin.Deps{}, wl, evt)
if err != nil || intent != nil {
t.Fatalf("expected nil intent, got intent=%v err=%v", intent, err)
}
})
}
@@ -0,0 +1,57 @@
// Package manual implements the "manual" trigger: any ManualEvent fires a
// deploy. No per-workload config — the trigger always matches its kind.
package manual
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
type trigger struct{}
func init() { plugin.RegisterTrigger(&trigger{}) }
func (*trigger) Kind() string { return "manual" }
func (*trigger) SchemaSample() any { return struct{}{} }
func (*trigger) Validate(cfg json.RawMessage) error {
// Manual triggers have no config; accept empty or a small valid JSON
// blob. The cap prevents an admin from pinning a 1 MiB blob to a
// trigger row that gets serialized on every read.
if len(cfg) == 0 {
return nil
}
if len(cfg) > 1024 {
return fmt.Errorf("manual trigger: config must be empty or a small JSON value (got %d bytes)", len(cfg))
}
if !json.Valid(cfg) {
return fmt.Errorf("manual trigger: invalid json")
}
return nil
}
func (*trigger) Match(ctx context.Context, deps plugin.Deps, w plugin.Workload, evt plugin.InboundEvent) (*plugin.DeploymentIntent, error) {
if evt.Kind != "manual" || evt.Manual == nil {
return nil, nil
}
actor := evt.Manual.Actor
if actor == "" {
actor = "manual"
}
meta := map[string]string{}
if evt.Manual.Note != "" {
meta["note"] = evt.Manual.Note
}
return &plugin.DeploymentIntent{
Reason: "manual",
Reference: evt.Manual.Reference,
Metadata: meta,
TriggeredAt: time.Now().UTC(),
TriggeredBy: actor,
}, nil
}
@@ -0,0 +1,83 @@
package manual
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
func TestValidate(t *testing.T) {
tr := &trigger{}
cases := []struct {
name string
cfg json.RawMessage
wantErr bool
}{
{"empty body accepted", nil, false},
{"empty object accepted", json.RawMessage(`{}`), false},
{"valid small object accepted", json.RawMessage(`{"note":"hello"}`), false},
{"invalid json rejected", json.RawMessage(`not json`), true},
{"oversize rejected", json.RawMessage(`{"big":"` + strings.Repeat("x", 1100) + `"}`), true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tr.Validate(tc.cfg)
if (err != nil) != tc.wantErr {
t.Fatalf("Validate(%s) err=%v want err=%v", tc.name, err, tc.wantErr)
}
})
}
}
func TestMatch(t *testing.T) {
tr := &trigger{}
wl := plugin.Workload{ID: "wkl-1"}
t.Run("wrong kind ignored", func(t *testing.T) {
evt := plugin.InboundEvent{Kind: "image-push"}
intent, err := tr.Match(context.Background(), plugin.Deps{}, wl, evt)
if err != nil || intent != nil {
t.Fatalf("expected nil intent, got intent=%v err=%v", intent, err)
}
})
t.Run("manual fires with actor + note", func(t *testing.T) {
evt := plugin.InboundEvent{
Kind: "manual",
Manual: &plugin.ManualEvent{Actor: "alice", Reference: "v1.0.0", Note: "rollback"},
}
intent, err := tr.Match(context.Background(), plugin.Deps{}, wl, evt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if intent == nil {
t.Fatal("expected non-nil intent")
}
if intent.TriggeredBy != "alice" {
t.Errorf("TriggeredBy = %q, want alice", intent.TriggeredBy)
}
if intent.Reference != "v1.0.0" {
t.Errorf("Reference = %q, want v1.0.0", intent.Reference)
}
if intent.Metadata["note"] != "rollback" {
t.Errorf("note metadata = %q, want rollback", intent.Metadata["note"])
}
})
t.Run("missing actor falls back", func(t *testing.T) {
evt := plugin.InboundEvent{
Kind: "manual",
Manual: &plugin.ManualEvent{Reference: "v2"},
}
intent, err := tr.Match(context.Background(), plugin.Deps{}, wl, evt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if intent.TriggeredBy != "manual" {
t.Errorf("TriggeredBy = %q, want manual", intent.TriggeredBy)
}
})
}
@@ -0,0 +1,115 @@
// Package registry implements the "registry" trigger: matches inbound image
// push events from container registries (Docker Hub, Gitea, ghcr, generic
// webhooks, polling) against a repo + tag-pattern filter.
package registry
import (
"context"
"encoding/json"
"fmt"
"path"
"strings"
"time"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// Config is the per-workload trigger config blob. Image is the
// fully-qualified image reference the workload deploys (e.g.
// "registry.example.com/owner/app"); a push of any matching tag fires a
// deploy. TagPattern is a path.Match glob ("*" matches all).
type Config struct {
Image string `json:"image"`
TagPattern string `json:"tag_pattern"`
}
type trigger struct{}
func init() { plugin.RegisterTrigger(&trigger{}) }
func (*trigger) Kind() string { return "registry" }
func (*trigger) SchemaSample() any {
return Config{
Image: "registry.example.com/owner/app",
TagPattern: "v*",
}
}
func (*trigger) Validate(cfg json.RawMessage) error {
var c Config
if len(cfg) == 0 {
return fmt.Errorf("registry trigger: config is required")
}
if err := json.Unmarshal(cfg, &c); err != nil {
return fmt.Errorf("registry trigger: invalid json: %w", err)
}
if strings.TrimSpace(c.Image) == "" {
return fmt.Errorf("registry trigger: image is required")
}
pattern := c.TagPattern
if pattern == "" {
pattern = "*"
}
if _, err := path.Match(pattern, "probe"); err != nil {
return fmt.Errorf("registry trigger: invalid tag_pattern %q: %w", pattern, err)
}
return nil
}
func (*trigger) Match(ctx context.Context, deps plugin.Deps, w plugin.Workload, evt plugin.InboundEvent) (*plugin.DeploymentIntent, error) {
if evt.Kind != "image-push" || evt.Image == nil {
return nil, nil
}
cfg, err := plugin.TriggerConfigOf[Config](w)
if err != nil {
return nil, fmt.Errorf("registry trigger: decode config: %w", err)
}
if !imageMatches(cfg.Image, fullRepo(evt.Image)) {
return nil, nil
}
pattern := cfg.TagPattern
if pattern == "" {
pattern = "*"
}
matched, err := path.Match(pattern, evt.Image.Tag)
if err != nil || !matched {
return nil, nil
}
return &plugin.DeploymentIntent{
Reason: "registry-push",
Reference: evt.Image.Tag,
Metadata: map[string]string{"digest": evt.Image.Digest, "repo": evt.Image.Repo},
TriggeredAt: time.Now().UTC(),
TriggeredBy: "registry-webhook",
}, nil
}
func fullRepo(e *plugin.ImagePushEvent) string {
if e.Registry == "" {
return e.Repo
}
return e.Registry + "/" + e.Repo
}
// imageMatches: registry host case-insensitive, path/owner/name exact.
// Single-segment refs (e.g. Docker Hub officials like "nginx") have no
// `/` and match by exact equality of the bare name.
func imageMatches(want, got string) bool {
if want == got {
return true
}
wIdx := strings.IndexByte(want, '/')
gIdx := strings.IndexByte(got, '/')
// Both single-segment: equality already failed above, so no match.
if wIdx < 0 && gIdx < 0 {
return false
}
// One side single-segment, the other qualified — does not match.
if wIdx < 0 || gIdx < 0 {
return false
}
wHost, wPath := want[:wIdx], want[wIdx:]
gHost, gPath := got[:gIdx], got[gIdx:]
return strings.EqualFold(wHost, gHost) && wPath == gPath
}
@@ -0,0 +1,155 @@
package registry
import (
"context"
"encoding/json"
"testing"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
func mustConfig(t *testing.T, c Config) json.RawMessage {
t.Helper()
b, err := json.Marshal(c)
if err != nil {
t.Fatalf("marshal config: %v", err)
}
return b
}
func TestValidate(t *testing.T) {
tr := &trigger{}
cases := []struct {
name string
cfg json.RawMessage
wantErr bool
}{
{"empty body rejected", nil, true},
{"missing image rejected", mustConfig(t, Config{TagPattern: "*"}), true},
{"valid wildcard", mustConfig(t, Config{Image: "owner/app", TagPattern: "*"}), false},
{"valid with glob", mustConfig(t, Config{Image: "registry.example.com/owner/app", TagPattern: "v*"}), false},
{"invalid glob", mustConfig(t, Config{Image: "owner/app", TagPattern: "v[oops"}), true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tr.Validate(tc.cfg)
if (err != nil) != tc.wantErr {
t.Fatalf("Validate(%s) err=%v want err=%v", tc.name, err, tc.wantErr)
}
})
}
}
func TestImageMatches(t *testing.T) {
cases := []struct {
name string
want, got string
shouldMatch bool
}{
{"exact qualified", "registry.example.com/owner/app", "registry.example.com/owner/app", true},
{"host case-insensitive", "REGISTRY.example.com/owner/app", "registry.example.com/owner/app", true},
{"path mismatch", "registry.example.com/owner/app", "registry.example.com/owner/other", false},
{"different registry", "a.example.com/owner/app", "b.example.com/owner/app", false},
// Single-segment images (Docker Hub officials) — recently fixed.
{"both single-segment equal", "nginx", "nginx", true},
{"both single-segment unequal", "nginx", "postgres", false},
{"want single, got qualified", "nginx", "library/nginx", false},
{"want qualified, got single", "library/nginx", "nginx", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := imageMatches(tc.want, tc.got); got != tc.shouldMatch {
t.Errorf("imageMatches(%q, %q) = %v, want %v", tc.want, tc.got, got, tc.shouldMatch)
}
})
}
}
func TestMatch(t *testing.T) {
tr := &trigger{}
cfg := mustConfig(t, Config{Image: "registry.example.com/owner/app", TagPattern: "v*"})
wl := plugin.Workload{
ID: "wkl-1",
TriggerConfig: cfg,
}
t.Run("wrong event kind returns nil", func(t *testing.T) {
evt := plugin.InboundEvent{Kind: "git-push"}
intent, err := tr.Match(context.Background(), plugin.Deps{}, wl, evt)
if err != nil || intent != nil {
t.Fatalf("expected nil intent, got intent=%v err=%v", intent, err)
}
})
t.Run("matching push produces intent", func(t *testing.T) {
evt := plugin.InboundEvent{
Kind: "image-push",
Image: &plugin.ImagePushEvent{
Registry: "registry.example.com",
Repo: "owner/app",
Tag: "v1.2.3",
},
}
intent, err := tr.Match(context.Background(), plugin.Deps{}, wl, evt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if intent == nil {
t.Fatal("expected non-nil intent")
}
if intent.Reference != "v1.2.3" {
t.Errorf("intent.Reference = %q, want v1.2.3", intent.Reference)
}
if intent.Reason != "registry-push" {
t.Errorf("intent.Reason = %q, want registry-push", intent.Reason)
}
})
t.Run("tag outside glob returns nil", func(t *testing.T) {
evt := plugin.InboundEvent{
Kind: "image-push",
Image: &plugin.ImagePushEvent{
Registry: "registry.example.com",
Repo: "owner/app",
Tag: "latest", // doesn't match v*
},
}
intent, err := tr.Match(context.Background(), plugin.Deps{}, wl, evt)
if err != nil || intent != nil {
t.Fatalf("expected nil intent for tag=latest, got intent=%v err=%v", intent, err)
}
})
t.Run("wrong repo returns nil", func(t *testing.T) {
evt := plugin.InboundEvent{
Kind: "image-push",
Image: &plugin.ImagePushEvent{
Registry: "registry.example.com",
Repo: "owner/other",
Tag: "v1.0.0",
},
}
intent, err := tr.Match(context.Background(), plugin.Deps{}, wl, evt)
if err != nil || intent != nil {
t.Fatalf("expected nil intent for wrong repo, got intent=%v err=%v", intent, err)
}
})
t.Run("empty pattern matches anything", func(t *testing.T) {
wlAny := plugin.Workload{
ID: "wkl-any",
TriggerConfig: mustConfig(t, Config{Image: "owner/app"}),
}
evt := plugin.InboundEvent{
Kind: "image-push",
Image: &plugin.ImagePushEvent{Repo: "owner/app", Tag: "latest"},
}
intent, err := tr.Match(context.Background(), plugin.Deps{}, wlAny, evt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if intent == nil {
t.Fatal("expected match with empty pattern")
}
})
}
+95
View File
@@ -0,0 +1,95 @@
package plugin
import (
"encoding/json"
"time"
)
// DeploymentIntent is the bridge between a Trigger (which decides "deploy
// this") and a Source (which knows how to deploy). Reference is the
// source-interpreted handle: an image tag for image sources, a git sha for
// compose/static sources, "" for manual.
type DeploymentIntent struct {
Reason string // "registry-push" | "git-push" | "manual" | "cron" | "promote"
Reference string // tag, sha, or "" — Source decides
Metadata map[string]string // extra context (branch name, actor, etc.)
TriggeredAt time.Time
TriggeredBy string // username, "system", "webhook:<delivery-id>"
}
// PublicFace describes one externally-routable face of a Workload. A
// Workload may have several (e.g. compose stack with web + admin services).
// The proxy provider is configured kind-agnostically from this shape.
type PublicFace struct {
Subdomain string // e.g. "myapp"; "" means root of Domain
Domain string // "" inherits from settings.domain
TargetService string // for compose: which service receives traffic; "" = single-container default
TargetPort int // 0 = use container's primary exposed port
AccessListID int // NPM access list, 0 = inherit
EnableSSL bool
}
// InboundEvent is what an upstream signal (webhook, poll, manual click)
// looks like to a Trigger.Match call. Triggers consult Kind first to
// decide whether the event is interesting, then read the matching payload
// field. RawBody / Headers are kept so trigger plugins can perform their
// own signature verification or vendor-specific parsing.
type InboundEvent struct {
Kind string // "image-push" | "git-push" | "git-tag" | "manual" | "cron-tick"
Image *ImagePushEvent
Git *GitEvent
Manual *ManualEvent
RawBody []byte
Headers map[string][]string
}
// ImagePushEvent is normalized across registry vendors (generic, Gitea,
// Docker Hub, ghcr, ...). Vendor-specific quirks are resolved by the
// webhook ingress before construction.
type ImagePushEvent struct {
Registry string // hostname; "" for default registry
Repo string // owner/name
Tag string
Digest string // optional
}
// GitEvent covers both push (commits) and tag-create flavors. Vendor is
// "gitea" | "github" | "gitlab" | "" (autodetected).
type GitEvent struct {
Vendor string
Repo string // owner/name
Ref string // refs/heads/main or refs/tags/v1.2.3
Branch string // populated for branch refs
Tag string // populated for tag refs
CommitSHA string
Pusher string
}
// ManualEvent represents a user-initiated deploy from the UI or API.
type ManualEvent struct {
Actor string
Reference string // optional override (force a specific tag / sha / revision)
Note string
}
// SourceConfigOf decodes the workload's SourceConfig blob into the typed
// shape a specific Source uses. Kept here so callers do not duplicate the
// boilerplate.
func SourceConfigOf[T any](w Workload) (T, error) {
var out T
if len(w.SourceConfig) == 0 {
return out, nil
}
err := json.Unmarshal(w.SourceConfig, &out)
return out, err
}
// TriggerConfigOf is the symmetric helper for TriggerConfig.
func TriggerConfigOf[T any](w Workload) (T, error) {
var out T
if len(w.TriggerConfig) == 0 {
return out, nil
}
err := json.Unmarshal(w.TriggerConfig, &out)
return out, err
}