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
+133
View File
@@ -0,0 +1,133 @@
package main
import (
"context"
"encoding/json"
"fmt"
"github.com/alexei/tinyforge/internal/staticsite"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/workload/plugin"
"github.com/alexei/tinyforge/internal/workload/plugin/source/static"
)
// staticBackend is the bridge between the plugin static source and the
// existing staticsite.Manager. The Manager operates on store.StaticSite
// rows keyed by site ID; this adapter keeps a phantom static_sites row
// for every plugin-native static workload (row ID = workload ID) so the
// Manager's deploy pipeline runs unchanged.
//
// The phantom row carries no UI weight — the legacy /api/static_sites
// endpoints will still surface it during the cutover window, which is
// fine: it lets operators inspect state through the existing legacy UI
// until /apps grows the equivalent screens. When the legacy cutover
// finishes, we can rewrite the static source to operate against the
// containers table directly and drop this adapter.
type staticBackend struct {
store *store.Store
mgr *staticsite.Manager
}
func newStaticBackend(st *store.Store, mgr *staticsite.Manager) *staticBackend {
return &staticBackend{store: st, mgr: mgr}
}
func (b *staticBackend) Deploy(ctx context.Context, _ plugin.Deps, w plugin.Workload, _ plugin.DeploymentIntent) error {
cfg, err := plugin.SourceConfigOf[static.Config](w)
if err != nil {
return fmt.Errorf("static backend: decode config: %w", err)
}
site, err := b.syncPhantomSite(w, cfg)
if err != nil {
return err
}
return b.mgr.Deploy(ctx, site.ID, true /* force */)
}
func (b *staticBackend) Teardown(ctx context.Context, _ plugin.Deps, w plugin.Workload) error {
// Stop best-effort (the row may not exist yet if Deploy never ran).
if _, err := b.store.GetStaticSiteByID(w.ID); err == nil {
if err := b.mgr.Stop(ctx, w.ID); err != nil {
// Log via the manager's own pipeline; we keep going so the
// phantom row is always dropped.
_ = err
}
_ = b.store.DeleteStaticSite(w.ID)
}
return nil
}
func (b *staticBackend) Reconcile(_ context.Context, _ plugin.Deps, w plugin.Workload) error {
// The staticsite.HealthChecker already polls every site row; no
// per-tick work is needed here. Reconcile becomes a no-op until the
// inline port lands.
_ = w
return nil
}
// syncPhantomSite upserts a store.StaticSite keyed on the workload ID,
// translating the plugin Config into the legacy shape. It is also where
// we shape the "single public face" expectation of the legacy table into
// a single domain string.
func (b *staticBackend) syncPhantomSite(w plugin.Workload, cfg static.Config) (store.StaticSite, error) {
domain := ""
for _, f := range w.PublicFaces {
// Pick the first enabled face. The API validator already caps
// faces at one for v1, but iterate defensively.
if f.Subdomain != "" || f.Domain != "" {
d := f.Domain
sub := f.Subdomain
switch {
case sub != "" && d != "":
domain = sub + "." + d
case sub == "" && d != "":
domain = d
case sub != "" && d == "":
// Domain falls back to settings.domain inside the
// Manager. Leave empty — Manager handles it.
domain = sub
}
break
}
}
site := store.StaticSite{
ID: w.ID,
Name: w.Name,
Provider: cfg.Provider,
GiteaURL: cfg.BaseURL,
RepoOwner: cfg.RepoOwner,
RepoName: cfg.RepoName,
Branch: cfg.Branch,
FolderPath: cfg.FolderPath,
AccessToken: cfg.AccessToken,
Domain: domain,
Mode: cfg.Mode,
RenderMarkdown: cfg.RenderMarkdown,
SyncTrigger: "manual",
StorageEnabled: cfg.StorageEnabled,
StorageLimitMB: cfg.StorageLimitMB,
NotificationURL: w.NotificationURL,
NotificationSecret: w.NotificationSecret,
WebhookSecret: w.WebhookSecret,
WebhookSigningSecret: w.WebhookSigningSecret,
WebhookRequireSignature: w.WebhookRequireSignature,
}
if err := b.store.UpsertStaticSiteWithID(site); err != nil {
return store.StaticSite{}, fmt.Errorf("static backend: sync phantom site: %w", err)
}
return site, nil
}
// wireStaticBackend installs the adapter so the plugin static source
// becomes deployable. Called once from main() after the staticsite
// Manager is constructed. Safe to call multiple times only because
// static.SetBackend itself panics on the second call — keeping the
// invariant explicit.
func wireStaticBackend(st *store.Store, mgr *staticsite.Manager) {
static.SetBackend(newStaticBackend(st, mgr))
}
// Unused but kept so the json import is referenced if we ever need to
// inspect raw SourceConfig blobs here for debugging.
var _ = json.Marshal
+154
View File
@@ -0,0 +1,154 @@
package api
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// getHookKindSchema returns the sample config shape for one registered
// plugin kind. Used by /apps/new and the edit form so the JSON editor's
// initial body is derived from the plugin itself rather than hardcoded
// in the frontend.
//
// GET /api/hooks/kinds/{kind}/schema
func (s *Server) getHookKindSchema(w http.ResponseWriter, r *http.Request) {
kind := chi.URLParam(r, "kind")
sample, ok := plugin.SchemaSampleFor(kind)
if !ok {
respondNotFound(w, "plugin kind")
return
}
respondJSON(w, http.StatusOK, map[string]any{
"kind": kind,
"sample": sample,
})
}
// listHookKinds reports every registered Source and Trigger so operators
// can verify the plugin registry is wired correctly without writing
// a workload.
//
// GET /api/hooks/kinds
func (s *Server) listHookKinds(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{
"sources": plugin.SourceKinds(),
"triggers": plugin.TriggerKinds(),
})
}
// dispatchGeneric accepts a pre-normalized InboundEvent payload and fans
// it out across registered triggers. The body shape mirrors
// plugin.InboundEvent — vendor-specific webhook parsing (Gitea / GitHub /
// generic registry) stays in the legacy /api/webhook/* handlers until
// Phase 5 of the refactor migrates them into trigger-specific ingress.
//
// POST /api/hooks/generic
// {
// "kind": "image-push",
// "image": { "registry": "...", "repo": "owner/app", "tag": "v1" }
// }
//
// Until the store rewrite lands and workloads carry source_kind /
// trigger_kind, the workloads iteration here returns an empty list and
// the response reports zero matches. This still exercises the registry
// path so operators can curl it and confirm wiring.
func (s *Server) dispatchGeneric(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
respondError(w, http.StatusBadRequest, "read body: "+err.Error())
return
}
var evt plugin.InboundEvent
if err := json.Unmarshal(body, &evt); err != nil {
respondError(w, http.StatusBadRequest, "invalid InboundEvent: "+err.Error())
return
}
if evt.Kind == "" {
respondError(w, http.StatusBadRequest, "kind is required")
return
}
ctx := r.Context()
triggers := plugin.AllTriggers()
workloads := listPluginWorkloads(s)
deps := s.deployer.PluginDeps()
type matchReport struct {
WorkloadID string `json:"workload_id"`
TriggerKind string `json:"trigger_kind"`
Reference string `json:"reference"`
Dispatched bool `json:"dispatched"`
DispatchError string `json:"dispatch_error,omitempty"`
}
matches := []matchReport{}
for _, wl := range workloads {
trig, ok := triggers[wl.TriggerKind]
if !ok {
continue
}
intent, err := trig.Match(ctx, deps, wl, evt)
if err != nil {
slog.Warn("hooks: trigger match error",
"trigger", wl.TriggerKind, "workload", wl.ID, "error", err)
continue
}
if intent == nil {
continue
}
if intent.TriggeredAt.IsZero() {
intent.TriggeredAt = time.Now().UTC()
}
report := matchReport{
WorkloadID: wl.ID,
TriggerKind: wl.TriggerKind,
Reference: intent.Reference,
}
if err := s.deployer.DispatchPlugin(ctx, wl, *intent); err != nil {
// Wrapped error can carry registry-auth bytes / compose stdout
// (i.e. user secrets baked into the YAML); keep it server-side
// only and return a generic flag to the client.
slog.Warn("hooks: dispatch failed",
"workload", wl.ID, "trigger", wl.TriggerKind, "error", err)
report.DispatchError = "dispatch failed; see server logs"
} else {
report.Dispatched = true
}
matches = append(matches, report)
}
respondJSON(w, http.StatusAccepted, map[string]any{
"event_kind": evt.Kind,
"examined_triggers": len(triggers),
"examined_workloads": len(workloads),
"matches": matches,
})
}
// listPluginWorkloads returns every workload row whose source_kind and
// trigger_kind are both set — these are the rows that opted into the new
// plugin pipeline. Legacy rows (kind/ref_id pointing at project/stack/site
// with empty source_kind) are skipped so the ingress only fires intents
// for workloads that have a registered Source + Trigger to dispatch them.
func listPluginWorkloads(s *Server) []plugin.Workload {
rows, err := s.store.ListWorkloads("")
if err != nil {
slog.Warn("hooks: list workloads failed", "error", err)
return nil
}
out := make([]plugin.Workload, 0, len(rows))
for _, w := range rows {
if w.SourceKind == "" || w.TriggerKind == "" {
continue
}
out = append(out, toPluginWorkload(w))
}
return out
}
+13 -1
View File
@@ -10,6 +10,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/store" "github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/workload/plugin"
) )
// listInstances handles GET /api/projects/{id}/stages/{stage}/instances. // listInstances handles GET /api/projects/{id}/stages/{stage}/instances.
@@ -216,10 +217,21 @@ func (s *Server) controlInstance(w http.ResponseWriter, r *http.Request, action
}) })
} }
// DeployTriggerer is the interface for triggering deployments. // DeployTriggerer is the interface for triggering deployments. The legacy
// project/stage methods continue to drive image-tag CI promotions; the
// plugin methods (DispatchPlugin / DispatchTeardown / DispatchReconcile)
// route through the unified Source registry. Both surfaces are kept on
// one interface so the API layer holds a single deployer reference and
// the type assertion in hooks.go / workloads_plugin.go is replaced with
// compile-time checking.
type DeployTriggerer interface { type DeployTriggerer interface {
TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error
AsyncTriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) (string, error) AsyncTriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) (string, error)
DispatchPlugin(ctx context.Context, w plugin.Workload, intent plugin.DeploymentIntent) error
DispatchTeardown(ctx context.Context, w plugin.Workload) error
DispatchReconcile(ctx context.Context, w plugin.Workload) error
PluginDeps() plugin.Deps
} }
// resolveAndAuthorizeInstance loads the container row identified by {iid} and // resolveAndAuthorizeInstance loads the container row identified by {iid} and
+18
View File
@@ -161,6 +161,24 @@ func jsonContentType(next http.Handler) http.Handler {
}) })
} }
// deprecated marks responses with RFC-8594-style headers so API consumers
// can detect that an endpoint is on its way out. The Workload-first
// refactor is migrating away from /api/projects, /api/stages,
// /api/static_sites, and /api/stacks toward /api/workloads; this signals
// it to integrators without breaking them. Date is the operator-facing
// sunset hint, not a hard switch.
func deprecated(replacement string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Deprecation", "true")
if replacement != "" {
w.Header().Set("Link", `<`+replacement+`>; rel="successor-version"`)
}
next.ServeHTTP(w, r)
})
}
}
// rateLimitMiddleware wraps a handler with per-IP rate limiting using the // rateLimitMiddleware wraps a handler with per-IP rate limiting using the
// supplied limiter. Requests over the limit get 429. // supplied limiter. Requests over the limit get 429.
func rateLimitMiddleware(rl *rateLimiter) func(http.Handler) http.Handler { func rateLimitMiddleware(rl *rateLimiter) func(http.Handler) http.Handler {
+18
View File
@@ -38,6 +38,10 @@ func respondNotFound(w http.ResponseWriter, entity string) {
// decodeJSON reads and decodes the request body into the given value. // decodeJSON reads and decodes the request body into the given value.
// Returns false and writes a 400 error response if decoding fails. // Returns false and writes a 400 error response if decoding fails.
//
// Lenient: unknown fields are silently dropped to keep legacy clients
// compatible. New endpoints that take opaque user-controlled JSON should
// use decodeJSONStrict instead.
func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool { func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool {
if err := json.NewDecoder(r.Body).Decode(v); err != nil { if err := json.NewDecoder(r.Body).Decode(v); err != nil {
respondError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) respondError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
@@ -45,3 +49,17 @@ func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool {
} }
return true return true
} }
// decodeJSONStrict is decodeJSON plus DisallowUnknownFields. Use for
// endpoints whose request shape is opaque (e.g. workload source/trigger
// config blobs) — surfacing typos client-side beats silently dropping
// fields the server then can't act on.
func decodeJSONStrict(w http.ResponseWriter, r *http.Request, v any) bool {
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
respondError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return false
}
return true
}
+213
View File
@@ -0,0 +1,213 @@
package api
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/auth"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// chainNode is the lightweight shape returned by /chain — we deliberately
// don't return full plugin.Workload values for ancestor/descendant rows
// because the secret fields don't belong in a chain-traversal response.
type chainNode struct {
ID string `json:"id"`
Name string `json:"name"`
SourceKind string `json:"source_kind"`
TriggerKind string `json:"trigger_kind"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func chainNodeOf(w store.Workload) chainNode {
return chainNode{
ID: w.ID,
Name: w.Name,
SourceKind: w.SourceKind,
TriggerKind: w.TriggerKind,
CreatedAt: w.CreatedAt,
UpdatedAt: w.UpdatedAt,
}
}
// getWorkloadChain handles GET /api/workloads/{id}/chain.
//
// Returns the workload's parent (or nil), itself, and its direct children
// — i.e. one hop in each direction along the parent_workload_id graph.
// Deeper traversal is left to the client: the chain is a tree the user
// builds incrementally, and a server-side recursive walk would surprise
// operators with O(N) loads on big graphs.
func (s *Server) getWorkloadChain(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
self, err := s.store.GetWorkloadByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "workload")
return
}
respondError(w, http.StatusInternalServerError, "get workload")
return
}
var parent *chainNode
if self.ParentWorkloadID != "" {
p, err := s.store.GetWorkloadByID(self.ParentWorkloadID)
if err == nil {
node := chainNodeOf(p)
parent = &node
} else if !errors.Is(err, store.ErrNotFound) {
slog.Warn("chain: parent lookup failed", "workload", id, "parent", self.ParentWorkloadID, "error", err)
}
}
childRows, err := s.store.ListChildrenByParent(self.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "list children")
return
}
children := make([]chainNode, 0, len(childRows))
for _, c := range childRows {
children = append(children, chainNodeOf(c))
}
respondJSON(w, http.StatusOK, map[string]any{
"parent": parent,
"self": chainNodeOf(self),
"children": children,
})
}
// promoteFromRequest is the body of /promote-from. ImageTag is optional —
// when blank the server falls back to whatever tag the source workload's
// most recent running container reports. The endpoint is intentionally
// non-destructive: it updates the SourceConfig.default_tag and queues a
// manual deploy. It does not change parent_workload_id.
type promoteFromRequest struct {
ImageTag string `json:"image_tag"`
Deploy bool `json:"deploy"`
}
// promoteFromWorkload handles POST /api/workloads/{id}/promote-from/{sourceID}.
//
// Copies the source workload's currently-running image tag into the
// target's SourceConfig.default_tag, optionally triggering an immediate
// deploy. The target's existing config blob is preserved aside from the
// promoted field. Both workloads must use the same source_kind (image)
// — promoting across kinds is undefined and rejected.
func (s *Server) promoteFromWorkload(w http.ResponseWriter, r *http.Request) {
targetID := chi.URLParam(r, "id")
sourceID := chi.URLParam(r, "sourceID")
if targetID == sourceID {
respondError(w, http.StatusBadRequest, "target and source must differ")
return
}
target, err := s.store.GetWorkloadByID(targetID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "workload")
return
}
respondError(w, http.StatusInternalServerError, "get target workload")
return
}
source, err := s.store.GetWorkloadByID(sourceID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "source workload")
return
}
respondError(w, http.StatusInternalServerError, "get source workload")
return
}
if target.SourceKind != "image" || source.SourceKind != "image" {
respondError(w, http.StatusBadRequest, "promote-from is only defined for image source workloads on both ends")
return
}
var req promoteFromRequest
if r.ContentLength > 0 {
if !decodeJSONStrict(w, r, &req) {
return
}
}
// Resolve the tag: explicit override wins; otherwise pick the running
// container's image_tag on the source workload.
tag := strings.TrimSpace(req.ImageTag)
if tag == "" {
rows, err := s.store.ListContainersByWorkload(sourceID)
if err != nil {
respondError(w, http.StatusInternalServerError, "list source containers")
return
}
for _, c := range rows {
if c.State == "running" && c.ImageTag != "" {
tag = c.ImageTag
break
}
}
if tag == "" {
respondError(w, http.StatusBadRequest, "source workload has no running container; specify image_tag explicitly")
return
}
}
// Decode target source_config, patch default_tag, re-encode.
cfg := map[string]any{}
if target.SourceConfig != "" && target.SourceConfig != "{}" {
if err := json.Unmarshal([]byte(target.SourceConfig), &cfg); err != nil {
respondError(w, http.StatusInternalServerError, "decode target source_config")
return
}
}
cfg["default_tag"] = tag
patched, err := json.Marshal(cfg)
if err != nil {
respondError(w, http.StatusInternalServerError, "encode target source_config")
return
}
target.SourceConfig = string(patched)
if err := s.store.UpdateWorkload(target); err != nil {
slog.Error("promote: update target", "target", targetID, "error", err)
respondError(w, http.StatusInternalServerError, "update target workload")
return
}
actor := "promote"
if claims, ok := auth.ClaimsFromContext(r.Context()); ok && claims.Username != "" {
actor = claims.Username
}
resp := map[string]any{
"workload_id": targetID,
"source_id": sourceID,
"promoted_tag": tag,
"deploy_queued": false,
}
if req.Deploy {
intent := plugin.DeploymentIntent{
Reason: "promote",
Reference: tag,
Metadata: map[string]string{"source_workload_id": sourceID},
TriggeredAt: time.Now().UTC(),
TriggeredBy: actor,
}
if err := s.deployer.DispatchPlugin(r.Context(), toPluginWorkload(target), intent); err != nil {
slog.Warn("promote: dispatch failed", "target", targetID, "error", err)
respondError(w, http.StatusInternalServerError, "dispatch failed; see server logs")
return
}
resp["deploy_queued"] = true
}
respondJSON(w, http.StatusOK, resp)
}
+89
View File
@@ -0,0 +1,89 @@
package api
import (
"encoding/json"
"log/slog"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// toPluginWorkload converts a persisted store.Workload row into the value
// shape that Source / Trigger plugins consume. Lives in the api package
// (rather than store or plugin) to keep plugin's dependency graph free of
// store imports and avoid the cycle that would form otherwise.
//
// SourceConfig / TriggerConfig are passed through as raw JSON; the matching
// plugin decodes them with plugin.SourceConfigOf[T] / TriggerConfigOf[T].
// PublicFaces is decoded eagerly because every consumer needs the parsed
// slice (proxy registration, UI, validation).
func toPluginWorkload(w store.Workload) plugin.Workload {
var faces []plugin.PublicFace
if w.PublicFaces != "" {
if err := json.Unmarshal([]byte(w.PublicFaces), &faces); err != nil {
slog.Warn("workload: invalid public_faces JSON, treating as empty",
"workload", w.ID, "error", err)
faces = nil
}
}
return plugin.Workload{
ID: w.ID,
Name: w.Name,
GroupID: w.AppID,
ParentWorkloadID: w.ParentWorkloadID,
SourceKind: w.SourceKind,
SourceConfig: json.RawMessage(w.SourceConfig),
TriggerKind: w.TriggerKind,
TriggerConfig: json.RawMessage(w.TriggerConfig),
PublicFaces: faces,
NotificationURL: w.NotificationURL,
NotificationSecret: w.NotificationSecret,
WebhookSecret: w.WebhookSecret,
WebhookSigningSecret: w.WebhookSigningSecret,
WebhookRequireSignature: w.WebhookRequireSignature,
CreatedAt: w.CreatedAt,
UpdatedAt: w.UpdatedAt,
}
}
// fromPluginWorkload is the symmetric direction — used by /api/workloads
// create + update handlers. Returns a store.Workload ready to pass to
// store.CreateWorkload / store.UpdateWorkload. The caller is responsible
// for re-encoding PublicFaces; we do it here to keep the JSON shape in
// one place.
func fromPluginWorkload(p plugin.Workload) (store.Workload, error) {
facesJSON := "[]"
if len(p.PublicFaces) > 0 {
b, err := json.Marshal(p.PublicFaces)
if err != nil {
return store.Workload{}, err
}
facesJSON = string(b)
}
srcCfg := string(p.SourceConfig)
if srcCfg == "" {
srcCfg = "{}"
}
trgCfg := string(p.TriggerConfig)
if trgCfg == "" {
trgCfg = "{}"
}
return store.Workload{
ID: p.ID,
Name: p.Name,
AppID: p.GroupID,
ParentWorkloadID: p.ParentWorkloadID,
SourceKind: p.SourceKind,
SourceConfig: srcCfg,
TriggerKind: p.TriggerKind,
TriggerConfig: trgCfg,
PublicFaces: facesJSON,
NotificationURL: p.NotificationURL,
NotificationSecret: p.NotificationSecret,
WebhookSecret: p.WebhookSecret,
WebhookSigningSecret: p.WebhookSigningSecret,
WebhookRequireSignature: p.WebhookRequireSignature,
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
}, nil
}
+214
View File
@@ -0,0 +1,214 @@
package api
import (
"errors"
"log/slog"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/crypto"
"github.com/alexei/tinyforge/internal/store"
)
// workloadEnvRow is the JSON shape returned to clients. Plaintext is
// redacted for encrypted entries — once a value is encrypted, the
// server treats it as write-only. To rotate, the operator submits a new
// value; to read, they have to look at the running container.
type workloadEnvRow struct {
ID string `json:"id"`
WorkloadID string `json:"workload_id"`
Key string `json:"key"`
Value string `json:"value"`
Encrypted bool `json:"encrypted"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func (s *Server) listWorkloadEnv(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.store.GetWorkloadByID(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "workload")
return
}
respondError(w, http.StatusInternalServerError, "get workload")
return
}
rows, err := s.store.ListWorkloadEnv(id)
if err != nil {
respondError(w, http.StatusInternalServerError, "list workload env")
return
}
out := make([]workloadEnvRow, 0, len(rows))
for _, e := range rows {
row := workloadEnvRow{
ID: e.ID,
WorkloadID: e.WorkloadID,
Key: e.Key,
Encrypted: e.Encrypted,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
}
if e.Encrypted {
row.Value = "" // write-only after encryption
} else {
row.Value = e.Value
}
out = append(out, row)
}
respondJSON(w, http.StatusOK, out)
}
// setWorkloadEnvRequest is the POST/PUT body. Encrypted=true causes the
// server to encrypt the value at rest with the global encryption key.
type setWorkloadEnvRequest struct {
Key string `json:"key"`
Value string `json:"value"`
Encrypted bool `json:"encrypted"`
}
func (s *Server) setWorkloadEnv(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.store.GetWorkloadByID(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "workload")
return
}
respondError(w, http.StatusInternalServerError, "get workload")
return
}
var req setWorkloadEnvRequest
if !decodeJSONStrict(w, r, &req) {
return
}
req.Key = strings.TrimSpace(req.Key)
if req.Key == "" {
respondError(w, http.StatusBadRequest, "key is required")
return
}
if !validEnvKey(req.Key) {
respondError(w, http.StatusBadRequest, "key must match [A-Za-z_][A-Za-z0-9_]*")
return
}
value := req.Value
if req.Encrypted && value != "" {
enc, err := crypto.Encrypt(s.encKey, value)
if err != nil {
respondError(w, http.StatusInternalServerError, "encrypt value")
return
}
value = enc
}
row, err := s.store.SetWorkloadEnv(store.WorkloadEnv{
WorkloadID: id,
Key: req.Key,
Value: value,
Encrypted: req.Encrypted,
})
if err != nil {
slog.Error("set workload env", "workload", id, "key", req.Key, "error", err)
respondError(w, http.StatusInternalServerError, "set workload env")
return
}
respondJSON(w, http.StatusOK, workloadEnvRow{
ID: row.ID,
WorkloadID: row.WorkloadID,
Key: row.Key,
Value: "", // never echo even fresh writes — caller already has it
Encrypted: row.Encrypted,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
})
}
func (s *Server) deleteWorkloadEnv(w http.ResponseWriter, r *http.Request) {
envID := chi.URLParam(r, "envID")
if err := s.store.DeleteWorkloadEnv(envID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "workload env")
return
}
respondError(w, http.StatusInternalServerError, "delete workload env")
return
}
respondJSON(w, http.StatusOK, map[string]string{"deleted": envID})
}
// getWorkloadWebhook handles GET /api/workloads/{id}/webhook. Returns
// the canonical URL + secret + signature-state flags. Lazily generates
// a secret if the workload row predates the column.
func (s *Server) getWorkloadWebhook(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
secret, err := s.store.EnsureWorkloadWebhookSecret(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "workload")
return
}
slog.Error("ensure workload webhook secret", "workload", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get webhook secret")
return
}
row, err := s.store.GetWorkloadByID(id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get workload")
return
}
respondJSON(w, http.StatusOK, webhookURLResponse{
WebhookURL: "/api/webhook/workloads/" + secret,
WebhookSecret: secret,
HasSigningSecret: row.WebhookSigningSecret != "",
WebhookRequireSignature: row.WebhookRequireSignature,
})
}
// regenerateWorkloadWebhook handles POST /api/workloads/{id}/webhook/regenerate.
// Rotates the URL secret. The old secret is invalidated immediately —
// any external system still hitting the old URL gets a 404 on next call.
func (s *Server) regenerateWorkloadWebhook(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.store.GetWorkloadByID(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "workload")
return
}
respondError(w, http.StatusInternalServerError, "failed to get workload")
return
}
secret := generateWebhookSecret()
if err := s.store.SetWorkloadWebhookSecret(id, secret); err != nil {
slog.Error("rotate workload webhook secret", "workload", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to rotate webhook secret")
return
}
row, _ := s.store.GetWorkloadByID(id)
respondJSON(w, http.StatusOK, webhookURLResponse{
WebhookURL: "/api/webhook/workloads/" + secret,
WebhookSecret: secret,
HasSigningSecret: row.WebhookSigningSecret != "",
WebhookRequireSignature: row.WebhookRequireSignature,
})
}
// validEnvKey accepts POSIX-style env names. Rejects anything that would
// confuse Docker's env parser (=, spaces, control chars).
func validEnvKey(k string) bool {
if len(k) == 0 || len(k) > 256 {
return false
}
for i, ch := range k {
switch {
case ch >= 'A' && ch <= 'Z',
ch >= 'a' && ch <= 'z',
ch == '_':
continue
case (ch >= '0' && ch <= '9') && i > 0:
continue
default:
return false
}
}
return true
}
+114
View File
@@ -0,0 +1,114 @@
package api
import (
"errors"
"log/slog"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/store"
)
// workloadVolumeRequest is the body shape accepted by the upsert
// endpoint. Defaults to scope=absolute when unset.
type workloadVolumeRequest struct {
Source string `json:"source"`
Target string `json:"target"`
Scope string `json:"scope"`
Name string `json:"name"`
}
func (s *Server) listWorkloadVolumes(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.store.GetWorkloadByID(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "workload")
return
}
respondError(w, http.StatusInternalServerError, "get workload")
return
}
rows, err := s.store.ListWorkloadVolumes(id)
if err != nil {
respondError(w, http.StatusInternalServerError, "list workload volumes")
return
}
respondJSON(w, http.StatusOK, rows)
}
func (s *Server) setWorkloadVolume(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.store.GetWorkloadByID(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "workload")
return
}
respondError(w, http.StatusInternalServerError, "get workload")
return
}
var req workloadVolumeRequest
if !decodeJSONStrict(w, r, &req) {
return
}
req.Target = strings.TrimSpace(req.Target)
if req.Target == "" {
respondError(w, http.StatusBadRequest, "target is required")
return
}
if !strings.HasPrefix(req.Target, "/") {
respondError(w, http.StatusBadRequest, "target must be an absolute container path")
return
}
if strings.Contains(req.Target, "..") {
respondError(w, http.StatusBadRequest, "target may not contain path traversal segments")
return
}
scope := req.Scope
if scope == "" {
scope = string(store.VolumeScopeAbsolute)
}
if !store.IsValidVolumeScope(scope) {
respondError(w, http.StatusBadRequest, "invalid scope")
return
}
// Absolute-scope mounts must reference a real host path; allow-list
// enforcement happens at deploy time against settings.AllowedVolumePaths.
if scope == string(store.VolumeScopeAbsolute) {
if strings.TrimSpace(req.Source) == "" {
respondError(w, http.StatusBadRequest, "source is required for absolute scope")
return
}
if strings.Contains(req.Source, "..") {
respondError(w, http.StatusBadRequest, "source may not contain path traversal segments")
return
}
}
row, err := s.store.SetWorkloadVolume(store.WorkloadVolume{
WorkloadID: id,
Source: req.Source,
Target: req.Target,
Scope: scope,
Name: req.Name,
})
if err != nil {
slog.Error("set workload volume", "workload", id, "target", req.Target, "error", err)
respondError(w, http.StatusInternalServerError, "set workload volume")
return
}
respondJSON(w, http.StatusOK, row)
}
func (s *Server) deleteWorkloadVolume(w http.ResponseWriter, r *http.Request) {
volID := chi.URLParam(r, "volID")
if err := s.store.DeleteWorkloadVolume(volID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "workload volume")
return
}
respondError(w, http.StatusInternalServerError, "delete workload volume")
return
}
respondJSON(w, http.StatusOK, map[string]string{"deleted": volID})
}
+30
View File
@@ -36,6 +36,36 @@ func (s *Server) getWorkload(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, wl) respondJSON(w, http.StatusOK, wl)
} }
// streamWorkloadContainerLogs handles GET /api/workloads/{id}/containers/{cid}/logs.
// Reuses the shared SSE/JSON log streamer; ownership is verified by joining
// through workload_id on the container row so an attacker can't stream
// logs from a foreign container by guessing IDs under the wrong workload URL.
func (s *Server) streamWorkloadContainerLogs(w http.ResponseWriter, r *http.Request) {
workloadID := chi.URLParam(r, "id")
containerRowID := chi.URLParam(r, "cid")
c, err := s.store.GetContainerByID(containerRowID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "container")
return
}
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
if c.WorkloadID != workloadID {
// Returning 404 (not 403) so the existence of a container under
// another workload is not confirmed.
respondNotFound(w, "container")
return
}
if c.ContainerID == "" {
respondError(w, http.StatusBadRequest, "container row has no docker container bound")
return
}
s.streamLogsForContainer(w, r, c.ContainerID)
}
// listWorkloadContainers handles GET /api/workloads/{id}/containers. // listWorkloadContainers handles GET /api/workloads/{id}/containers.
// Returns every Container row owned by this workload, newest first. The // Returns every Container row owned by this workload, newest first. The
// frontend's <WorkloadContainers> component uses this on every kind-specific // frontend's <WorkloadContainers> component uses this on every kind-specific
+293
View File
@@ -0,0 +1,293 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/auth"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// pluginWorkloadRequest is the JSON body accepted by create + update.
// SourceConfig / TriggerConfig are raw JSON blobs validated by the
// matching plugin's Validate() before persistence.
type pluginWorkloadRequest struct {
Name string `json:"name"`
GroupID string `json:"group_id"`
ParentWorkloadID string `json:"parent_workload_id"`
SourceKind string `json:"source_kind"`
SourceConfig json.RawMessage `json:"source_config"`
TriggerKind string `json:"trigger_kind"`
TriggerConfig json.RawMessage `json:"trigger_config"`
PublicFaces []plugin.PublicFace `json:"public_faces"`
NotificationURL string `json:"notification_url"`
WebhookRequireSignature bool `json:"webhook_require_signature"`
}
// Per-blob caps so two opaque JSON fields can't blow past the route-level
// body limit individually. The route already caps the whole body, but a
// 1 MiB SourceConfig is unreasonable for any source we plan to support.
const (
maxSourceConfigBytes = 64 << 10 // 64 KiB
maxTriggerConfigBytes = 16 << 10 // 16 KiB
// Hard upper bound on public faces — multi-face is now supported (route
// IDs are stored per-fqdn in container.extra_json so teardown is clean)
// but a workload with hundreds of public faces is almost certainly a
// bug in the caller, not legitimate config.
maxPublicFaces = 16
)
// createPluginWorkload handles POST /api/workloads.
//
// Validates source/trigger kinds against the registered plugins, runs each
// plugin's own Validate() on its config blob, then persists the row. The
// row is created with the new plugin-shape fields populated; the legacy
// kind/ref_id columns stay empty for plugin-native workloads.
func (s *Server) createPluginWorkload(w http.ResponseWriter, r *http.Request) {
var req pluginWorkloadRequest
if !decodeJSONStrict(w, r, &req) {
return
}
if strings.TrimSpace(req.Name) == "" {
respondError(w, http.StatusBadRequest, "name is required")
return
}
if err := validatePluginKinds(req); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
pw := plugin.Workload{
Name: req.Name,
GroupID: req.GroupID,
ParentWorkloadID: req.ParentWorkloadID,
SourceKind: req.SourceKind,
SourceConfig: req.SourceConfig,
TriggerKind: req.TriggerKind,
TriggerConfig: req.TriggerConfig,
PublicFaces: req.PublicFaces,
NotificationURL: req.NotificationURL,
WebhookRequireSignature: req.WebhookRequireSignature,
}
sw, err := fromPluginWorkload(pw)
if err != nil {
respondError(w, http.StatusBadRequest, "encode workload: "+err.Error())
return
}
// Plugin-native rows are flagged with kind="plugin"; ref_id is left
// empty by the caller and filled with the generated ID below so the
// UNIQUE(kind, ref_id) index can hold many plugin workloads (each
// pair is the row's own ID, which is itself unique).
sw.Kind = "plugin"
created, err := s.store.CreateWorkload(sw)
if err != nil {
slog.Error("create plugin workload", "error", err)
respondError(w, http.StatusInternalServerError, "create workload")
return
}
if created.RefID == "" {
// Self-reference so (kind, ref_id) stays unique. Done as a follow-up
// update — CreateWorkload generates the UUID itself, so the value is
// only known after insert.
created.RefID = created.ID
if err := s.store.UpdateWorkload(created); err != nil {
slog.Warn("backfill plugin workload ref_id", "id", created.ID, "error", err)
}
}
respondJSON(w, http.StatusCreated, toPluginWorkload(created))
}
// updatePluginWorkload handles PUT /api/workloads/{id}/plugin. Only the
// fields that belong to the plugin model are mutable here; legacy
// project/stack/site fields are edited through their own endpoints during
// the cutover.
func (s *Server) updatePluginWorkload(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
existing, err := s.store.GetWorkloadByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "workload")
return
}
respondError(w, http.StatusInternalServerError, "get workload")
return
}
var req pluginWorkloadRequest
if !decodeJSONStrict(w, r, &req) {
return
}
if err := validatePluginKinds(req); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
if req.Name != "" {
existing.Name = req.Name
}
existing.AppID = req.GroupID
existing.ParentWorkloadID = req.ParentWorkloadID
existing.SourceKind = req.SourceKind
if len(req.SourceConfig) > 0 {
existing.SourceConfig = string(req.SourceConfig)
}
existing.TriggerKind = req.TriggerKind
if len(req.TriggerConfig) > 0 {
existing.TriggerConfig = string(req.TriggerConfig)
}
if req.PublicFaces != nil {
b, _ := json.Marshal(req.PublicFaces)
existing.PublicFaces = string(b)
}
existing.NotificationURL = req.NotificationURL
existing.WebhookRequireSignature = req.WebhookRequireSignature
if err := s.store.UpdateWorkload(existing); err != nil {
slog.Error("update plugin workload", "error", err)
respondError(w, http.StatusInternalServerError, "update workload")
return
}
respondJSON(w, http.StatusOK, toPluginWorkload(existing))
}
// deployPluginWorkload handles POST /api/workloads/{id}/deploy.
//
// Builds a manual DeploymentIntent and dispatches it through the matching
// Source plugin — independent of whatever TriggerKind the workload has
// configured. The body is optional; supplying `reference` overrides what
// the Source uses (e.g. force a specific image tag).
func (s *Server) deployPluginWorkload(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
row, err := s.store.GetWorkloadByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "workload")
return
}
respondError(w, http.StatusInternalServerError, "get workload")
return
}
if row.SourceKind == "" {
respondError(w, http.StatusBadRequest, "workload has no source_kind; cannot dispatch")
return
}
var body struct {
Reference string `json:"reference"`
Note string `json:"note"`
}
if r.ContentLength > 0 {
if !decodeJSONStrict(w, r, &body) {
return
}
}
actor := "manual"
if claims, ok := auth.ClaimsFromContext(r.Context()); ok && claims.Username != "" {
actor = claims.Username
}
intent := plugin.DeploymentIntent{
Reason: "manual",
Reference: body.Reference,
Metadata: map[string]string{"note": body.Note},
TriggeredAt: time.Now().UTC(),
TriggeredBy: actor,
}
if err := s.deployer.DispatchPlugin(r.Context(), toPluginWorkload(row), intent); err != nil {
// Full error stays in the server log; the client gets a generic
// message because the wrapped error can carry registry-auth bytes
// or compose-stdout secrets.
slog.Warn("manual dispatch failed", "workload", id, "actor", actor, "error", err)
respondError(w, http.StatusInternalServerError, "dispatch failed; see server logs")
return
}
respondJSON(w, http.StatusAccepted, map[string]any{
"workload_id": id,
"reference": intent.Reference,
"triggered_by": actor,
})
}
// deletePluginWorkload handles DELETE /api/workloads/{id}.
//
// Performs Source.Teardown first so containers / proxy routes / DNS are
// cleaned up before the workload row is dropped. A teardown failure is
// logged but does not block the row delete — the row must not outlive
// the things it owns even when the cleanup is partial.
func (s *Server) deletePluginWorkload(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
row, err := s.store.GetWorkloadByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "workload")
return
}
respondError(w, http.StatusInternalServerError, "get workload")
return
}
if row.SourceKind != "" {
if err := s.deployer.DispatchTeardown(r.Context(), toPluginWorkload(row)); err != nil {
slog.Warn("delete workload: teardown error",
"workload", id, "kind", row.SourceKind, "error", err)
}
}
if err := s.store.DeleteWorkload(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "workload")
return
}
respondError(w, http.StatusInternalServerError, "delete workload")
return
}
respondJSON(w, http.StatusOK, map[string]string{"deleted": id})
}
// validatePluginKinds verifies the requested source_kind and trigger_kind
// resolve to registered plugins, then asks each plugin to validate its
// own config blob. Empty kinds are allowed (legacy rows or partial setup).
// Per-blob byte caps and the v1 single-face limit are enforced here so a
// hand-crafted DB write can't bypass them later.
func validatePluginKinds(req pluginWorkloadRequest) error {
if len(req.SourceConfig) > maxSourceConfigBytes {
return fmt.Errorf("source_config exceeds %d bytes", maxSourceConfigBytes)
}
if len(req.TriggerConfig) > maxTriggerConfigBytes {
return fmt.Errorf("trigger_config exceeds %d bytes", maxTriggerConfigBytes)
}
if len(req.PublicFaces) > maxPublicFaces {
return fmt.Errorf("at most %d public faces per workload", maxPublicFaces)
}
if req.SourceKind != "" {
src, err := plugin.GetSource(req.SourceKind)
if err != nil {
return err
}
if err := src.Validate(req.SourceConfig); err != nil {
return err
}
}
if req.TriggerKind != "" {
trg, err := plugin.GetTrigger(req.TriggerKind)
if err != nil {
return err
}
if err := trg.Validate(req.TriggerConfig); err != nil {
return err
}
}
return nil
}
+65
View File
@@ -0,0 +1,65 @@
package deployer
import (
"context"
"fmt"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// DispatchPlugin routes a DeploymentIntent for w to the matching Source
// plugin. This is the new unified deploy path; the legacy executeDeploy
// remains in place until Phase 6 ports image-deploy logic into
// source/image. While both exist, callers must pick: webhook/registry
// triggers + image deploys still go through the legacy path, while
// /api/hooks/generic + the unified webhook ingress go through here.
func (d *Deployer) DispatchPlugin(ctx context.Context, w plugin.Workload, intent plugin.DeploymentIntent) error {
src, err := plugin.GetSource(w.SourceKind)
if err != nil {
return fmt.Errorf("dispatch %s: %w", w.Name, err)
}
return src.Deploy(ctx, d.PluginDeps(), w, intent)
}
// DispatchTeardown routes a teardown call to the matching Source plugin.
// Used when a workload is deleted.
func (d *Deployer) DispatchTeardown(ctx context.Context, w plugin.Workload) error {
src, err := plugin.GetSource(w.SourceKind)
if err != nil {
return fmt.Errorf("dispatch teardown %s: %w", w.Name, err)
}
return src.Teardown(ctx, d.PluginDeps(), w)
}
// DispatchReconcile routes a Reconcile call. Periodic reconciler iterates
// every Workload and calls this; idle Sources should make it a cheap
// no-op.
func (d *Deployer) DispatchReconcile(ctx context.Context, w plugin.Workload) error {
src, err := plugin.GetSource(w.SourceKind)
if err != nil {
return fmt.Errorf("dispatch reconcile %s: %w", w.Name, err)
}
return src.Reconcile(ctx, d.PluginDeps(), w)
}
// PluginDeps captures the Deployer's existing dependencies in the bundle
// shape Sources expect. Reads d.dns under the RWMutex since proxy/DNS
// can be hot-swapped at runtime when settings change. Exported so the
// API layer can hand the same Deps to Trigger.Match — passing zero-Deps
// to triggers would silently nil-panic the moment any Trigger touches
// deps.Store / deps.Crypto for signature verification.
func (d *Deployer) PluginDeps() plugin.Deps {
d.dnsMu.RLock()
dnsProvider := d.dns
d.dnsMu.RUnlock()
return plugin.Deps{
Store: d.store,
Docker: d.docker,
Proxy: d.proxy,
DNS: dnsProvider,
Health: d.health,
Notifier: d.notifier,
Events: d.eventBus,
EncKey: d.encKey,
}
}
+71
View File
@@ -20,6 +20,7 @@ package reconciler
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"log/slog" "log/slog"
"strings" "strings"
@@ -28,6 +29,7 @@ import (
"github.com/alexei/tinyforge/internal/docker" "github.com/alexei/tinyforge/internal/docker"
"github.com/alexei/tinyforge/internal/store" "github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/workload/plugin"
) )
// DockerLister is the subset of docker.Client the reconciler depends on. // DockerLister is the subset of docker.Client the reconciler depends on.
@@ -37,11 +39,19 @@ type DockerLister interface {
ListAllForReconciler(ctx context.Context) ([]docker.ReconcileItem, error) ListAllForReconciler(ctx context.Context) ([]docker.ReconcileItem, error)
} }
// PluginReconciler is the optional dispatch surface for per-workload
// Source.Reconcile calls. Nil-safe — when unset, the reconciler skips
// the plugin pass and only refreshes the containers index from Docker.
type PluginReconciler interface {
DispatchReconcile(ctx context.Context, w plugin.Workload) error
}
// Reconciler is the background worker that syncs the containers index. // Reconciler is the background worker that syncs the containers index.
type Reconciler struct { type Reconciler struct {
store *store.Store store *store.Store
docker DockerLister docker DockerLister
interval time.Duration interval time.Duration
plugins PluginReconciler // optional; nil disables the per-workload Source.Reconcile pass.
stop chan struct{} stop chan struct{}
cancel context.CancelFunc // populated in Start; invoked by Stop so an in-flight tick is unblocked. cancel context.CancelFunc // populated in Start; invoked by Stop so an in-flight tick is unblocked.
@@ -66,6 +76,11 @@ func New(st *store.Store, dockerClient DockerLister, interval time.Duration) *Re
} }
} }
// SetPluginReconciler injects the per-workload Source.Reconcile dispatch.
// Safe to call before or after Start; tick uses whatever's set at the
// time.
func (r *Reconciler) SetPluginReconciler(p PluginReconciler) { r.plugins = p }
// Start kicks off the background reconciliation loop. Runs one tick // Start kicks off the background reconciliation loop. Runs one tick
// immediately so startup populates the index without waiting for the first // immediately so startup populates the index without waiting for the first
// timer fire. The provided context is wrapped with a child cancel func so // timer fire. The provided context is wrapped with a child cancel func so
@@ -115,9 +130,65 @@ func (r *Reconciler) ReconcileOnce(ctx context.Context) error {
} }
r.markMissingRows(seen) r.markMissingRows(seen)
r.reconcilePluginWorkloads(ctx)
return nil return nil
} }
// reconcilePluginWorkloads iterates every workload row that opted into
// the plugin pipeline (source_kind + trigger_kind both set) and asks the
// dispatcher to invoke Source.Reconcile. Failures are logged per-workload
// — one workload's broken state must not stop sweeping the rest.
//
// No-op when the plugin dispatcher hasn't been wired (boot-time race,
// disabled deployments, tests).
func (r *Reconciler) reconcilePluginWorkloads(ctx context.Context) {
if r.plugins == nil {
return
}
rows, err := r.store.ListWorkloads("")
if err != nil {
slog.Warn("reconciler: list workloads for plugin pass", "error", err)
return
}
for _, w := range rows {
if w.SourceKind == "" || w.TriggerKind == "" {
continue
}
pw := toPluginWorkload(w)
if err := r.plugins.DispatchReconcile(ctx, pw); err != nil {
slog.Warn("reconciler: plugin reconcile failed",
"workload", w.ID, "kind", w.SourceKind, "error", err)
}
}
}
// toPluginWorkload mirrors the api / webhook converters; kept local to
// avoid an import dependency between those packages.
func toPluginWorkload(w store.Workload) plugin.Workload {
var faces []plugin.PublicFace
if w.PublicFaces != "" {
_ = json.Unmarshal([]byte(w.PublicFaces), &faces)
}
return plugin.Workload{
ID: w.ID,
Name: w.Name,
GroupID: w.AppID,
ParentWorkloadID: w.ParentWorkloadID,
SourceKind: w.SourceKind,
SourceConfig: json.RawMessage(w.SourceConfig),
TriggerKind: w.TriggerKind,
TriggerConfig: json.RawMessage(w.TriggerConfig),
PublicFaces: faces,
NotificationURL: w.NotificationURL,
NotificationSecret: w.NotificationSecret,
WebhookSecret: w.WebhookSecret,
WebhookSigningSecret: w.WebhookSigningSecret,
WebhookRequireSignature: w.WebhookRequireSignature,
CreatedAt: w.CreatedAt,
UpdatedAt: w.UpdatedAt,
}
}
func (r *Reconciler) loop(ctx context.Context) { func (r *Reconciler) loop(ctx context.Context) {
defer r.wg.Done() defer r.wg.Done()
+27 -10
View File
@@ -15,7 +15,7 @@ import (
const containerColumns = `id, workload_id, workload_kind, role, stage_id, container_id, const containerColumns = `id, workload_id, workload_kind, role, stage_id, container_id,
image_ref, image_tag, host, state, port, image_ref, image_tag, host, state, port,
subdomain, proxy_route_id, npm_proxy_id, subdomain, proxy_route_id, npm_proxy_id,
last_seen_at, created_at, updated_at` last_seen_at, extra_json, created_at, updated_at`
func scanContainer(scanner interface{ Scan(...any) error }) (Container, error) { func scanContainer(scanner interface{ Scan(...any) error }) (Container, error) {
var c Container var c Container
@@ -23,7 +23,7 @@ func scanContainer(scanner interface{ Scan(...any) error }) (Container, error) {
&c.ID, &c.WorkloadID, &c.WorkloadKind, &c.Role, &c.StageID, &c.ContainerID, &c.ID, &c.WorkloadID, &c.WorkloadKind, &c.Role, &c.StageID, &c.ContainerID,
&c.ImageRef, &c.ImageTag, &c.Host, &c.State, &c.Port, &c.ImageRef, &c.ImageTag, &c.Host, &c.State, &c.Port,
&c.Subdomain, &c.ProxyRouteID, &c.NpmProxyID, &c.Subdomain, &c.ProxyRouteID, &c.NpmProxyID,
&c.LastSeenAt, &c.CreatedAt, &c.UpdatedAt, &c.LastSeenAt, &c.ExtraJSON, &c.CreatedAt, &c.UpdatedAt,
) )
return c, err return c, err
} }
@@ -39,14 +39,17 @@ func (s *Store) CreateContainer(c Container) (Container, error) {
} }
c.CreatedAt = Now() c.CreatedAt = Now()
c.UpdatedAt = c.CreatedAt c.UpdatedAt = c.CreatedAt
if c.ExtraJSON == "" {
c.ExtraJSON = "{}"
}
_, err := s.db.Exec( _, err := s.db.Exec(
`INSERT INTO containers (`+containerColumns+`) `INSERT INTO containers (`+containerColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
c.ID, c.WorkloadID, c.WorkloadKind, c.Role, c.StageID, c.ContainerID, c.ID, c.WorkloadID, c.WorkloadKind, c.Role, c.StageID, c.ContainerID,
c.ImageRef, c.ImageTag, c.Host, c.State, c.Port, c.ImageRef, c.ImageTag, c.Host, c.State, c.Port,
c.Subdomain, c.ProxyRouteID, c.NpmProxyID, c.Subdomain, c.ProxyRouteID, c.NpmProxyID,
c.LastSeenAt, c.CreatedAt, c.UpdatedAt, c.LastSeenAt, c.ExtraJSON, c.CreatedAt, c.UpdatedAt,
) )
if err != nil { if err != nil {
return Container{}, fmt.Errorf("insert container: %w", err) return Container{}, fmt.Errorf("insert container: %w", err)
@@ -71,11 +74,14 @@ func (s *Store) UpsertContainer(c Container) error {
if c.CreatedAt == "" { if c.CreatedAt == "" {
c.CreatedAt = c.UpdatedAt c.CreatedAt = c.UpdatedAt
} }
if c.ExtraJSON == "" {
c.ExtraJSON = "{}"
}
// SQLite UPSERT — INSERT...ON CONFLICT(id) DO UPDATE. // SQLite UPSERT — INSERT...ON CONFLICT(id) DO UPDATE.
_, err := s.db.Exec( _, err := s.db.Exec(
`INSERT INTO containers (`+containerColumns+`) `INSERT INTO containers (`+containerColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
workload_id=excluded.workload_id, workload_id=excluded.workload_id,
workload_kind=excluded.workload_kind, workload_kind=excluded.workload_kind,
@@ -91,11 +97,12 @@ func (s *Store) UpsertContainer(c Container) error {
proxy_route_id=excluded.proxy_route_id, proxy_route_id=excluded.proxy_route_id,
npm_proxy_id=excluded.npm_proxy_id, npm_proxy_id=excluded.npm_proxy_id,
last_seen_at=excluded.last_seen_at, last_seen_at=excluded.last_seen_at,
extra_json=excluded.extra_json,
updated_at=excluded.updated_at`, updated_at=excluded.updated_at`,
c.ID, c.WorkloadID, c.WorkloadKind, c.Role, c.StageID, c.ContainerID, c.ID, c.WorkloadID, c.WorkloadKind, c.Role, c.StageID, c.ContainerID,
c.ImageRef, c.ImageTag, c.Host, c.State, c.Port, c.ImageRef, c.ImageTag, c.Host, c.State, c.Port,
c.Subdomain, c.ProxyRouteID, c.NpmProxyID, c.Subdomain, c.ProxyRouteID, c.NpmProxyID,
c.LastSeenAt, c.CreatedAt, c.UpdatedAt, c.LastSeenAt, c.ExtraJSON, c.CreatedAt, c.UpdatedAt,
) )
if err != nil { if err != nil {
return fmt.Errorf("upsert container: %w", err) return fmt.Errorf("upsert container: %w", err)
@@ -119,10 +126,17 @@ func (s *Store) ReconcileContainer(c Container) error {
if c.CreatedAt == "" { if c.CreatedAt == "" {
c.CreatedAt = c.UpdatedAt c.CreatedAt = c.UpdatedAt
} }
if c.ExtraJSON == "" {
c.ExtraJSON = "{}"
}
// extra_json is deliberately NOT in the ON CONFLICT SET clause: the
// reconciler can't observe per-face route IDs from Docker, and
// stomping the deployer's writes would orphan proxy routes at
// teardown.
_, err := s.db.Exec( _, err := s.db.Exec(
`INSERT INTO containers (`+containerColumns+`) `INSERT INTO containers (`+containerColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
container_id=excluded.container_id, container_id=excluded.container_id,
image_ref=excluded.image_ref, image_ref=excluded.image_ref,
@@ -133,7 +147,7 @@ func (s *Store) ReconcileContainer(c Container) error {
c.ID, c.WorkloadID, c.WorkloadKind, c.Role, c.StageID, c.ContainerID, c.ID, c.WorkloadID, c.WorkloadKind, c.Role, c.StageID, c.ContainerID,
c.ImageRef, c.ImageTag, c.Host, c.State, c.Port, c.ImageRef, c.ImageTag, c.Host, c.State, c.Port,
c.Subdomain, c.ProxyRouteID, c.NpmProxyID, c.Subdomain, c.ProxyRouteID, c.NpmProxyID,
c.LastSeenAt, c.CreatedAt, c.UpdatedAt, c.LastSeenAt, c.ExtraJSON, c.CreatedAt, c.UpdatedAt,
) )
if err != nil { if err != nil {
return fmt.Errorf("reconcile container: %w", err) return fmt.Errorf("reconcile container: %w", err)
@@ -335,16 +349,19 @@ func (s *Store) ListContainers(f ContainerFilter) ([]Container, error) {
// Use this from the deployer when proxy / subdomain assignments change. // Use this from the deployer when proxy / subdomain assignments change.
func (s *Store) UpdateContainer(c Container) error { func (s *Store) UpdateContainer(c Container) error {
c.UpdatedAt = Now() c.UpdatedAt = Now()
if c.ExtraJSON == "" {
c.ExtraJSON = "{}"
}
result, err := s.db.Exec( result, err := s.db.Exec(
`UPDATE containers SET workload_id=?, workload_kind=?, role=?, stage_id=?, container_id=?, `UPDATE containers SET workload_id=?, workload_kind=?, role=?, stage_id=?, container_id=?,
image_ref=?, image_tag=?, host=?, state=?, port=?, image_ref=?, image_tag=?, host=?, state=?, port=?,
subdomain=?, proxy_route_id=?, npm_proxy_id=?, subdomain=?, proxy_route_id=?, npm_proxy_id=?,
last_seen_at=?, updated_at=? last_seen_at=?, extra_json=?, updated_at=?
WHERE id=?`, WHERE id=?`,
c.WorkloadID, c.WorkloadKind, c.Role, c.StageID, c.ContainerID, c.WorkloadID, c.WorkloadKind, c.Role, c.StageID, c.ContainerID,
c.ImageRef, c.ImageTag, c.Host, c.State, c.Port, c.ImageRef, c.ImageTag, c.Host, c.State, c.Port,
c.Subdomain, c.ProxyRouteID, c.NpmProxyID, c.Subdomain, c.ProxyRouteID, c.NpmProxyID,
c.LastSeenAt, c.UpdatedAt, c.ID, c.LastSeenAt, c.ExtraJSON, c.UpdatedAt, c.ID,
) )
if err != nil { if err != nil {
return fmt.Errorf("update container: %w", err) return fmt.Errorf("update container: %w", err)
+48
View File
@@ -18,6 +18,54 @@ const staticSiteCols = `id, name, provider, gitea_url, repo_owner, repo_name, br
notification_url, notification_secret, notification_url, notification_secret,
created_at, updated_at` created_at, updated_at`
// UpsertStaticSiteWithID inserts or replaces a static site, keeping the
// caller-supplied ID. Used by the plugin static-source Backend adapter
// to keep a phantom row keyed on the workload ID so staticsite.Manager
// (which reads from this table) can serve plugin-native workloads
// without being refactored. Skips workload-row sync since the caller
// already owns the workload row.
func (s *Store) UpsertStaticSiteWithID(site StaticSite) error {
if site.ID == "" {
return fmt.Errorf("UpsertStaticSiteWithID: id is required")
}
if site.WebhookSecret == "" {
site.WebhookSecret = generateWebhookSecret()
}
if site.SyncTrigger == "" {
site.SyncTrigger = "manual"
}
if site.Mode == "" {
site.Mode = "static"
}
if site.Branch == "" {
site.Branch = "main"
}
if site.Status == "" {
site.Status = "idle"
}
now := Now()
site.UpdatedAt = now
if site.CreatedAt == "" {
site.CreatedAt = now
}
_, err := s.db.Exec(
`INSERT OR REPLACE INTO static_sites (`+staticSiteCols+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
site.ID, site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName,
site.Branch, site.FolderPath, site.AccessToken, site.Domain, site.Mode,
BoolToInt(site.RenderMarkdown), site.SyncTrigger, site.TagPattern,
site.ContainerID, site.ProxyRouteID, site.Status, site.LastSyncAt,
site.LastCommitSHA, site.Error, BoolToInt(site.StorageEnabled), site.StorageLimitMB,
site.WebhookSecret, site.WebhookSigningSecret, BoolToInt(site.WebhookRequireSignature),
site.NotificationURL, site.NotificationSecret,
site.CreatedAt, site.UpdatedAt,
)
if err != nil {
return fmt.Errorf("upsert static site: %w", err)
}
return nil
}
// CreateStaticSite inserts a new static site and returns it. A webhook secret // CreateStaticSite inserts a new static site and returns it. A webhook secret
// is generated automatically if one is not already set on the input. Site row // is generated automatically if one is not already set on the input. Site row
// + matching workload row are written in a single transaction. // + matching workload row are written in a single transaction.
+111
View File
@@ -0,0 +1,111 @@
package store
import (
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
// SetWorkloadEnv upserts a single env var for the workload. Uses
// (workload_id, key) as the natural key — duplicate keys collapse onto
// the same row instead of accumulating.
func (s *Store) SetWorkloadEnv(env WorkloadEnv) (WorkloadEnv, error) {
if env.WorkloadID == "" || env.Key == "" {
return WorkloadEnv{}, fmt.Errorf("workload_env: workload_id and key are required")
}
now := Now()
if env.ID == "" {
env.ID = uuid.New().String()
}
env.UpdatedAt = now
// Try INSERT first; on UNIQUE violation, fall through to UPDATE so the
// row's ID + created_at survive.
_, err := s.db.Exec(
`INSERT INTO workload_env (id, workload_id, key, value, encrypted, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(workload_id, key) DO UPDATE SET
value = excluded.value,
encrypted = excluded.encrypted,
updated_at = excluded.updated_at`,
env.ID, env.WorkloadID, env.Key, env.Value, BoolToInt(env.Encrypted),
now, now,
)
if err != nil {
return WorkloadEnv{}, fmt.Errorf("upsert workload env: %w", err)
}
// Re-read so the caller gets the canonical row (ID may differ when
// the conflict path took over an older row).
row, err := s.getWorkloadEnvByKey(env.WorkloadID, env.Key)
if err != nil {
return WorkloadEnv{}, err
}
return row, nil
}
// ListWorkloadEnv returns every env var for a workload, ordered by key.
func (s *Store) ListWorkloadEnv(workloadID string) ([]WorkloadEnv, error) {
rows, err := s.db.Query(
`SELECT id, workload_id, key, value, encrypted, created_at, updated_at
FROM workload_env WHERE workload_id = ? ORDER BY key`, workloadID,
)
if err != nil {
return nil, fmt.Errorf("query workload env: %w", err)
}
defer rows.Close()
out := []WorkloadEnv{}
for rows.Next() {
env, err := scanWorkloadEnvRows(rows)
if err != nil {
return nil, err
}
out = append(out, env)
}
return out, rows.Err()
}
// DeleteWorkloadEnv removes one env var by ID.
func (s *Store) DeleteWorkloadEnv(id string) error {
result, err := s.db.Exec(`DELETE FROM workload_env WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete workload env: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("workload env %s: %w", id, ErrNotFound)
}
return nil
}
// getWorkloadEnvByKey is the upsert's re-read helper.
func (s *Store) getWorkloadEnvByKey(workloadID, key string) (WorkloadEnv, error) {
var env WorkloadEnv
var enc int
err := s.db.QueryRow(
`SELECT id, workload_id, key, value, encrypted, created_at, updated_at
FROM workload_env WHERE workload_id = ? AND key = ?`, workloadID, key,
).Scan(&env.ID, &env.WorkloadID, &env.Key, &env.Value, &enc,
&env.CreatedAt, &env.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return WorkloadEnv{}, fmt.Errorf("workload env (%s,%s): %w", workloadID, key, ErrNotFound)
}
if err != nil {
return WorkloadEnv{}, fmt.Errorf("query workload env: %w", err)
}
env.Encrypted = enc != 0
return env, nil
}
func scanWorkloadEnvRows(rows *sql.Rows) (WorkloadEnv, error) {
var env WorkloadEnv
var enc int
if err := rows.Scan(&env.ID, &env.WorkloadID, &env.Key, &env.Value, &enc,
&env.CreatedAt, &env.UpdatedAt); err != nil {
return WorkloadEnv{}, fmt.Errorf("scan workload env: %w", err)
}
env.Encrypted = enc != 0
return env, nil
}
+133
View File
@@ -0,0 +1,133 @@
package store
import (
"strings"
"testing"
)
func mustCreateWorkload(t *testing.T, s *Store, name string) Workload {
t.Helper()
w, err := s.CreateWorkload(Workload{
Name: name,
Kind: "plugin",
RefID: name,
SourceKind: "image",
TriggerKind: "manual",
})
if err != nil {
t.Fatalf("CreateWorkload(%s): %v", name, err)
}
return w
}
func TestSetWorkloadEnvUpsertSameKey(t *testing.T) {
s := newTestStore(t)
w := mustCreateWorkload(t, s, "envwl")
first, err := s.SetWorkloadEnv(WorkloadEnv{
WorkloadID: w.ID, Key: "DB_URL", Value: "v1",
})
if err != nil {
t.Fatalf("first set: %v", err)
}
second, err := s.SetWorkloadEnv(WorkloadEnv{
WorkloadID: w.ID, Key: "DB_URL", Value: "v2", Encrypted: true,
})
if err != nil {
t.Fatalf("second set: %v", err)
}
// Same row ID — UPSERT must preserve identity, not accumulate rows.
if first.ID != second.ID {
t.Errorf("upsert produced new row: first=%s second=%s", first.ID, second.ID)
}
all, err := s.ListWorkloadEnv(w.ID)
if err != nil {
t.Fatalf("ListWorkloadEnv: %v", err)
}
if len(all) != 1 {
t.Fatalf("expected 1 row after upsert, got %d", len(all))
}
if all[0].Value != "v2" || !all[0].Encrypted {
t.Errorf("expected upserted value+encrypted, got value=%q encrypted=%v",
all[0].Value, all[0].Encrypted)
}
}
func TestSetWorkloadEnvValidation(t *testing.T) {
s := newTestStore(t)
w := mustCreateWorkload(t, s, "validate-wl")
if _, err := s.SetWorkloadEnv(WorkloadEnv{Key: "X"}); err == nil {
t.Fatal("expected error when WorkloadID missing")
}
if _, err := s.SetWorkloadEnv(WorkloadEnv{WorkloadID: w.ID}); err == nil {
t.Fatal("expected error when Key missing")
}
}
func TestDeleteWorkloadEnv(t *testing.T) {
s := newTestStore(t)
w := mustCreateWorkload(t, s, "delete-wl")
row, _ := s.SetWorkloadEnv(WorkloadEnv{WorkloadID: w.ID, Key: "K", Value: "V"})
if err := s.DeleteWorkloadEnv(row.ID); err != nil {
t.Fatalf("delete: %v", err)
}
if err := s.DeleteWorkloadEnv(row.ID); err == nil {
t.Fatal("expected ErrNotFound on second delete")
} else if !strings.Contains(err.Error(), "not found") {
t.Errorf("expected not-found error, got %v", err)
}
}
func TestListChildrenByParent(t *testing.T) {
s := newTestStore(t)
parent := mustCreateWorkload(t, s, "parent")
other := mustCreateWorkload(t, s, "other-root")
// Two children of parent, plus one root unrelated.
for _, name := range []string{"child-a", "child-b"} {
c, err := s.CreateWorkload(Workload{
Name: name,
Kind: "plugin",
RefID: name,
SourceKind: "image",
TriggerKind: "manual",
ParentWorkloadID: parent.ID,
})
if err != nil {
t.Fatalf("create child %s: %v", name, err)
}
_ = c
}
got, err := s.ListChildrenByParent(parent.ID)
if err != nil {
t.Fatalf("ListChildrenByParent: %v", err)
}
if len(got) != 2 {
t.Fatalf("expected 2 children, got %d", len(got))
}
if got[0].Name >= got[1].Name {
t.Errorf("expected name-ordered output, got %q then %q", got[0].Name, got[1].Name)
}
// The unrelated workload must not appear.
for _, c := range got {
if c.ID == other.ID {
t.Errorf("ListChildrenByParent leaked unrelated workload %s", other.ID)
}
}
// Empty parent returns empty slice, not error.
empty, err := s.ListChildrenByParent("")
if err != nil {
t.Fatalf("empty parent should not error: %v", err)
}
if len(empty) != 0 {
t.Errorf("empty parent should return 0 rows, got %d", len(empty))
}
}
+117
View File
@@ -0,0 +1,117 @@
package store
import (
"database/sql"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
)
// SetWorkloadVolume upserts a volume mount keyed by (workload_id, target).
// The target is the natural key — re-using a target replaces the row
// rather than accumulating duplicates that would conflict at mount time.
func (s *Store) SetWorkloadVolume(v WorkloadVolume) (WorkloadVolume, error) {
if v.WorkloadID == "" || v.Target == "" {
return WorkloadVolume{}, fmt.Errorf("workload_volume: workload_id and target are required")
}
if v.Scope == "" {
v.Scope = string(VolumeScopeAbsolute)
}
if !IsValidVolumeScope(v.Scope) {
return WorkloadVolume{}, fmt.Errorf("workload_volume: invalid scope %q", v.Scope)
}
if v.ID == "" {
v.ID = uuid.New().String()
}
now := Now()
if _, err := s.db.Exec(
`INSERT INTO workload_volumes (id, workload_id, source, target, scope, name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(workload_id, target) DO UPDATE SET
source = excluded.source,
scope = excluded.scope,
name = excluded.name,
updated_at = excluded.updated_at`,
v.ID, v.WorkloadID, v.Source, v.Target, v.Scope, v.Name, now, now,
); err != nil {
return WorkloadVolume{}, fmt.Errorf("upsert workload volume: %w", err)
}
return s.getWorkloadVolumeByTarget(v.WorkloadID, v.Target)
}
// ListWorkloadVolumes returns every mount for the given workload, ordered
// by target so the UI rendering is stable across requests.
func (s *Store) ListWorkloadVolumes(workloadID string) ([]WorkloadVolume, error) {
rows, err := s.db.Query(
`SELECT id, workload_id, source, target, scope, name, created_at, updated_at
FROM workload_volumes WHERE workload_id = ? ORDER BY target`, workloadID,
)
if err != nil {
return nil, fmt.Errorf("query workload volumes: %w", err)
}
defer rows.Close()
out := []WorkloadVolume{}
for rows.Next() {
v, err := scanWorkloadVolume(rows)
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
// DeleteWorkloadVolume removes one mount by ID.
func (s *Store) DeleteWorkloadVolume(id string) error {
result, err := s.db.Exec(`DELETE FROM workload_volumes WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete workload volume: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("workload volume %s: %w", id, ErrNotFound)
}
return nil
}
func (s *Store) getWorkloadVolumeByTarget(workloadID, target string) (WorkloadVolume, error) {
var v WorkloadVolume
err := s.db.QueryRow(
`SELECT id, workload_id, source, target, scope, name, created_at, updated_at
FROM workload_volumes WHERE workload_id = ? AND target = ?`, workloadID, target,
).Scan(&v.ID, &v.WorkloadID, &v.Source, &v.Target, &v.Scope, &v.Name, &v.CreatedAt, &v.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return WorkloadVolume{}, fmt.Errorf("workload volume (%s,%s): %w", workloadID, target, ErrNotFound)
}
if err != nil {
return WorkloadVolume{}, fmt.Errorf("query workload volume: %w", err)
}
return v, nil
}
func scanWorkloadVolume(rows *sql.Rows) (WorkloadVolume, error) {
var v WorkloadVolume
if err := rows.Scan(&v.ID, &v.WorkloadID, &v.Source, &v.Target, &v.Scope, &v.Name,
&v.CreatedAt, &v.UpdatedAt); err != nil {
return WorkloadVolume{}, fmt.Errorf("scan workload volume: %w", err)
}
return v, nil
}
// normalizeAbsolutePath is a defensive helper for volume source paths in
// "absolute" scope. Rejects path-traversal segments so a malicious client
// can't escape an allow-listed prefix at the API layer. The actual
// allowed-paths check lives in settings.AllowedVolumePaths and remains
// the policy authority.
func normalizeAbsolutePath(p string) string {
p = strings.TrimSpace(p)
if p == "" {
return ""
}
if strings.Contains(p, "..") {
return ""
}
return p
}
+95 -2
View File
@@ -9,6 +9,8 @@ import (
) )
const workloadColumns = `id, kind, ref_id, name, app_id, const workloadColumns = `id, kind, ref_id, name, app_id,
source_kind, source_config, trigger_kind, trigger_config,
public_faces, parent_workload_id,
notification_url, notification_secret, notification_url, notification_secret,
webhook_secret, webhook_signing_secret, webhook_require_signature, webhook_secret, webhook_signing_secret, webhook_require_signature,
created_at, updated_at` created_at, updated_at`
@@ -17,6 +19,8 @@ func scanWorkload(scanner interface{ Scan(...any) error }) (Workload, error) {
var w Workload var w Workload
err := scanner.Scan( err := scanner.Scan(
&w.ID, &w.Kind, &w.RefID, &w.Name, &w.AppID, &w.ID, &w.Kind, &w.RefID, &w.Name, &w.AppID,
&w.SourceKind, &w.SourceConfig, &w.TriggerKind, &w.TriggerConfig,
&w.PublicFaces, &w.ParentWorkloadID,
&w.NotificationURL, &w.NotificationSecret, &w.NotificationURL, &w.NotificationSecret,
&w.WebhookSecret, &w.WebhookSigningSecret, &w.WebhookRequireSignature, &w.WebhookSecret, &w.WebhookSigningSecret, &w.WebhookRequireSignature,
&w.CreatedAt, &w.UpdatedAt, &w.CreatedAt, &w.UpdatedAt,
@@ -33,10 +37,21 @@ func (s *Store) CreateWorkload(w Workload) (Workload, error) {
w.CreatedAt = Now() w.CreatedAt = Now()
w.UpdatedAt = w.CreatedAt w.UpdatedAt = w.CreatedAt
if w.SourceConfig == "" {
w.SourceConfig = "{}"
}
if w.TriggerConfig == "" {
w.TriggerConfig = "{}"
}
if w.PublicFaces == "" {
w.PublicFaces = "[]"
}
_, err := s.db.Exec( _, err := s.db.Exec(
`INSERT INTO workloads (`+workloadColumns+`) `INSERT INTO workloads (`+workloadColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
w.ID, w.Kind, w.RefID, w.Name, w.AppID, w.ID, w.Kind, w.RefID, w.Name, w.AppID,
w.SourceKind, w.SourceConfig, w.TriggerKind, w.TriggerConfig,
w.PublicFaces, w.ParentWorkloadID,
w.NotificationURL, w.NotificationSecret, w.NotificationURL, w.NotificationSecret,
w.WebhookSecret, w.WebhookSigningSecret, BoolToInt(w.WebhookRequireSignature), w.WebhookSecret, w.WebhookSigningSecret, BoolToInt(w.WebhookRequireSignature),
w.CreatedAt, w.UpdatedAt, w.CreatedAt, w.UpdatedAt,
@@ -128,16 +143,30 @@ func (s *Store) ListWorkloads(kind WorkloadKind) ([]Workload, error) {
} }
// UpdateWorkload updates the mutable fields of a workload (name, app_id, // UpdateWorkload updates the mutable fields of a workload (name, app_id,
// notification config, webhook config). Kind and RefID are immutable post-create. // source/trigger config, public faces, parent chain, notification + webhook
// config). Kind and RefID are immutable post-create.
func (s *Store) UpdateWorkload(w Workload) error { func (s *Store) UpdateWorkload(w Workload) error {
w.UpdatedAt = Now() w.UpdatedAt = Now()
if w.SourceConfig == "" {
w.SourceConfig = "{}"
}
if w.TriggerConfig == "" {
w.TriggerConfig = "{}"
}
if w.PublicFaces == "" {
w.PublicFaces = "[]"
}
result, err := s.db.Exec( result, err := s.db.Exec(
`UPDATE workloads SET name=?, app_id=?, `UPDATE workloads SET name=?, app_id=?,
source_kind=?, source_config=?, trigger_kind=?, trigger_config=?,
public_faces=?, parent_workload_id=?,
notification_url=?, notification_secret=?, notification_url=?, notification_secret=?,
webhook_secret=?, webhook_signing_secret=?, webhook_require_signature=?, webhook_secret=?, webhook_signing_secret=?, webhook_require_signature=?,
updated_at=? updated_at=?
WHERE id=?`, WHERE id=?`,
w.Name, w.AppID, w.Name, w.AppID,
w.SourceKind, w.SourceConfig, w.TriggerKind, w.TriggerConfig,
w.PublicFaces, w.ParentWorkloadID,
w.NotificationURL, w.NotificationSecret, w.NotificationURL, w.NotificationSecret,
w.WebhookSecret, w.WebhookSigningSecret, BoolToInt(w.WebhookRequireSignature), w.WebhookSecret, w.WebhookSigningSecret, BoolToInt(w.WebhookRequireSignature),
w.UpdatedAt, w.ID, w.UpdatedAt, w.ID,
@@ -173,6 +202,70 @@ func (s *Store) DeleteWorkload(id string) error {
return nil return nil
} }
// ListChildrenByParent returns every workload whose parent_workload_id
// equals the given id. Used to render the stages chain ("dev → staging
// → prod") on /apps/[id] without forcing a separate stages table.
//
// Returns rows ordered by name for a stable UI.
func (s *Store) ListChildrenByParent(parentID string) ([]Workload, error) {
if parentID == "" {
return []Workload{}, nil
}
rows, err := s.db.Query(
`SELECT `+workloadColumns+` FROM workloads WHERE parent_workload_id = ? ORDER BY name`,
parentID,
)
if err != nil {
return nil, fmt.Errorf("query workload children: %w", err)
}
defer rows.Close()
out := []Workload{}
for rows.Next() {
w, err := scanWorkload(rows)
if err != nil {
return nil, fmt.Errorf("scan child workload: %w", err)
}
out = append(out, w)
}
return out, rows.Err()
}
// SetWorkloadWebhookSecret rotates the inbound webhook URL secret. Pass
// empty to disable inbound webhooks for this workload.
func (s *Store) SetWorkloadWebhookSecret(id, secret string) error {
result, err := s.db.Exec(
`UPDATE workloads SET webhook_secret=?, updated_at=? WHERE id=?`,
secret, Now(), id,
)
if err != nil {
return fmt.Errorf("update workload webhook_secret: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("workload %s: %w", id, ErrNotFound)
}
return nil
}
// EnsureWorkloadWebhookSecret returns the current secret, generating one
// lazily for workloads that predate the column. Mirrors the project /
// site equivalents.
func (s *Store) EnsureWorkloadWebhookSecret(id string) (string, error) {
w, err := s.GetWorkloadByID(id)
if err != nil {
return "", err
}
if w.WebhookSecret != "" {
return w.WebhookSecret, nil
}
secret := generateWebhookSecret()
if err := s.SetWorkloadWebhookSecret(id, secret); err != nil {
return "", err
}
return secret, nil
}
// DeleteWorkloadByRef removes the workload paired with a given (kind, ref_id). // DeleteWorkloadByRef removes the workload paired with a given (kind, ref_id).
// Idempotent — returns nil if no row exists, since the kind-specific Delete // Idempotent — returns nil if no row exists, since the kind-specific Delete
// callers don't always know whether a workload row was created. // callers don't always know whether a workload row was created.
+120
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"github.com/alexei/tinyforge/internal/store" "github.com/alexei/tinyforge/internal/store"
@@ -106,3 +107,122 @@ func parseAllowedPaths(jsonStr string) ([]string, error) {
func ParseAllowedPaths(jsonStr string) ([]string, error) { func ParseAllowedPaths(jsonStr string) ([]string, error) {
return parseAllowedPaths(jsonStr) return parseAllowedPaths(jsonStr)
} }
// ResolveWorkloadParams holds the parameters needed to resolve a
// workload-volume's host path. Unlike ResolveParams it is keyed on the
// workload identity (name + id) rather than the legacy project/stage
// dual-key, so it survives the Workload-first cutover.
type ResolveWorkloadParams struct {
BasePath string
WorkloadID string
WorkloadName string
ImageTag string // required for "instance" scope only
AllowedVolumePaths string // JSON array of allowed absolute paths
}
// ResolveWorkloadPath returns the absolute host path for a WorkloadVolume.
// Scope semantics map onto the workload-first model:
//
// - absolute — host bind, must lie under settings.AllowedVolumePaths.
// - ephemeral — caller renders this as tmpfs; the function returns an
// error because there is no host path.
// - instance — per-tag isolation under <workload>/instance-<tag>/<source>.
// Useful for blue-green when each running instance needs its own dir.
// - stage, project — both legacy names collapse to "shared across all
// instances of this workload" under <workload>/<source>. Two names
// for one shape is intentional: it lets legacy data migrate without
// a path rewrite.
// - project_named — workload-scoped named volume under
// <workload>/_named/<name>/<source>.
// - named — globally-scoped named volume under
// _named/<name>/<source>.
//
// The <workload> directory segment is `<sanitized-name>-<short-id>`. The
// short-id suffix prevents collisions when two workloads share a name
// (the workloads table only enforces uniqueness on (kind, ref_id)).
func ResolveWorkloadPath(vol store.WorkloadVolume, params ResolveWorkloadParams) (string, error) {
scope := vol.Scope
if scope == "" {
return "", fmt.Errorf("workload volume: scope is required")
}
if scope == string(store.VolumeScopeEphemeral) {
return "", fmt.Errorf("ephemeral volumes have no host path")
}
if scope == string(store.VolumeScopeAbsolute) {
return resolveAbsolute(vol.Source, params.AllowedVolumePaths)
}
if params.BasePath == "" {
return "", fmt.Errorf("workload volume: base path is required for scope %q", scope)
}
workloadDir, err := workloadPathSegment(params.WorkloadName, params.WorkloadID)
if err != nil {
return "", err
}
switch scope {
case string(store.VolumeScopeInstance):
if params.ImageTag == "" {
return "", fmt.Errorf("instance scope requires image tag")
}
tag := sanitizePathSegment(params.ImageTag)
if tag == "" {
return "", fmt.Errorf("instance scope requires non-empty image tag")
}
return filepath.Join(params.BasePath, workloadDir, "instance-"+tag, vol.Source), nil
case string(store.VolumeScopeStage), string(store.VolumeScopeProject):
return filepath.Join(params.BasePath, workloadDir, vol.Source), nil
case string(store.VolumeScopeProjectNamed):
name := sanitizePathSegment(vol.Name)
if name == "" {
return "", fmt.Errorf("project_named scope requires name")
}
return filepath.Join(params.BasePath, workloadDir, "_named", name, vol.Source), nil
case string(store.VolumeScopeNamed):
name := sanitizePathSegment(vol.Name)
if name == "" {
return "", fmt.Errorf("named scope requires name")
}
return filepath.Join(params.BasePath, "_named", name, vol.Source), nil
default:
return "", fmt.Errorf("unknown volume scope %q", scope)
}
}
// pathSegmentSanitizer collapses anything outside the [a-zA-Z0-9_.-] set
// to a single dash. The character set matches Docker's permissive segment
// rules; the additional Trim afterward keeps the segment from starting
// or ending with a separator.
var pathSegmentSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_.-]+`)
func sanitizePathSegment(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
return strings.Trim(pathSegmentSanitizer.ReplaceAllString(s, "-"), "-")
}
// workloadPathSegment builds the per-workload directory name. The
// 8-char id-short suffix disambiguates same-named workloads — only
// (kind, ref_id) is unique at the DB level, so names alone are unsafe.
// Returns an error when both identity fields are empty, since the
// resulting path would not be workload-scoped.
func workloadPathSegment(name, id string) (string, error) {
cleanName := sanitizePathSegment(name)
idShort := id
if len(idShort) > 8 {
idShort = idShort[:8]
}
idShort = sanitizePathSegment(idShort)
if cleanName == "" && idShort == "" {
return "", fmt.Errorf("workload volume: workload id or name required")
}
if cleanName == "" {
return idShort, nil
}
if idShort == "" {
return cleanName, nil
}
return cleanName + "-" + idShort, nil
}
+229
View File
@@ -0,0 +1,229 @@
package volume
import (
"path/filepath"
"strings"
"testing"
"github.com/alexei/tinyforge/internal/store"
)
func TestResolveWorkloadPath(t *testing.T) {
// Use real-OS absolute paths so the suite is portable Linux/Windows.
allowedDir := t.TempDir()
allowedJSON := `["` + filepath.ToSlash(allowedDir) + `"]`
bindSource := filepath.Join(allowedDir, "db")
outsideSource := filepath.Join(t.TempDir(), "passwd")
const base = "/var/forge/volumes"
type tc struct {
name string
vol store.WorkloadVolume
params ResolveWorkloadParams
want string
wantErr string // substring match; empty = no error
}
cases := []tc{
{
name: "absolute allowed",
vol: store.WorkloadVolume{Source: bindSource, Scope: "absolute"},
params: ResolveWorkloadParams{
BasePath: base,
WorkloadID: "01abcdef1234",
WorkloadName: "api",
AllowedVolumePaths: allowedJSON,
},
want: filepath.Clean(bindSource),
},
{
name: "absolute outside allow-list",
vol: store.WorkloadVolume{Source: outsideSource, Scope: "absolute"},
params: ResolveWorkloadParams{
BasePath: base,
WorkloadID: "01abcdef1234",
AllowedVolumePaths: allowedJSON,
},
wantErr: "not under any allowed",
},
{
name: "absolute requires non-empty source",
vol: store.WorkloadVolume{Source: "", Scope: "absolute"},
params: ResolveWorkloadParams{
BasePath: base,
AllowedVolumePaths: allowedJSON,
},
wantErr: "absolute scope requires a source path",
},
{
name: "ephemeral has no host path",
vol: store.WorkloadVolume{Scope: "ephemeral"},
params: ResolveWorkloadParams{
BasePath: base, WorkloadID: "01abcdef", WorkloadName: "api",
},
wantErr: "ephemeral",
},
{
name: "instance uses tag suffix",
vol: store.WorkloadVolume{Source: "data", Scope: "instance"},
params: ResolveWorkloadParams{
BasePath: base, WorkloadID: "01abcdef1234", WorkloadName: "api", ImageTag: "v1.2.3",
},
want: filepath.Join(base, "api-01abcdef", "instance-v1.2.3", "data"),
},
{
name: "instance scope requires tag",
vol: store.WorkloadVolume{Source: "data", Scope: "instance"},
params: ResolveWorkloadParams{
BasePath: base, WorkloadID: "01abcdef", WorkloadName: "api",
},
wantErr: "instance scope requires image tag",
},
{
name: "stage and project collapse to workload dir",
vol: store.WorkloadVolume{Source: "shared", Scope: "stage"},
params: ResolveWorkloadParams{
BasePath: base, WorkloadID: "01abcdef1234", WorkloadName: "api",
},
want: filepath.Join(base, "api-01abcdef", "shared"),
},
{
name: "project scope",
vol: store.WorkloadVolume{Source: "shared", Scope: "project"},
params: ResolveWorkloadParams{
BasePath: base, WorkloadID: "01abcdef1234", WorkloadName: "api",
},
want: filepath.Join(base, "api-01abcdef", "shared"),
},
{
name: "project_named requires name",
vol: store.WorkloadVolume{Source: "data", Scope: "project_named"},
params: ResolveWorkloadParams{
BasePath: base, WorkloadID: "01abcdef1234", WorkloadName: "api",
},
wantErr: "project_named scope requires name",
},
{
name: "project_named",
vol: store.WorkloadVolume{Source: "data", Scope: "project_named", Name: "cache"},
params: ResolveWorkloadParams{
BasePath: base, WorkloadID: "01abcdef1234", WorkloadName: "api",
},
want: filepath.Join(base, "api-01abcdef", "_named", "cache", "data"),
},
{
name: "named",
vol: store.WorkloadVolume{Source: "data", Scope: "named", Name: "global-cache"},
params: ResolveWorkloadParams{
BasePath: base, WorkloadID: "01abcdef1234", WorkloadName: "api",
},
want: filepath.Join(base, "_named", "global-cache", "data"),
},
{
name: "named requires name",
vol: store.WorkloadVolume{Source: "data", Scope: "named"},
params: ResolveWorkloadParams{
BasePath: base, WorkloadID: "01abcdef1234", WorkloadName: "api",
},
wantErr: "named scope requires name",
},
{
name: "empty scope rejected",
vol: store.WorkloadVolume{Source: "data", Scope: ""},
params: ResolveWorkloadParams{
BasePath: base, WorkloadID: "01abcdef1234", WorkloadName: "api",
},
wantErr: "scope is required",
},
{
name: "unknown scope rejected",
vol: store.WorkloadVolume{Source: "data", Scope: "weird"},
params: ResolveWorkloadParams{
BasePath: base, WorkloadID: "01abcdef1234", WorkloadName: "api",
},
wantErr: "unknown volume scope",
},
{
name: "id-only workload still resolves",
vol: store.WorkloadVolume{Source: "data", Scope: "project"},
params: ResolveWorkloadParams{
BasePath: base, WorkloadID: "01abcdef1234",
},
want: filepath.Join(base, "01abcdef", "data"),
},
{
name: "name-only workload still resolves",
vol: store.WorkloadVolume{Source: "data", Scope: "project"},
params: ResolveWorkloadParams{
BasePath: base, WorkloadName: "api",
},
want: filepath.Join(base, "api", "data"),
},
{
name: "name with unsafe chars sanitized",
vol: store.WorkloadVolume{Source: "data", Scope: "project"},
params: ResolveWorkloadParams{
BasePath: base, WorkloadID: "01abcdef1234", WorkloadName: "api/../etc",
},
want: filepath.Join(base, "api-..-etc-01abcdef", "data"),
},
{
name: "no workload identity rejected",
vol: store.WorkloadVolume{Source: "data", Scope: "project"},
params: ResolveWorkloadParams{
BasePath: base,
},
wantErr: "workload id or name required",
},
{
name: "non-absolute scope requires base path",
vol: store.WorkloadVolume{Source: "data", Scope: "project"},
params: ResolveWorkloadParams{
WorkloadID: "01abcdef", WorkloadName: "api",
},
wantErr: "base path is required",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := ResolveWorkloadPath(c.vol, c.params)
if c.wantErr != "" {
if err == nil {
t.Fatalf("want error containing %q, got nil (path=%q)", c.wantErr, got)
}
if !strings.Contains(err.Error(), c.wantErr) {
t.Fatalf("want error containing %q, got %q", c.wantErr, err.Error())
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != c.want {
t.Fatalf("path mismatch:\n got %q\n want %q", got, c.want)
}
})
}
}
func TestSanitizePathSegment(t *testing.T) {
cases := []struct {
in, out string
}{
{"api", "api"},
{" api ", "api"},
{"api/../etc", "api-..-etc"},
{"my app v1", "my-app-v1"},
{"---", ""},
{"", ""},
{"v1.2.3", "v1.2.3"},
}
for _, c := range cases {
got := sanitizePathSegment(c.in)
if got != c.out {
t.Errorf("sanitize(%q) = %q, want %q", c.in, got, c.out)
}
}
}
+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
}
+13 -2
View File
@@ -6,14 +6,19 @@
--> -->
<script lang="ts"> <script lang="ts">
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { fetchContainerLogs, fetchStaticSiteLogs } from '$lib/api'; import {
fetchContainerLogs,
fetchStaticSiteLogs,
fetchWorkloadContainerLogs
} from '$lib/api';
import { getAuthToken } from '$lib/auth'; import { getAuthToken } from '$lib/auth';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { IconLoader, IconX } from '$lib/components/icons'; import { IconLoader, IconX } from '$lib/components/icons';
export type LogSource = export type LogSource =
| { kind: 'instance'; projectId: string; stageId: string; instanceId: string } | { kind: 'instance'; projectId: string; stageId: string; instanceId: string }
| { kind: 'site'; siteId: string }; | { kind: 'site'; siteId: string }
| { kind: 'workload'; workloadId: string; containerRowId: string };
interface Props { interface Props {
source: LogSource; source: LogSource;
@@ -58,6 +63,9 @@
if (source.kind === 'instance') { if (source.kind === 'instance') {
return `/api/projects/${source.projectId}/stages/${source.stageId}/instances/${source.instanceId}/logs?follow=true&tail=0${tokenParam}`; return `/api/projects/${source.projectId}/stages/${source.stageId}/instances/${source.instanceId}/logs?follow=true&tail=0${tokenParam}`;
} }
if (source.kind === 'workload') {
return `/api/workloads/${source.workloadId}/containers/${source.containerRowId}/logs?follow=true&tail=0${tokenParam}`;
}
return `/api/sites/${source.siteId}/logs?follow=true&tail=0${tokenParam}`; return `/api/sites/${source.siteId}/logs?follow=true&tail=0${tokenParam}`;
} }
@@ -65,6 +73,9 @@
if (source.kind === 'instance') { if (source.kind === 'instance') {
return fetchContainerLogs(source.projectId, source.stageId, source.instanceId, tail); return fetchContainerLogs(source.projectId, source.stageId, source.instanceId, tail);
} }
if (source.kind === 'workload') {
return fetchWorkloadContainerLogs(source.workloadId, source.containerRowId, tail);
}
return fetchStaticSiteLogs(source.siteId, tail); return fetchStaticSiteLogs(source.siteId, tail);
} }
+36 -3
View File
@@ -536,11 +536,11 @@ export interface SystemStatsSample {
// ── Workload / Container / App ──────────────────────────────────── // ── Workload / Container / App ────────────────────────────────────
export type WorkloadKind = 'project' | 'stack' | 'site'; export type WorkloadKind = 'project' | 'stack' | 'site' | 'plugin' | (string & {});
/** /**
* Workload is the unifying primitive over Project / Stack / StaticSite. * Workload is the unifying primitive over Project / Stack / StaticSite,
* Read-only at this layer — mutations go through the kind-specific endpoints. * plus plugin-native rows whose source/trigger kinds are populated.
*/ */
export interface Workload { export interface Workload {
id: string; id: string;
@@ -548,12 +548,45 @@ export interface Workload {
ref_id: string; ref_id: string;
name: string; name: string;
app_id: string; app_id: string;
source_kind: string;
source_config: string;
trigger_kind: string;
trigger_config: string;
public_faces: string;
parent_workload_id: string;
notification_url: string; notification_url: string;
webhook_require_signature: boolean; webhook_require_signature: boolean;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
export interface PublicFace {
subdomain: string;
domain: string;
target_service: string;
target_port: number;
access_list_id: number;
enable_ssl: boolean;
}
export interface PluginWorkloadInput {
name: string;
group_id?: string;
parent_workload_id?: string;
source_kind: string;
source_config: unknown;
trigger_kind: string;
trigger_config: unknown;
public_faces?: PublicFace[];
notification_url?: string;
webhook_require_signature?: boolean;
}
export interface HookKinds {
sources: string[];
triggers: string[];
}
/** /**
* Canonical container states. The trailing `(string & {})` is the * Canonical container states. The trailing `(string & {})` is the
* "literal-friendly string" trick — it lets the union accept arbitrary * "literal-friendly string" trick — it lets the union accept arbitrary
+530
View File
@@ -0,0 +1,530 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Workload } from '$lib/types';
import * as api from '$lib/api';
import { IconPlus, IconRefresh } from '$lib/components/icons';
import ForgeHero from '$lib/components/ForgeHero.svelte';
let workloads = $state<Workload[]>([]);
let loading = $state(true);
let error = $state('');
let filter = $state<'all' | string>('all');
// Plugin-native rows are the ones with both source_kind and trigger_kind
// populated. Legacy project/stack/site rows still appear in
// /api/workloads — those are surfaced under their own sections.
const pluginRows = $derived(
workloads.filter((w) => w.source_kind !== '' && w.trigger_kind !== '')
);
const filtered = $derived(
filter === 'all' ? pluginRows : pluginRows.filter((w) => w.source_kind === filter)
);
const sourceKinds = $derived(
Array.from(new Set(pluginRows.map((w) => w.source_kind))).sort()
);
const countBy = $derived((kind: string) =>
pluginRows.filter((w) => w.source_kind === kind).length
);
async function load() {
loading = true;
error = '';
try {
workloads = await api.listWorkloads();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load apps';
} finally {
loading = false;
}
}
function sourceBadge(kind: string): string {
switch (kind) {
case 'image':
return 'badge-image';
case 'compose':
return 'badge-compose';
case 'static':
return 'badge-static';
default:
return 'badge-other';
}
}
onMount(load);
</script>
<svelte:head>
<title>Apps · Tinyforge</title>
</svelte:head>
<div class="forge">
{#snippet appsToolbar()}
<button class="forge-btn-icon" onclick={load} aria-label="Refresh">
<IconRefresh size={16} />
</button>
<a href="/apps/new" class="forge-btn">
<IconPlus size={14} />
<span>New App</span>
</a>
{/snippet}
{#snippet appsStats()}
<div>
<dt>TOTAL</dt>
<dd>{loading ? '—' : String(pluginRows.length).padStart(2, '0')}</dd>
</div>
<div>
<dt>IMAGE</dt>
<dd>{loading ? '—' : String(countBy('image')).padStart(2, '0')}</dd>
</div>
<div>
<dt>COMPOSE</dt>
<dd>{loading ? '—' : String(countBy('compose')).padStart(2, '0')}</dd>
</div>
<div>
<dt>STATIC</dt>
<dd class="accent">{loading ? '—' : String(countBy('static')).padStart(2, '0')}</dd>
</div>
{/snippet}
{#snippet appsLede()}
Plugin-native deployables &mdash; <em>image</em>, <em>compose</em>, or <em>static</em>, with
pluggable redeploy triggers. Legacy projects, stacks, and sites continue to live under their
own sections during the cutover.
{/snippet}
<ForgeHero
eyebrowSuffix="APPS"
title="Apps"
size="lg"
toolbar={appsToolbar}
lede_html={appsLede}
stats={appsStats}
/>
{#if error}
<div class="alert"><span class="alert-tag">ERR</span><span>{error}</span></div>
{/if}
{#if !loading && pluginRows.length > 0}
<div class="filter-row" role="tablist" aria-label="Filter by source plugin">
<button
class="chip"
class:active={filter === 'all'}
role="tab"
aria-selected={filter === 'all'}
onclick={() => (filter = 'all')}
>
<span class="chip-label">ALL</span>
<span class="chip-count">{String(pluginRows.length).padStart(2, '0')}</span>
</button>
{#each sourceKinds as kind}
{@const count = countBy(kind)}
<button
class="chip src-{kind}"
class:active={filter === kind}
role="tab"
aria-selected={filter === kind}
onclick={() => (filter = kind)}
>
<span class="chip-dot" aria-hidden="true"></span>
<span class="chip-label">{kind.toUpperCase()}</span>
<span class="chip-count">{String(count).padStart(2, '0')}</span>
</button>
{/each}
</div>
{/if}
{#if loading}
<div class="skeleton-rows" aria-busy="true">
{#each Array(4) as _, i}
<div class="skeleton-row" style:--i={i}></div>
{/each}
</div>
{:else if filtered.length === 0}
<div class="empty">
<div class="empty-mark"><span></span><span></span><span></span></div>
<h2>No apps yet</h2>
<p>
Apps unify image, compose, and static deployables behind a single plugin-driven
surface. Forge your first one to see it light up here.
</p>
<a href="/apps/new" class="btn-primary">
<IconPlus size={14} /><span>Forge the first app</span>
</a>
</div>
{:else}
<div class="table-wrap">
<table class="forge-table">
<thead>
<tr>
<th>Name</th>
<th>Source</th>
<th>Trigger</th>
<th>Created</th>
<th class="t-right">Actions</th>
</tr>
</thead>
<tbody>
{#each filtered as w, i (w.id)}
<tr>
<td>
<a class="row-link" href={`/apps/${w.id}`}>
<span class="row-ref">{String(i + 1).padStart(2, '0')}</span>
<span class="row-name">{w.name}</span>
</a>
</td>
<td>
<span class="badge {sourceBadge(w.source_kind)}">
<span class="badge-dot" aria-hidden="true"></span>{w.source_kind}
</span>
</td>
<td>
<span class="badge badge-trigger">{w.trigger_kind}</span>
</td>
<td class="muted mono">{w.created_at}</td>
<td class="actions-cell">
<a class="row-action" href={`/apps/${w.id}`}>
Open <span class="arrow" aria-hidden="true"></span>
</a>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<style>
.forge {
--accent: var(--forge-accent);
--accent-soft: var(--forge-accent-soft);
--glow: var(--forge-glow);
display: flex;
flex-direction: column;
gap: 1.25rem;
max-width: 1240px;
margin: 0 auto;
}
/* ── Alert ─────────────────────────────────────── */
.alert {
display: flex;
gap: 0.7rem;
align-items: center;
padding: 0.7rem 0.9rem;
background: var(--color-danger-light);
color: var(--color-danger-dark);
border: 1px solid var(--color-danger);
border-left-width: 4px;
border-radius: var(--radius-lg);
font-size: 0.875rem;
}
.alert-tag {
font-family: var(--forge-mono);
font-weight: 700;
font-size: 0.65rem;
letter-spacing: 0.16em;
padding: 0.15rem 0.4rem;
background: var(--color-danger);
color: #fff;
border-radius: var(--radius-sm);
}
:global([data-theme='dark']) .alert {
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
color: #fca5a5;
}
/* ── Filter chips ──────────────────────────────── */
.filter-row {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.35rem 0.75rem;
border: 1px solid var(--border-primary);
background: var(--surface-card);
border-radius: var(--radius-full);
font-family: var(--forge-mono);
font-size: 0.66rem;
font-weight: 600;
letter-spacing: 0.12em;
color: var(--text-secondary);
cursor: pointer;
transition: border-color 150ms ease, background 150ms ease, color 150ms ease,
transform 150ms ease;
}
.chip:hover {
background: var(--surface-card-hover);
color: var(--text-primary);
transform: translateY(-1px);
}
.chip:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
.chip.active {
background: var(--text-primary);
color: var(--surface-card);
border-color: var(--text-primary);
}
.chip.active .chip-count,
.chip.active .chip-dot {
color: var(--surface-card);
}
.chip-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
opacity: 0.7;
}
.chip.src-image .chip-dot {
color: var(--badge-image-color);
}
.chip.src-compose .chip-dot {
color: var(--badge-compose-color);
}
.chip.src-static .chip-dot {
color: var(--badge-static-color);
}
.chip-count {
font-variant-numeric: tabular-nums;
font-size: 0.62rem;
opacity: 0.7;
}
/* ── Skeleton ──────────────────────────────────── */
.skeleton-rows {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.skeleton-row {
height: 52px;
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
background: linear-gradient(
110deg,
var(--surface-card) 20%,
var(--surface-card-hover) 50%,
var(--surface-card) 80%
);
background-size: 200% 100%;
animation: shimmer 1.6s linear infinite;
animation-delay: calc(var(--i) * 120ms);
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* ── Empty ─────────────────────────────────────── */
.empty {
text-align: center;
padding: 4rem 2rem;
border: 1px dashed var(--border-primary);
border-radius: var(--radius-2xl);
background: var(--surface-card);
}
.empty-mark {
display: inline-flex;
gap: 4px;
margin-bottom: 1.5rem;
}
.empty-mark span {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--border-input);
}
.empty-mark span:nth-child(2) {
background: var(--accent);
animation: ember 2.4s ease-in-out infinite;
}
@keyframes ember {
0%,
100% {
box-shadow: 0 0 0 3px var(--accent-soft);
}
50% {
box-shadow: 0 0 0 6px color-mix(in srgb, var(--color-brand-500) 18%, transparent);
}
}
.empty h2 {
font-family: var(--font-family-sans);
font-weight: 700;
font-size: 1.5rem;
margin: 0 0 0.5rem;
letter-spacing: -0.01em;
}
.empty p {
color: var(--text-secondary);
margin: 0 auto 1.5rem;
font-size: 0.95rem;
max-width: 48ch;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1rem;
background: var(--text-primary);
color: var(--surface-card);
border: 0;
border-radius: var(--radius-lg);
font-family: var(--forge-mono);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
text-decoration: none;
cursor: pointer;
transition: transform 150ms ease, box-shadow 150ms ease;
box-shadow: 0 0 0 0 var(--glow);
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 0 0 4px var(--glow);
}
/* ── Table ─────────────────────────────────────── */
.table-wrap {
border: 1px solid var(--border-primary);
border-radius: var(--radius-xl);
background: var(--surface-card);
overflow: hidden;
}
.t-right {
text-align: right;
}
.row-link {
display: inline-flex;
align-items: baseline;
gap: 0.6rem;
color: var(--text-primary);
text-decoration: none;
transition: color 120ms ease;
}
.row-link:hover {
color: var(--accent);
}
.row-link:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
.row-ref {
font-family: var(--forge-mono);
font-size: 0.68rem;
letter-spacing: 0.1em;
color: var(--text-tertiary);
}
.row-name {
font-weight: 600;
}
.row-action {
font-family: var(--forge-mono);
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--accent);
text-decoration: none;
}
.row-action:hover {
color: var(--color-brand-700);
}
.row-action:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
.row-action .arrow {
display: inline-block;
transition: transform 150ms ease;
}
.row-action:hover .arrow {
transform: translateX(3px);
}
/* ── Source badges (themed via tokens) ─────────── */
/* Light-theme defaults; dark overrides below pick up automatically. */
.forge {
--badge-image-color: var(--color-info);
--badge-image-text: var(--color-info-dark);
--badge-compose-color: var(--color-brand-500);
--badge-compose-text: var(--color-brand-700);
--badge-static-color: var(--color-success);
--badge-static-text: var(--color-success-dark);
}
:global([data-theme='dark']) .forge {
--badge-image-text: #93c5fd;
--badge-compose-text: #c4b5fd;
--badge-static-text: #6ee7b7;
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.18rem 0.55rem;
border-radius: var(--radius-full);
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
border: 1px solid var(--border-primary);
}
.badge-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.badge-image {
background: color-mix(in srgb, var(--badge-image-color) 10%, transparent);
color: var(--badge-image-text);
border-color: color-mix(in srgb, var(--badge-image-color) 35%, transparent);
}
.badge-compose {
background: color-mix(in srgb, var(--badge-compose-color) 10%, transparent);
color: var(--badge-compose-text);
border-color: color-mix(in srgb, var(--badge-compose-color) 35%, transparent);
}
.badge-static {
background: color-mix(in srgb, var(--badge-static-color) 10%, transparent);
color: var(--badge-static-text);
border-color: color-mix(in srgb, var(--badge-static-color) 35%, transparent);
}
.badge-other {
background: var(--surface-card-hover);
color: var(--text-secondary);
}
.badge-trigger {
background: var(--surface-card-hover);
color: var(--text-secondary);
}
.muted {
color: var(--text-tertiary);
}
.mono {
font-family: var(--forge-mono);
font-size: 0.78rem;
}
.actions-cell {
text-align: right;
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff