From 8d6a527a2ba61cb84e1d579f50d24ceec76fc222 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 11 May 2026 22:17:41 +0300 Subject: [PATCH] refactor(workload): plugin architecture wave + apps UI + volume scopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- cmd/server/static_backend.go | 133 + internal/api/hooks.go | 154 + internal/api/instances.go | 14 +- internal/api/middleware.go | 18 + internal/api/response.go | 18 + internal/api/workload_chain.go | 213 ++ internal/api/workload_convert.go | 89 + internal/api/workload_env.go | 214 ++ internal/api/workload_volumes.go | 114 + internal/api/workloads.go | 30 + internal/api/workloads_plugin.go | 293 ++ internal/deployer/dispatch.go | 65 + internal/reconciler/reconciler.go | 71 + internal/store/containers.go | 37 +- internal/store/static_sites.go | 48 + internal/store/workload_env.go | 111 + internal/store/workload_env_test.go | 133 + internal/store/workload_volumes.go | 117 + internal/store/workloads.go | 97 +- internal/volume/resolver.go | 120 + internal/volume/resolver_test.go | 229 ++ internal/workload/plugin/plugin.go | 79 + internal/workload/plugin/registry.go | 27 + internal/workload/plugin/source.go | 114 + .../workload/plugin/source/compose/compose.go | 263 ++ .../workload/plugin/source/image/image.go | 740 +++++ .../plugin/source/image/image_helpers_test.go | 120 + .../workload/plugin/source/static/static.go | 147 + internal/workload/plugin/trigger.go | 74 + internal/workload/plugin/trigger/git/git.go | 123 + .../workload/plugin/trigger/git/git_test.go | 142 + .../workload/plugin/trigger/manual/manual.go | 57 + .../plugin/trigger/manual/manual_test.go | 83 + .../plugin/trigger/registry/registry.go | 115 + .../plugin/trigger/registry/registry_test.go | 155 + internal/workload/plugin/types.go | 95 + web/src/lib/components/ContainerLogs.svelte | 15 +- web/src/lib/types.ts | 39 +- web/src/routes/apps/+page.svelte | 530 ++++ web/src/routes/apps/[id]/+page.svelte | 2690 +++++++++++++++++ web/src/routes/apps/new/+page.svelte | 1574 ++++++++++ 41 files changed, 9482 insertions(+), 18 deletions(-) create mode 100644 cmd/server/static_backend.go create mode 100644 internal/api/hooks.go create mode 100644 internal/api/workload_chain.go create mode 100644 internal/api/workload_convert.go create mode 100644 internal/api/workload_env.go create mode 100644 internal/api/workload_volumes.go create mode 100644 internal/api/workloads_plugin.go create mode 100644 internal/deployer/dispatch.go create mode 100644 internal/store/workload_env.go create mode 100644 internal/store/workload_env_test.go create mode 100644 internal/store/workload_volumes.go create mode 100644 internal/volume/resolver_test.go create mode 100644 internal/workload/plugin/plugin.go create mode 100644 internal/workload/plugin/registry.go create mode 100644 internal/workload/plugin/source.go create mode 100644 internal/workload/plugin/source/compose/compose.go create mode 100644 internal/workload/plugin/source/image/image.go create mode 100644 internal/workload/plugin/source/image/image_helpers_test.go create mode 100644 internal/workload/plugin/source/static/static.go create mode 100644 internal/workload/plugin/trigger.go create mode 100644 internal/workload/plugin/trigger/git/git.go create mode 100644 internal/workload/plugin/trigger/git/git_test.go create mode 100644 internal/workload/plugin/trigger/manual/manual.go create mode 100644 internal/workload/plugin/trigger/manual/manual_test.go create mode 100644 internal/workload/plugin/trigger/registry/registry.go create mode 100644 internal/workload/plugin/trigger/registry/registry_test.go create mode 100644 internal/workload/plugin/types.go create mode 100644 web/src/routes/apps/+page.svelte create mode 100644 web/src/routes/apps/[id]/+page.svelte create mode 100644 web/src/routes/apps/new/+page.svelte diff --git a/cmd/server/static_backend.go b/cmd/server/static_backend.go new file mode 100644 index 0000000..52a1441 --- /dev/null +++ b/cmd/server/static_backend.go @@ -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 diff --git a/internal/api/hooks.go b/internal/api/hooks.go new file mode 100644 index 0000000..32ddda0 --- /dev/null +++ b/internal/api/hooks.go @@ -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 +} diff --git a/internal/api/instances.go b/internal/api/instances.go index bcfc7ee..5bbedc7 100644 --- a/internal/api/instances.go +++ b/internal/api/instances.go @@ -10,6 +10,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/alexei/tinyforge/internal/store" + "github.com/alexei/tinyforge/internal/workload/plugin" ) // 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 { TriggerDeploy(ctx context.Context, projectID, stageID, imageTag 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 diff --git a/internal/api/middleware.go b/internal/api/middleware.go index af814a4..b9b4d7a 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -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 // supplied limiter. Requests over the limit get 429. func rateLimitMiddleware(rl *rateLimiter) func(http.Handler) http.Handler { diff --git a/internal/api/response.go b/internal/api/response.go index ea82ec4..5944e95 100644 --- a/internal/api/response.go +++ b/internal/api/response.go @@ -38,6 +38,10 @@ func respondNotFound(w http.ResponseWriter, entity string) { // decodeJSON reads and decodes the request body into the given value. // 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 { if err := json.NewDecoder(r.Body).Decode(v); err != nil { 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 } + +// 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 +} diff --git a/internal/api/workload_chain.go b/internal/api/workload_chain.go new file mode 100644 index 0000000..9f7f395 --- /dev/null +++ b/internal/api/workload_chain.go @@ -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) +} + diff --git a/internal/api/workload_convert.go b/internal/api/workload_convert.go new file mode 100644 index 0000000..3df7275 --- /dev/null +++ b/internal/api/workload_convert.go @@ -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 +} diff --git a/internal/api/workload_env.go b/internal/api/workload_env.go new file mode 100644 index 0000000..ef6ed67 --- /dev/null +++ b/internal/api/workload_env.go @@ -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 +} diff --git a/internal/api/workload_volumes.go b/internal/api/workload_volumes.go new file mode 100644 index 0000000..efb8b7a --- /dev/null +++ b/internal/api/workload_volumes.go @@ -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}) +} diff --git a/internal/api/workloads.go b/internal/api/workloads.go index eb41a0b..34e6097 100644 --- a/internal/api/workloads.go +++ b/internal/api/workloads.go @@ -36,6 +36,36 @@ func (s *Server) getWorkload(w http.ResponseWriter, r *http.Request) { 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. // Returns every Container row owned by this workload, newest first. The // frontend's component uses this on every kind-specific diff --git a/internal/api/workloads_plugin.go b/internal/api/workloads_plugin.go new file mode 100644 index 0000000..9f404eb --- /dev/null +++ b/internal/api/workloads_plugin.go @@ -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 +} + diff --git a/internal/deployer/dispatch.go b/internal/deployer/dispatch.go new file mode 100644 index 0000000..0ccb610 --- /dev/null +++ b/internal/deployer/dispatch.go @@ -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, + } +} diff --git a/internal/reconciler/reconciler.go b/internal/reconciler/reconciler.go index cbc36b2..12c28d7 100644 --- a/internal/reconciler/reconciler.go +++ b/internal/reconciler/reconciler.go @@ -20,6 +20,7 @@ package reconciler import ( "context" + "encoding/json" "errors" "log/slog" "strings" @@ -28,6 +29,7 @@ import ( "github.com/alexei/tinyforge/internal/docker" "github.com/alexei/tinyforge/internal/store" + "github.com/alexei/tinyforge/internal/workload/plugin" ) // 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) } +// 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. type Reconciler struct { store *store.Store docker DockerLister interval time.Duration + plugins PluginReconciler // optional; nil disables the per-workload Source.Reconcile pass. stop chan struct{} 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 // immediately so startup populates the index without waiting for the first // 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.reconcilePluginWorkloads(ctx) 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) { defer r.wg.Done() diff --git a/internal/store/containers.go b/internal/store/containers.go index cf6233c..ea891d1 100644 --- a/internal/store/containers.go +++ b/internal/store/containers.go @@ -15,7 +15,7 @@ import ( const containerColumns = `id, workload_id, workload_kind, role, stage_id, container_id, image_ref, image_tag, host, state, port, 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) { 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.ImageRef, &c.ImageTag, &c.Host, &c.State, &c.Port, &c.Subdomain, &c.ProxyRouteID, &c.NpmProxyID, - &c.LastSeenAt, &c.CreatedAt, &c.UpdatedAt, + &c.LastSeenAt, &c.ExtraJSON, &c.CreatedAt, &c.UpdatedAt, ) return c, err } @@ -39,14 +39,17 @@ func (s *Store) CreateContainer(c Container) (Container, error) { } c.CreatedAt = Now() c.UpdatedAt = c.CreatedAt + if c.ExtraJSON == "" { + c.ExtraJSON = "{}" + } _, err := s.db.Exec( `INSERT INTO containers (`+containerColumns+`) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, c.ID, c.WorkloadID, c.WorkloadKind, c.Role, c.StageID, c.ContainerID, c.ImageRef, c.ImageTag, c.Host, c.State, c.Port, c.Subdomain, c.ProxyRouteID, c.NpmProxyID, - c.LastSeenAt, c.CreatedAt, c.UpdatedAt, + c.LastSeenAt, c.ExtraJSON, c.CreatedAt, c.UpdatedAt, ) if err != nil { return Container{}, fmt.Errorf("insert container: %w", err) @@ -71,11 +74,14 @@ func (s *Store) UpsertContainer(c Container) error { if c.CreatedAt == "" { c.CreatedAt = c.UpdatedAt } + if c.ExtraJSON == "" { + c.ExtraJSON = "{}" + } // SQLite UPSERT — INSERT...ON CONFLICT(id) DO UPDATE. _, err := s.db.Exec( `INSERT INTO containers (`+containerColumns+`) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET workload_id=excluded.workload_id, workload_kind=excluded.workload_kind, @@ -91,11 +97,12 @@ func (s *Store) UpsertContainer(c Container) error { proxy_route_id=excluded.proxy_route_id, npm_proxy_id=excluded.npm_proxy_id, last_seen_at=excluded.last_seen_at, + extra_json=excluded.extra_json, updated_at=excluded.updated_at`, c.ID, c.WorkloadID, c.WorkloadKind, c.Role, c.StageID, c.ContainerID, c.ImageRef, c.ImageTag, c.Host, c.State, c.Port, c.Subdomain, c.ProxyRouteID, c.NpmProxyID, - c.LastSeenAt, c.CreatedAt, c.UpdatedAt, + c.LastSeenAt, c.ExtraJSON, c.CreatedAt, c.UpdatedAt, ) if err != nil { return fmt.Errorf("upsert container: %w", err) @@ -119,10 +126,17 @@ func (s *Store) ReconcileContainer(c Container) error { if c.CreatedAt == "" { 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( `INSERT INTO containers (`+containerColumns+`) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET container_id=excluded.container_id, 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.ImageRef, c.ImageTag, c.Host, c.State, c.Port, c.Subdomain, c.ProxyRouteID, c.NpmProxyID, - c.LastSeenAt, c.CreatedAt, c.UpdatedAt, + c.LastSeenAt, c.ExtraJSON, c.CreatedAt, c.UpdatedAt, ) if err != nil { 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. func (s *Store) UpdateContainer(c Container) error { c.UpdatedAt = Now() + if c.ExtraJSON == "" { + c.ExtraJSON = "{}" + } result, err := s.db.Exec( `UPDATE containers SET workload_id=?, workload_kind=?, role=?, stage_id=?, container_id=?, image_ref=?, image_tag=?, host=?, state=?, port=?, subdomain=?, proxy_route_id=?, npm_proxy_id=?, - last_seen_at=?, updated_at=? + last_seen_at=?, extra_json=?, updated_at=? WHERE id=?`, c.WorkloadID, c.WorkloadKind, c.Role, c.StageID, c.ContainerID, c.ImageRef, c.ImageTag, c.Host, c.State, c.Port, c.Subdomain, c.ProxyRouteID, c.NpmProxyID, - c.LastSeenAt, c.UpdatedAt, c.ID, + c.LastSeenAt, c.ExtraJSON, c.UpdatedAt, c.ID, ) if err != nil { return fmt.Errorf("update container: %w", err) diff --git a/internal/store/static_sites.go b/internal/store/static_sites.go index 9c4da67..17d62e6 100644 --- a/internal/store/static_sites.go +++ b/internal/store/static_sites.go @@ -18,6 +18,54 @@ const staticSiteCols = `id, name, provider, gitea_url, repo_owner, repo_name, br notification_url, notification_secret, 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 // is generated automatically if one is not already set on the input. Site row // + matching workload row are written in a single transaction. diff --git a/internal/store/workload_env.go b/internal/store/workload_env.go new file mode 100644 index 0000000..fb31857 --- /dev/null +++ b/internal/store/workload_env.go @@ -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 +} diff --git a/internal/store/workload_env_test.go b/internal/store/workload_env_test.go new file mode 100644 index 0000000..035a5bc --- /dev/null +++ b/internal/store/workload_env_test.go @@ -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)) + } +} diff --git a/internal/store/workload_volumes.go b/internal/store/workload_volumes.go new file mode 100644 index 0000000..e64ff08 --- /dev/null +++ b/internal/store/workload_volumes.go @@ -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 +} diff --git a/internal/store/workloads.go b/internal/store/workloads.go index e83969a..cd12765 100644 --- a/internal/store/workloads.go +++ b/internal/store/workloads.go @@ -9,6 +9,8 @@ import ( ) 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, webhook_secret, webhook_signing_secret, webhook_require_signature, created_at, updated_at` @@ -17,6 +19,8 @@ func scanWorkload(scanner interface{ Scan(...any) error }) (Workload, error) { var w Workload err := scanner.Scan( &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.WebhookSecret, &w.WebhookSigningSecret, &w.WebhookRequireSignature, &w.CreatedAt, &w.UpdatedAt, @@ -33,10 +37,21 @@ func (s *Store) CreateWorkload(w Workload) (Workload, error) { w.CreatedAt = Now() w.UpdatedAt = w.CreatedAt + if w.SourceConfig == "" { + w.SourceConfig = "{}" + } + if w.TriggerConfig == "" { + w.TriggerConfig = "{}" + } + if w.PublicFaces == "" { + w.PublicFaces = "[]" + } _, err := s.db.Exec( `INSERT INTO workloads (`+workloadColumns+`) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 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.WebhookSecret, w.WebhookSigningSecret, BoolToInt(w.WebhookRequireSignature), 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, -// 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 { w.UpdatedAt = Now() + if w.SourceConfig == "" { + w.SourceConfig = "{}" + } + if w.TriggerConfig == "" { + w.TriggerConfig = "{}" + } + if w.PublicFaces == "" { + w.PublicFaces = "[]" + } result, err := s.db.Exec( `UPDATE workloads SET name=?, app_id=?, + source_kind=?, source_config=?, trigger_kind=?, trigger_config=?, + public_faces=?, parent_workload_id=?, notification_url=?, notification_secret=?, webhook_secret=?, webhook_signing_secret=?, webhook_require_signature=?, updated_at=? WHERE id=?`, w.Name, w.AppID, + w.SourceKind, w.SourceConfig, w.TriggerKind, w.TriggerConfig, + w.PublicFaces, w.ParentWorkloadID, w.NotificationURL, w.NotificationSecret, w.WebhookSecret, w.WebhookSigningSecret, BoolToInt(w.WebhookRequireSignature), w.UpdatedAt, w.ID, @@ -173,6 +202,70 @@ func (s *Store) DeleteWorkload(id string) error { 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). // Idempotent — returns nil if no row exists, since the kind-specific Delete // callers don't always know whether a workload row was created. diff --git a/internal/volume/resolver.go b/internal/volume/resolver.go index 12b74c5..4751b74 100644 --- a/internal/volume/resolver.go +++ b/internal/volume/resolver.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "path/filepath" + "regexp" "strings" "github.com/alexei/tinyforge/internal/store" @@ -106,3 +107,122 @@ func parseAllowedPaths(jsonStr string) ([]string, error) { func ParseAllowedPaths(jsonStr string) ([]string, error) { 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 /instance-/. +// 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 /. Two names +// for one shape is intentional: it lets legacy data migrate without +// a path rewrite. +// - project_named — workload-scoped named volume under +// /_named//. +// - named — globally-scoped named volume under +// _named//. +// +// The directory segment is `-`. 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 +} diff --git a/internal/volume/resolver_test.go b/internal/volume/resolver_test.go new file mode 100644 index 0000000..f58cd26 --- /dev/null +++ b/internal/volume/resolver_test.go @@ -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) + } + } +} diff --git a/internal/workload/plugin/plugin.go b/internal/workload/plugin/plugin.go new file mode 100644 index 0000000..2e92d05 --- /dev/null +++ b/internal/workload/plugin/plugin.go @@ -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 +} diff --git a/internal/workload/plugin/registry.go b/internal/workload/plugin/registry.go new file mode 100644 index 0000000..2164dd7 --- /dev/null +++ b/internal/workload/plugin/registry.go @@ -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 +} diff --git a/internal/workload/plugin/source.go b/internal/workload/plugin/source.go new file mode 100644 index 0000000..09cfb2f --- /dev/null +++ b/internal/workload/plugin/source.go @@ -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 +} diff --git a/internal/workload/plugin/source/compose/compose.go b/internal/workload/plugin/source/compose/compose.go new file mode 100644 index 0000000..afed7fe --- /dev/null +++ b/internal/workload/plugin/source/compose/compose.go @@ -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 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)" +} diff --git a/internal/workload/plugin/source/image/image.go b/internal/workload/plugin/source/image/image.go new file mode 100644 index 0000000..e10905c --- /dev/null +++ b/internal/workload/plugin/source/image/image.go @@ -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 /instance-/ +// - 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{} +} diff --git a/internal/workload/plugin/source/image/image_helpers_test.go b/internal/workload/plugin/source/image/image_helpers_test.go new file mode 100644 index 0000000..d1d1763 --- /dev/null +++ b/internal/workload/plugin/source/image/image_helpers_test.go @@ -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) + } + }) + } +} diff --git a/internal/workload/plugin/source/static/static.go b/internal/workload/plugin/source/static/static.go new file mode 100644 index 0000000..7cb9115 --- /dev/null +++ b/internal/workload/plugin/source/static/static.go @@ -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) +} diff --git a/internal/workload/plugin/trigger.go b/internal/workload/plugin/trigger.go new file mode 100644 index 0000000..8e6f99c --- /dev/null +++ b/internal/workload/plugin/trigger.go @@ -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) } diff --git a/internal/workload/plugin/trigger/git/git.go b/internal/workload/plugin/trigger/git/git.go new file mode 100644 index 0000000..00b0761 --- /dev/null +++ b/internal/workload/plugin/trigger/git/git.go @@ -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 +} diff --git a/internal/workload/plugin/trigger/git/git_test.go b/internal/workload/plugin/trigger/git/git_test.go new file mode 100644 index 0000000..e9f7973 --- /dev/null +++ b/internal/workload/plugin/trigger/git/git_test.go @@ -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) + } + }) +} diff --git a/internal/workload/plugin/trigger/manual/manual.go b/internal/workload/plugin/trigger/manual/manual.go new file mode 100644 index 0000000..2fdefe7 --- /dev/null +++ b/internal/workload/plugin/trigger/manual/manual.go @@ -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 +} diff --git a/internal/workload/plugin/trigger/manual/manual_test.go b/internal/workload/plugin/trigger/manual/manual_test.go new file mode 100644 index 0000000..fb9f99b --- /dev/null +++ b/internal/workload/plugin/trigger/manual/manual_test.go @@ -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) + } + }) +} diff --git a/internal/workload/plugin/trigger/registry/registry.go b/internal/workload/plugin/trigger/registry/registry.go new file mode 100644 index 0000000..2e102e4 --- /dev/null +++ b/internal/workload/plugin/trigger/registry/registry.go @@ -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 +} diff --git a/internal/workload/plugin/trigger/registry/registry_test.go b/internal/workload/plugin/trigger/registry/registry_test.go new file mode 100644 index 0000000..18edb9f --- /dev/null +++ b/internal/workload/plugin/trigger/registry/registry_test.go @@ -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") + } + }) +} diff --git a/internal/workload/plugin/types.go b/internal/workload/plugin/types.go new file mode 100644 index 0000000..81a6bca --- /dev/null +++ b/internal/workload/plugin/types.go @@ -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:" +} + +// 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 +} diff --git a/web/src/lib/components/ContainerLogs.svelte b/web/src/lib/components/ContainerLogs.svelte index 4c8a6d8..6a83439 100644 --- a/web/src/lib/components/ContainerLogs.svelte +++ b/web/src/lib/components/ContainerLogs.svelte @@ -6,14 +6,19 @@ --> + + + Apps · Tinyforge + + +
+ {#snippet appsToolbar()} + + + + New App + + {/snippet} + + {#snippet appsStats()} +
+
TOTAL
+
{loading ? '—' : String(pluginRows.length).padStart(2, '0')}
+
+
+
IMAGE
+
{loading ? '—' : String(countBy('image')).padStart(2, '0')}
+
+
+
COMPOSE
+
{loading ? '—' : String(countBy('compose')).padStart(2, '0')}
+
+
+
STATIC
+
{loading ? '—' : String(countBy('static')).padStart(2, '0')}
+
+ {/snippet} + + {#snippet appsLede()} + Plugin-native deployables — image, compose, or static, with + pluggable redeploy triggers. Legacy projects, stacks, and sites continue to live under their + own sections during the cutover. + {/snippet} + + + + {#if error} +
ERR{error}
+ {/if} + + {#if !loading && pluginRows.length > 0} +
+ + {#each sourceKinds as kind} + {@const count = countBy(kind)} + + {/each} +
+ {/if} + + {#if loading} +
+ {#each Array(4) as _, i} +
+ {/each} +
+ {:else if filtered.length === 0} +
+
+

No apps yet

+

+ Apps unify image, compose, and static deployables behind a single plugin-driven + surface. Forge your first one to see it light up here. +

+ + Forge the first app + +
+ {:else} +
+ + + + + + + + + + + + {#each filtered as w, i (w.id)} + + + + + + + + {/each} + +
NameSourceTriggerCreatedActions
+ + {String(i + 1).padStart(2, '0')} + {w.name} + + + + {w.source_kind} + + + {w.trigger_kind} + {w.created_at} + + Open + +
+
+ {/if} +
+ + diff --git a/web/src/routes/apps/[id]/+page.svelte b/web/src/routes/apps/[id]/+page.svelte new file mode 100644 index 0000000..f4c3510 --- /dev/null +++ b/web/src/routes/apps/[id]/+page.svelte @@ -0,0 +1,2690 @@ + + + + {workload?.name ?? 'App'} · Tinyforge + + +
+ {#if loading && !workload} +
+ + Loading workload… +
+ {:else if error && !workload} +
ERR{error}
+ {:else if workload} + {#snippet detailToolbar()} + + {#if !editing} + + + {/if} + {/snippet} + + {#snippet detailLede()} + + + + {workload.source_kind} + + {workload.trigger_kind} + · + + created {workload.created_at} + + + {/snippet} + + + + {#if error} +
ERR{error}
+ {/if} + + {#if editing} + +
+ + + + + +
+

Edit configuration.

+ + Source {workload.source_kind} · Trigger + {workload.trigger_kind} + +
+ +
+ + +
+ +
+ + +
+ +
+
+ 03 + Source config + + {useEditComposeForm + ? 'YAML' + : useEditImageForm || useEditStaticForm + ? 'FORM' + : 'JSON'} + +
+ {#if useEditComposeForm} +
+
+ + compose.yaml + + +
+ +
+ + + YAML + +
+
+ + {:else if useEditImageForm} +
+
+ image source · runtime knobs + +
+ +
+ + + +
+ +
+ + + +
+

+ Env vars and volume mounts use their own panels below — saving here + preserves them. +

+
+ {:else if useEditStaticForm} +
+
+ static source · pages from a repo + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ Mode + + +
+ +
+ {:else} +
+
+ + source_config.json + + {#if (workload?.source_kind ?? '') === 'compose' || (workload?.source_kind ?? '') === 'image' || (workload?.source_kind ?? '') === 'static'} + + {/if} +
+ +
+ + + {sourceValid ? 'JSON OK' : 'JSON INVALID'} + +
+
+ {/if} +
+ +
+
+ 03 + Trigger config + JSON +
+
+
+ + trigger_config.json +
+ +
+ + + {triggerValid ? 'JSON OK' : 'JSON INVALID'} + +
+
+
+ +
+
+ 04 + Public faces + JSON ARRAY +
+
+
+ + public_faces.json +
+ +
+ + + {facesValid ? 'JSON OK' : 'JSON INVALID'} + +
+
+
+ +
+ + +
+
+ {/if} + + +
+
+

Manual deploy.

+ + Bypasses the {workload.trigger_kind} trigger and dispatches through the + source plugin directly. + +
+ + {#if lastDeployMsg} +
+ OK + {lastDeployMsg} +
+ {/if} + +
+ + +
+

+ Use a specific image tag, git sha, or branch to force a deploy. Leave blank to use the + default reference resolved by the source plugin. +

+
+ + +
+
+

Containers.

+ + {containers.length === 0 ? 'No containers yet' : `${containers.length} reconciled`} + +
+ {#if containers.length === 0} +
+ + No containers yet — deploy to spin one up. +
+ {:else} + {#if logContainerRowID} +
+ (logContainerRowID = null)} + /> +
+ {/if} +
+ + + + + + + + + + + + + {#each containers as c (c.id)} + + + + + + + + + {/each} + +
RoleStateImageSubdomainLast seenActions
{c.role || '—'} + + + {c.state} + + {c.image_ref || '—'}{c.subdomain || '—'}{c.last_seen_at || '—'} + {#if c.container_id} + + {:else} + + {/if} +
+
+ {/if} +
+ + + {#if !editing && chain && (chain.parent || chain.children.length > 0)} +
+
+

Chain.

+ + {chain.parent ? 'promotes from a parent' : 'parent of'} + {chain.children.length} + {chain.children.length === 1 ? 'child' : 'children'} + +
+ {#if chainError} +
ERR{chainError}
+ {/if} + + {#if chain.parent} +
+ Parent + + {chain.parent.name} + {chain.parent.source_kind} · {chain.parent.trigger_kind} + + {#if workload?.source_kind === 'image' && chain.parent.source_kind === 'image'} + + {/if} +
+ {/if} + +
+ This +
+ {workload?.name ?? '—'} + {workload?.source_kind} · {workload?.trigger_kind} +
+
+ + {#if chain.children.length > 0} +
+ Children +
+ {#each chain.children as child (child.id)} + + {child.name} + {child.source_kind} · {child.trigger_kind} + + {/each} +
+
+ {/if} +

+ Set parent_workload_id on a workload to build a chain. Image-source children + can promote the parent's currently-running tag with one click. +

+
+ {/if} + + + {#if !editing} +
+
+

+ {$t('logscan.panel.heading')}. +

+ + {logRules.length === 0 + ? $t('logscan.panel.subEmpty') + : logRules.length === 1 + ? $t('logscan.panel.subCountOne') + : $t('logscan.panel.subCount', { count: String(logRules.length) })} + · {$t('observability.manage')} + +
+ {#if logRulesError} + + {/if} + {#if logRules.length === 0} +

+ {$t('logscan.panel.emptyHint')} + {$t('logscan.panel.newRule')}. +

+ {:else} +
+ + + + + + + + + + + + + + {#each logRules as r (r.id)} + {@const kind = classifyRule(r)} + + + + + + + + + + {/each} + +
{$t('logscan.list.name')}{$t('logscan.list.pattern')}{$t('logscan.list.scope')}{$t('logscan.list.severity')}{$t('logscan.list.streams')}{$t('logscan.list.status')}{$t('triggers.list.action')}
+ + {r.name} + + /{r.pattern}/ + {$t(`logscan.filter.${kind === 'override' ? 'overrides' : kind}`).toLowerCase()} + + {r.severity} + {r.streams} + + + {r.enabled ? $t('logscan.status.on') : $t('logscan.status.off')} + + + {#if kind === 'global'} + + {:else} + + {$t('observability.edit')} + + {/if} +
+
+

{$t('logscan.panel.footerHint')}

+ {/if} +
+ {/if} + + + {#if !editing} +
+
+

Volumes.

+ + {volumeRows.length === 0 + ? 'No mounts' + : `${volumeRows.length} mount${volumeRows.length === 1 ? '' : 's'}`} + +
+ {#if volumeError} +
ERR{volumeError}
+ {/if} + {#if volumeRows.length > 0} +
+ + + + + + + + + + + + {#each volumeRows as v (v.id)} + + + + + + + + {/each} + +
TargetSourceScopeUpdatedActions
{v.target}{v.source || '—'} + {v.scope} + {v.updated_at} + +
+
+ {/if} +
{ + ev.preventDefault(); + addVolume(); + }} + > + + + + +
+

+ Absolute mounts bind a host path into the container. Non-absolute scopes are accepted for + future use; only absolute is honoured at deploy time today. +

+
+ + {/if} + + + {#if !editing} +
+
+

Env.

+ + {envRows.length === 0 + ? 'No overrides' + : `${envRows.length} override${envRows.length === 1 ? '' : 's'}`} + +
+ {#if envError} +
ERR{envError}
+ {/if} + {#if envRows.length > 0} +
+ + + + + + + + + + + {#each envRows as e (e.id)} + + + + + + + {/each} + +
KeyValueUpdatedActions
{e.key} + {#if e.encrypted} + + + ENCRYPTED + + {:else} + {e.value || '—'} + {/if} + {e.updated_at} + +
+
+ {/if} +
{ + ev.preventDefault(); + addEnv(); + }} + > + + + + +
+

+ Encrypted values are write-only after store — the API redacts them on read. Rotate by + setting a new value. +

+
+ + +
+
+

Webhook.

+ {#if !webhook} + + {/if} +
+ {#if webhookError} +
ERR{webhookError}
+ {/if} + {#if webhook} +

+ Point your registry or CI here. The URL itself is the credential — treat it as a + secret. Rotate any time without disrupting deploys (the next call uses the new URL). +

+
+ {webhook.webhook_url} + +
+
+ + {webhook.has_signing_secret ? 'HMAC SIGNED' : 'UNSIGNED'} + + + {webhook.webhook_require_signature ? 'SIGNATURE REQUIRED' : 'SIGNATURE OPTIONAL'} + +
+
+ +
+ {/if} +
+ {/if} + + + {#if !editing} +
+
+ + +
+ {#if openSource} +
+
{prettyJson(workload.source_config)}
+
+ {/if} +
+ +
+
+ + +
+ {#if openTrigger} +
+
{prettyJson(workload.trigger_config)}
+
+ {/if} +
+ + {#if workload.public_faces && workload.public_faces !== '[]'} +
+
+ + +
+ {#if openFaces} +
+
{prettyJson(workload.public_faces)}
+
+ {/if} +
+ {/if} + {/if} + {/if} +
+ + { + if (!deleting) confirmDelete = false; + }} +/> + + diff --git a/web/src/routes/apps/new/+page.svelte b/web/src/routes/apps/new/+page.svelte new file mode 100644 index 0000000..63a0bdd --- /dev/null +++ b/web/src/routes/apps/new/+page.svelte @@ -0,0 +1,1574 @@ + + + + New App · Tinyforge + + +
+ {#snippet newLede()} + Create a plugin-native workload. Source = how it deploys (image, compose, static). + Trigger = when it redeploys (registry push, git push, manual). Both axes are + independently extensible. + {/snippet} + + + + {#if loading} +
+ + Loading available plugin kinds… +
+ {:else} +
+ + + + + + {#if error} +
ERR{error}
+ {/if} + +
+ + +

Lowercase, no spaces. Becomes part of container names and subdomains.

+
+ +
+
+ 02 + Plugins + SOURCE × TRIGGER +
+
+ + +
+

+ Both pickers are populated from the running daemon — only plugins compiled in show up. +

+
+ +
+
+ 03 + Source config + + {useComposeForm + ? 'YAML' + : useImageForm || useStaticForm + ? 'FORM' + : 'JSON'} + +
+ {#if useComposeForm} +
+
+ + compose.yaml · compose + + +
+ +
+ + + YAML + + · + {composeYaml.split('\n').length} lines +
+
+ + {:else if useImageForm} + +
+
+ image source · runtime knobs + +
+ +
+ + + +
+ +
+ + + +
+

+ Env vars and volume mounts live in their own panels on the workload + detail page after creation. +

+
+ {:else if useStaticForm} + +
+
+ static source · pages from a repo + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ Mode + + +
+ +

+ The webhook secret for git push triggers lives on the workload's + Webhook panel after creation. +

+
+ {:else} +
+
+ + source_config.json · {sourceKind} + + {#if sourceKind === 'compose' || sourceKind === 'image' || sourceKind === 'static'} + + {/if} + +
+ +
+ + + {sourceValid ? 'JSON OK' : 'JSON INVALID'} + + · + {sourceLines} lines + · + {sourceBytes} B +
+
+ {/if} +
+ +
+
+ 04 + Trigger config + JSON +
+
+
+ + trigger_config.json · {triggerKind} + + +
+ +
+ + + {triggerValid ? 'JSON OK' : 'JSON INVALID'} + + · + {triggerLines} lines + · + {triggerBytes} B +
+
+
+ +
+ + 05 + Public face + OPTIONAL + +
+ + + +
+

+ Leave blank to skip provisioning a proxy route. Filling any field creates a single + face row attached to this workload. +

+
+ +
+ Cancel + +
+
+ {/if} +
+ +