refactor(workload): plugin architecture wave + apps UI + volume scopes
Completes the workload-first refactor's plugin layer:
- internal/workload/plugin/ — Source/Trigger plugin contract,
registry, types (Workload, DeploymentIntent, InboundEvent,
PublicFace). Self-registering init() pattern + blank-import
in cmd/server/main.go.
- Source plugins: image (blue-green with multi-face proxy routing),
compose, static. Trigger plugins: registry, git, manual.
- internal/deployer/dispatch.go — DispatchPlugin/Teardown/Reconcile
seam routing the legacy deployer through plugins.
- internal/api/workload_*.go — REST surface: workloads, env,
volumes, chain (parent/children), promote-from. hooks.go
serves /api/hooks/kinds/{kind}/schema for the wizard.
- internal/store: workload_env (encrypt-at-rest secrets) and
workload_volumes tables, keyed on workload_id.
- cmd/server/static_backend.go — phantom-row adapter delegating
the static source plugin to the legacy staticsite.Manager
(deleted at hard cutover once the static inline port lands).
- web/src/routes/apps/ — /apps list + /apps/new wizard +
/apps/[id] detail with kind-aware compose / image / static
forms (Advanced JSON toggle), env panel, volumes panel,
webhook panel, chain panel, manual deploy.
Volume scope generalization (v2 resolver):
- internal/volume.ResolveWorkloadPath (workload-keyed, sits
next to legacy ResolvePath). Honors all VolumeScope values:
absolute, ephemeral, instance, stage, project, project_named,
named. internal/workload/plugin/source/image/image.go
computeMounts wires settings + imageTag through. Coverage in
internal/volume/resolver_test.go (portable Linux/Windows via
t.TempDir).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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 <WorkloadContainers> component uses this on every kind-specific
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <workload>/instance-<tag>/<source>.
|
||||
// Useful for blue-green when each running instance needs its own dir.
|
||||
// - stage, project — both legacy names collapse to "shared across all
|
||||
// instances of this workload" under <workload>/<source>. Two names
|
||||
// for one shape is intentional: it lets legacy data migrate without
|
||||
// a path rewrite.
|
||||
// - project_named — workload-scoped named volume under
|
||||
// <workload>/_named/<name>/<source>.
|
||||
// - named — globally-scoped named volume under
|
||||
// _named/<name>/<source>.
|
||||
//
|
||||
// The <workload> directory segment is `<sanitized-name>-<short-id>`. The
|
||||
// short-id suffix prevents collisions when two workloads share a name
|
||||
// (the workloads table only enforces uniqueness on (kind, ref_id)).
|
||||
func ResolveWorkloadPath(vol store.WorkloadVolume, params ResolveWorkloadParams) (string, error) {
|
||||
scope := vol.Scope
|
||||
if scope == "" {
|
||||
return "", fmt.Errorf("workload volume: scope is required")
|
||||
}
|
||||
if scope == string(store.VolumeScopeEphemeral) {
|
||||
return "", fmt.Errorf("ephemeral volumes have no host path")
|
||||
}
|
||||
if scope == string(store.VolumeScopeAbsolute) {
|
||||
return resolveAbsolute(vol.Source, params.AllowedVolumePaths)
|
||||
}
|
||||
if params.BasePath == "" {
|
||||
return "", fmt.Errorf("workload volume: base path is required for scope %q", scope)
|
||||
}
|
||||
|
||||
workloadDir, err := workloadPathSegment(params.WorkloadName, params.WorkloadID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
switch scope {
|
||||
case string(store.VolumeScopeInstance):
|
||||
if params.ImageTag == "" {
|
||||
return "", fmt.Errorf("instance scope requires image tag")
|
||||
}
|
||||
tag := sanitizePathSegment(params.ImageTag)
|
||||
if tag == "" {
|
||||
return "", fmt.Errorf("instance scope requires non-empty image tag")
|
||||
}
|
||||
return filepath.Join(params.BasePath, workloadDir, "instance-"+tag, vol.Source), nil
|
||||
case string(store.VolumeScopeStage), string(store.VolumeScopeProject):
|
||||
return filepath.Join(params.BasePath, workloadDir, vol.Source), nil
|
||||
case string(store.VolumeScopeProjectNamed):
|
||||
name := sanitizePathSegment(vol.Name)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("project_named scope requires name")
|
||||
}
|
||||
return filepath.Join(params.BasePath, workloadDir, "_named", name, vol.Source), nil
|
||||
case string(store.VolumeScopeNamed):
|
||||
name := sanitizePathSegment(vol.Name)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("named scope requires name")
|
||||
}
|
||||
return filepath.Join(params.BasePath, "_named", name, vol.Source), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown volume scope %q", scope)
|
||||
}
|
||||
}
|
||||
|
||||
// pathSegmentSanitizer collapses anything outside the [a-zA-Z0-9_.-] set
|
||||
// to a single dash. The character set matches Docker's permissive segment
|
||||
// rules; the additional Trim afterward keeps the segment from starting
|
||||
// or ending with a separator.
|
||||
var pathSegmentSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_.-]+`)
|
||||
|
||||
func sanitizePathSegment(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.Trim(pathSegmentSanitizer.ReplaceAllString(s, "-"), "-")
|
||||
}
|
||||
|
||||
// workloadPathSegment builds the per-workload directory name. The
|
||||
// 8-char id-short suffix disambiguates same-named workloads — only
|
||||
// (kind, ref_id) is unique at the DB level, so names alone are unsafe.
|
||||
// Returns an error when both identity fields are empty, since the
|
||||
// resulting path would not be workload-scoped.
|
||||
func workloadPathSegment(name, id string) (string, error) {
|
||||
cleanName := sanitizePathSegment(name)
|
||||
idShort := id
|
||||
if len(idShort) > 8 {
|
||||
idShort = idShort[:8]
|
||||
}
|
||||
idShort = sanitizePathSegment(idShort)
|
||||
if cleanName == "" && idShort == "" {
|
||||
return "", fmt.Errorf("workload volume: workload id or name required")
|
||||
}
|
||||
if cleanName == "" {
|
||||
return idShort, nil
|
||||
}
|
||||
if idShort == "" {
|
||||
return cleanName, nil
|
||||
}
|
||||
return cleanName + "-" + idShort, nil
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Source is the contract for one deployable shape (image, compose, static,
|
||||
// ...). Implementations are stateless: every method receives Deps so the
|
||||
// same value can serve concurrent deploys safely.
|
||||
//
|
||||
// A Source owns the full lifecycle of its containers — it is expected to
|
||||
// reconcile rows in the containers index, register/deregister proxy
|
||||
// routes via Deps.Proxy, and manage DNS via Deps.DNS. The deployer
|
||||
// pipeline only chooses the right Source and feeds it a DeploymentIntent.
|
||||
type Source interface {
|
||||
// Kind is the registration key (e.g. "image", "compose", "static").
|
||||
Kind() string
|
||||
|
||||
// Validate type-checks a raw config blob before it is persisted.
|
||||
// Return a user-friendly error — the message is shown in the UI.
|
||||
Validate(cfg json.RawMessage) error
|
||||
|
||||
// Deploy executes one deployment of w using intent. Whether this is a
|
||||
// fresh start, an update, or a no-op is the Source's call: e.g. an
|
||||
// image source short-circuits if the requested tag already runs.
|
||||
Deploy(ctx context.Context, deps Deps, w Workload, intent DeploymentIntent) error
|
||||
|
||||
// Teardown removes everything Deploy created (containers, proxy
|
||||
// routes, DNS, source-specific state). Idempotent.
|
||||
Teardown(ctx context.Context, deps Deps, w Workload) error
|
||||
|
||||
// Reconcile brings the containers index in sync with reality. Called
|
||||
// by the periodic reconciler — must be cheap when nothing changed.
|
||||
Reconcile(ctx context.Context, deps Deps, w Workload) error
|
||||
}
|
||||
|
||||
var (
|
||||
sourcesMu sync.RWMutex
|
||||
sources = map[string]Source{}
|
||||
)
|
||||
|
||||
// RegisterSource installs s under s.Kind(). Panics on duplicate
|
||||
// registration: that always indicates a bug in init() ordering, not a
|
||||
// recoverable runtime condition.
|
||||
func RegisterSource(s Source) {
|
||||
sourcesMu.Lock()
|
||||
defer sourcesMu.Unlock()
|
||||
k := s.Kind()
|
||||
if _, dup := sources[k]; dup {
|
||||
panic(fmt.Sprintf("plugin: source %q already registered", k))
|
||||
}
|
||||
sources[k] = s
|
||||
}
|
||||
|
||||
// GetSource returns the Source registered for kind, or an error mentioning
|
||||
// the kind that was missing — useful when a workload row references a
|
||||
// kind whose package was not blank-imported.
|
||||
func GetSource(kind string) (Source, error) {
|
||||
sourcesMu.RLock()
|
||||
defer sourcesMu.RUnlock()
|
||||
s, ok := sources[kind]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("plugin: no source registered for kind %q", kind)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Schemaer is the optional interface a Source or Trigger may implement
|
||||
// to surface a sample config blob. The /api/hooks/kinds/{kind}/schema
|
||||
// endpoint uses this so frontends can render kind-aware forms without
|
||||
// hardcoding samples per call-site. Plugins that don't implement it
|
||||
// produce an empty object on the wire.
|
||||
type Schemaer interface {
|
||||
SchemaSample() any
|
||||
}
|
||||
|
||||
// SchemaSampleFor returns the typed sample value declared by the plugin
|
||||
// registered under kind, or nil if no sample is published.
|
||||
func SchemaSampleFor(kind string) (any, bool) {
|
||||
sourcesMu.RLock()
|
||||
if s, ok := sources[kind]; ok {
|
||||
sourcesMu.RUnlock()
|
||||
if sm, ok := s.(Schemaer); ok {
|
||||
return sm.SchemaSample(), true
|
||||
}
|
||||
return nil, true
|
||||
}
|
||||
sourcesMu.RUnlock()
|
||||
triggersMu.RLock()
|
||||
defer triggersMu.RUnlock()
|
||||
if t, ok := triggers[kind]; ok {
|
||||
if sm, ok := t.(Schemaer); ok {
|
||||
return sm.SchemaSample(), true
|
||||
}
|
||||
return nil, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// SourceKinds returns all registered source kinds, sorted for stable
|
||||
// listing in /api/workloads/source-kinds.
|
||||
func SourceKinds() []string {
|
||||
sourcesMu.RLock()
|
||||
defer sourcesMu.RUnlock()
|
||||
out := make([]string, 0, len(sources))
|
||||
for k := range sources {
|
||||
out = append(out, k)
|
||||
}
|
||||
sortStrings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
// Package compose implements the "compose" source: a docker-compose stack
|
||||
// deployed as a single logical unit. Multiple service containers may
|
||||
// result; each becomes one row in the containers index keyed by service
|
||||
// name in Container.Role.
|
||||
package compose
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/stack"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
)
|
||||
|
||||
// Config is the per-workload source config blob. ComposeYAML is the
|
||||
// authoritative spec — either inline (manual / paste-in flow) or fetched
|
||||
// by a git trigger and stashed here on each deploy. ComposeProjectName
|
||||
// is the `-p` arg passed to docker compose; defaults to a stable
|
||||
// workload-derived value when blank.
|
||||
type Config struct {
|
||||
ComposeYAML string `json:"compose_yaml"`
|
||||
ComposeProjectName string `json:"compose_project_name"`
|
||||
}
|
||||
|
||||
type source struct{}
|
||||
|
||||
func init() { plugin.RegisterSource(&source{}) }
|
||||
|
||||
func (*source) Kind() string { return "compose" }
|
||||
|
||||
func (*source) SchemaSample() any {
|
||||
return Config{
|
||||
ComposeYAML: "services:\n web:\n image: nginx:alpine\n ports:\n - \"80\"\n",
|
||||
}
|
||||
}
|
||||
|
||||
func (*source) Validate(cfg json.RawMessage) error {
|
||||
var c Config
|
||||
if len(cfg) == 0 {
|
||||
return fmt.Errorf("compose source: config is required")
|
||||
}
|
||||
if err := json.Unmarshal(cfg, &c); err != nil {
|
||||
return fmt.Errorf("compose source: invalid json: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(c.ComposeYAML) == "" {
|
||||
return fmt.Errorf("compose source: compose_yaml is required")
|
||||
}
|
||||
spec, err := stack.Parse(c.ComposeYAML)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compose source: parse yaml: %w", err)
|
||||
}
|
||||
if err := stack.Validate(spec); err != nil {
|
||||
return fmt.Errorf("compose source: validate yaml: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deploy writes the compose YAML to a stable per-workload path, runs
|
||||
// `docker compose -p <project> up -d`, then syncs one Container row per
|
||||
// service. The workload ID is the natural compose project name unless
|
||||
// the user supplied one explicitly.
|
||||
func (*source) Deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plugin.DeploymentIntent) error {
|
||||
cfg, err := plugin.SourceConfigOf[Config](w)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compose source: decode config: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(cfg.ComposeYAML) == "" {
|
||||
return fmt.Errorf("compose source: workload %s has empty compose_yaml", w.ID)
|
||||
}
|
||||
|
||||
projectName := composeProjectName(cfg.ComposeProjectName, w)
|
||||
yamlPath, err := writeYAML(w.ID, cfg.ComposeYAML)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compose source: write yaml: %w", err)
|
||||
}
|
||||
|
||||
compose := stack.NewCompose("")
|
||||
out, err := compose.Up(ctx, projectName, yamlPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compose source: docker compose up: %w (output: %s)", err, truncate(out, 1024))
|
||||
}
|
||||
|
||||
if err := syncContainers(ctx, deps, compose, w, projectName, yamlPath); err != nil {
|
||||
// `up` succeeded but we could not enumerate the resulting
|
||||
// containers — surface the failure so the UI does not show an
|
||||
// empty containers index for a running stack.
|
||||
return fmt.Errorf("compose source: sync container rows: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Teardown runs `docker compose down --remove-orphans -v` and drops the
|
||||
// container rows. Idempotent: missing compose project is treated as
|
||||
// already-down. Volume removal is intentional — workload teardown is
|
||||
// destructive by design (matches `DeleteStack(removeVolumes=true)`).
|
||||
func (*source) Teardown(ctx context.Context, deps plugin.Deps, w plugin.Workload) error {
|
||||
cfg, _ := plugin.SourceConfigOf[Config](w)
|
||||
projectName := composeProjectName(cfg.ComposeProjectName, w)
|
||||
|
||||
compose := stack.NewCompose("")
|
||||
if _, err := compose.Down(ctx, projectName, true); err != nil {
|
||||
// Log but proceed — the DB rows must not be orphaned.
|
||||
slog.Warn("compose source: docker compose down", "workload", w.ID, "error", err)
|
||||
}
|
||||
|
||||
// Best-effort: remove the YAML scratch dir.
|
||||
_ = os.RemoveAll(workloadDir(w.ID))
|
||||
|
||||
rows, err := deps.Store.ListContainersByWorkload(w.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compose source: list containers: %w", err)
|
||||
}
|
||||
for _, c := range rows {
|
||||
if err := deps.Store.DeleteContainer(c.ID); err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||
slog.Warn("compose source: delete container row", "id", c.ID, "error", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reconcile refreshes the containers index from `docker compose ps`. If
|
||||
// the compose project is unknown to Docker, container rows are marked
|
||||
// missing so the UI flags them. The reconciler hits this on every tick
|
||||
// per workload, so the YAML is only rewritten when its content has
|
||||
// actually changed.
|
||||
func (*source) Reconcile(ctx context.Context, deps plugin.Deps, w plugin.Workload) error {
|
||||
cfg, err := plugin.SourceConfigOf[Config](w)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compose source: decode config: %w", err)
|
||||
}
|
||||
projectName := composeProjectName(cfg.ComposeProjectName, w)
|
||||
yamlPath, _ := writeYAMLIfChanged(w.ID, cfg.ComposeYAML)
|
||||
|
||||
compose := stack.NewCompose("")
|
||||
services, err := compose.Ps(ctx, projectName, yamlPath)
|
||||
if err != nil {
|
||||
// Likely no compose project running for this workload. Mark
|
||||
// existing rows missing so the UI surfaces it.
|
||||
rows, _ := deps.Store.ListContainersByWorkload(w.ID)
|
||||
for _, c := range rows {
|
||||
_ = deps.Store.UpdateContainerState(c.ID, "missing")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, svc := range services {
|
||||
state := svc.State
|
||||
if state == "" {
|
||||
state = svc.Status
|
||||
}
|
||||
upsertServiceRow(deps, w, svc, state)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncContainers shares its body with Reconcile minus the missing-row
|
||||
// fallback — Deploy expects compose ps to succeed since `up` just ran.
|
||||
func syncContainers(ctx context.Context, deps plugin.Deps, compose *stack.Compose, w plugin.Workload, projectName, yamlPath string) error {
|
||||
services, err := compose.Ps(ctx, projectName, yamlPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compose ps: %w", err)
|
||||
}
|
||||
for _, svc := range services {
|
||||
state := svc.State
|
||||
if state == "" {
|
||||
state = svc.Status
|
||||
}
|
||||
upsertServiceRow(deps, w, svc, state)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func upsertServiceRow(deps plugin.Deps, w plugin.Workload, svc stack.Service, state string) {
|
||||
role := svc.Service
|
||||
if role == "" {
|
||||
role = svc.Name
|
||||
}
|
||||
if err := deps.Store.UpsertContainer(store.Container{
|
||||
ID: w.ID + ":" + role,
|
||||
WorkloadID: w.ID,
|
||||
WorkloadKind: "compose",
|
||||
Role: role,
|
||||
ContainerID: "", // reconciler fills via `docker ps` label join
|
||||
Host: "local",
|
||||
State: state,
|
||||
LastSeenAt: store.Now(),
|
||||
}); err != nil {
|
||||
slog.Warn("compose source: upsert container row", "workload", w.ID, "service", role, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// composeProjectName returns the `-p` argument for docker compose. We
|
||||
// always derive a stable name from the workload (sanitized + truncated
|
||||
// ID) when the user did not set ComposeProjectName, so re-deploys of the
|
||||
// same workload reuse the same project.
|
||||
var projectNameSanitizer = regexp.MustCompile(`[^a-z0-9_-]`)
|
||||
|
||||
func composeProjectName(explicit string, w plugin.Workload) string {
|
||||
if explicit != "" {
|
||||
return explicit
|
||||
}
|
||||
name := strings.ToLower(w.Name)
|
||||
name = projectNameSanitizer.ReplaceAllString(name, "-")
|
||||
name = strings.Trim(name, "-")
|
||||
if name == "" {
|
||||
name = "wkl"
|
||||
}
|
||||
idShort := w.ID
|
||||
if len(idShort) > 8 {
|
||||
idShort = idShort[:8]
|
||||
}
|
||||
return fmt.Sprintf("tf-%s-%s", name, idShort)
|
||||
}
|
||||
|
||||
// workloadDir is the per-workload scratch directory for compose YAML.
|
||||
func workloadDir(workloadID string) string {
|
||||
return filepath.Join(os.TempDir(), "tinyforge-compose", workloadID)
|
||||
}
|
||||
|
||||
// writeYAML writes the current compose YAML to a stable path under the
|
||||
// workload's scratch dir. Returns the path. Each deploy overwrites the
|
||||
// file — there are no revisions at the source level (the workload row is
|
||||
// the single source of truth; git or registry triggers update SourceConfig).
|
||||
//
|
||||
// Permissions are owner-only (0o700 / 0o600) because the YAML often
|
||||
// contains environment-section secrets and the dir lives in shared /tmp.
|
||||
func writeYAML(workloadID, yamlText string) (string, error) {
|
||||
dir := workloadDir(workloadID)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := filepath.Join(dir, "compose.yml")
|
||||
if err := os.WriteFile(path, []byte(yamlText), 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// writeYAMLIfChanged is writeYAML minus the disk write when the existing
|
||||
// file already matches yamlText. Used by Reconcile, which runs per
|
||||
// workload per tick; redundant fsync churn was a measurable cost.
|
||||
func writeYAMLIfChanged(workloadID, yamlText string) (string, error) {
|
||||
dir := workloadDir(workloadID)
|
||||
path := filepath.Join(dir, "compose.yml")
|
||||
if existing, err := os.ReadFile(path); err == nil && string(existing) == yamlText {
|
||||
return path, nil
|
||||
}
|
||||
return writeYAML(workloadID, yamlText)
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "...(truncated)"
|
||||
}
|
||||
@@ -0,0 +1,740 @@
|
||||
// Package image implements the "image" source: a single container pulled
|
||||
// from a registry. This is the canonical CI-driven shape — the registry
|
||||
// trigger feeds it new tags, and Deploy reconciles the running container
|
||||
// to match the requested tag.
|
||||
package image
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/moby/moby/api/types/mount"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/crypto"
|
||||
"github.com/alexei/tinyforge/internal/docker"
|
||||
"github.com/alexei/tinyforge/internal/proxy"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/volume"
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
)
|
||||
|
||||
// Config is the per-workload source config blob. Mirrors the deployment
|
||||
// fields that used to live on the projects + stages tables, less anything
|
||||
// that is now a Workload-level concern (notification config, webhook
|
||||
// secrets, public_face, group/parent).
|
||||
type Config struct {
|
||||
Image string `json:"image"` // fully-qualified, e.g. registry.example.com/owner/app
|
||||
RegistryName string `json:"registry_name"` // FK by name into registries table; "" = public/no auth
|
||||
Port int `json:"port"` // container's primary exposed port
|
||||
Healthcheck string `json:"healthcheck"` // HTTP path, e.g. "/healthz"; "" disables
|
||||
Env map[string]string `json:"env"` // injected as container env
|
||||
Volumes []VolumeMount `json:"volumes"`
|
||||
CpuLimit float64 `json:"cpu_limit"` // CPU cores; 0 = unlimited
|
||||
MemoryLimit int `json:"memory_limit"` // megabytes; 0 = unlimited
|
||||
DefaultTag string `json:"default_tag"` // tag used when intent.Reference is empty
|
||||
MaxInstances int `json:"max_instances"` // simultaneous containers to keep; 0/1 = strict blue-green
|
||||
}
|
||||
|
||||
// VolumeMount mirrors the existing store.Volume scope shape but as a flat
|
||||
// per-workload list. Future absolute / named-volume scopes can extend
|
||||
// this without schema changes.
|
||||
type VolumeMount struct {
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
Scope string `json:"scope"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type source struct{}
|
||||
|
||||
func init() { plugin.RegisterSource(&source{}) }
|
||||
|
||||
func (*source) Kind() string { return "image" }
|
||||
|
||||
// SchemaSample returns a populated example of Config so the frontend can
|
||||
// render kind-aware forms without hardcoding samples per call-site. Each
|
||||
// Source / Trigger exposes the same hook via plugin.SourceSchemaer /
|
||||
// plugin.TriggerSchemaer below.
|
||||
func (*source) SchemaSample() any {
|
||||
return Config{
|
||||
Image: "registry.example.com/owner/app",
|
||||
Port: 8080,
|
||||
Healthcheck: "/healthz",
|
||||
Env: map[string]string{},
|
||||
Volumes: []VolumeMount{},
|
||||
DefaultTag: "latest",
|
||||
MaxInstances: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func (*source) Validate(cfg json.RawMessage) error {
|
||||
var c Config
|
||||
if len(cfg) == 0 {
|
||||
return fmt.Errorf("image source: config is required")
|
||||
}
|
||||
if err := json.Unmarshal(cfg, &c); err != nil {
|
||||
return fmt.Errorf("image source: invalid json: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(c.Image) == "" {
|
||||
return fmt.Errorf("image source: image is required")
|
||||
}
|
||||
if c.Port < 0 || c.Port > 65535 {
|
||||
return fmt.Errorf("image source: port must be 0-65535")
|
||||
}
|
||||
for i, v := range c.Volumes {
|
||||
if strings.TrimSpace(v.Target) == "" {
|
||||
return fmt.Errorf("image source: volumes[%d].target is required", i)
|
||||
}
|
||||
if v.Scope == "" {
|
||||
return fmt.Errorf("image source: volumes[%d].scope is required", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deploy executes a blue-green deploy of w against the image tag implied
|
||||
// by intent. The flow:
|
||||
//
|
||||
// 1. Short-circuit if an existing container for this workload is already
|
||||
// running the requested ImageRef (duplicate webhook deliveries).
|
||||
// 2. Pull image, ensure network.
|
||||
// 3. Create + start a NEW container with a unique-per-deploy name (the
|
||||
// old container keeps serving traffic).
|
||||
// 4. Optional in-network healthcheck. Failure rolls back the new
|
||||
// container only — the old container is untouched.
|
||||
// 5. Register / update each public face's proxy route to point at the
|
||||
// new container.
|
||||
// 6. Enforce cfg.MaxInstances (default 1) by removing the oldest
|
||||
// surplus containers belonging to this workload. With MaxInstances=1
|
||||
// this is the "green" cutover — old container is removed only AFTER
|
||||
// the new face is live.
|
||||
//
|
||||
// Any failure between create and face-registration rolls back the new
|
||||
// container + its row; old serving state is preserved.
|
||||
func (*source) Deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plugin.DeploymentIntent) error {
|
||||
cfg, err := plugin.SourceConfigOf[Config](w)
|
||||
if err != nil {
|
||||
return fmt.Errorf("image source: decode config: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(cfg.Image) == "" {
|
||||
return fmt.Errorf("image source: workload %s has empty image", w.ID)
|
||||
}
|
||||
|
||||
tag := intent.Reference
|
||||
if tag == "" {
|
||||
tag = cfg.DefaultTag
|
||||
}
|
||||
if tag == "" {
|
||||
tag = "latest"
|
||||
}
|
||||
imageRef := cfg.Image + ":" + tag
|
||||
|
||||
settings, err := deps.Store.GetSettings()
|
||||
if err != nil {
|
||||
return fmt.Errorf("image source: load settings: %w", err)
|
||||
}
|
||||
if settings.Network == "" {
|
||||
return fmt.Errorf("image source: settings.network is required")
|
||||
}
|
||||
|
||||
existing, err := deps.Store.ListContainersByWorkload(w.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("image source: list existing containers: %w", err)
|
||||
}
|
||||
|
||||
// Idempotency: if a container is already running the requested
|
||||
// ImageRef, short-circuit. Saves a pull + churn on duplicate webhook
|
||||
// deliveries (Gitea retries on flaky 5xx, etc.).
|
||||
for _, c := range existing {
|
||||
if c.ImageRef == imageRef && c.State == "running" && c.ContainerID != "" {
|
||||
if running, err := deps.Docker.IsContainerRunning(ctx, c.ContainerID); err == nil && running {
|
||||
slog.Info("image source: deploy skipped — already running",
|
||||
"workload", w.ID, "image", imageRef, "trigger", intent.Reason)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
authConfig, err := buildRegistryAuth(deps, cfg.RegistryName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("image source: %w", err)
|
||||
}
|
||||
if err := deps.Docker.PullImage(ctx, cfg.Image, tag, authConfig); err != nil {
|
||||
slog.Warn("image source: pull failed", "image", imageRef, "error", err)
|
||||
return fmt.Errorf("image source: pull %s failed", imageRef)
|
||||
}
|
||||
|
||||
networkID, err := deps.Docker.EnsureNetwork(ctx, settings.Network)
|
||||
if err != nil {
|
||||
return fmt.Errorf("image source: ensure network: %w", err)
|
||||
}
|
||||
|
||||
// Unique-per-deploy name so the new container can run alongside the
|
||||
// old one. The suffix is monotonic ms; collisions are not a real
|
||||
// concern for human-driven or webhook-driven deploys.
|
||||
containerName := buildContainerName(w.Name, w.ID, tag, time.Now())
|
||||
|
||||
cc := docker.ContainerConfig{
|
||||
Name: containerName,
|
||||
Image: imageRef,
|
||||
Env: buildEnv(deps, w, cfg),
|
||||
ExposedPorts: []string{fmt.Sprintf("%d/tcp", cfg.Port)},
|
||||
NetworkName: settings.Network,
|
||||
NetworkID: networkID,
|
||||
WorkloadID: w.ID,
|
||||
WorkloadKind: "image",
|
||||
Role: "image",
|
||||
Mounts: computeMounts(deps, w, cfg, tag, settings),
|
||||
CpuLimit: cfg.CpuLimit,
|
||||
MemoryLimit: cfg.MemoryLimit,
|
||||
}
|
||||
|
||||
// Per-face proxy labels (Traefik picks these up; NPM ignores them).
|
||||
primary := primaryFace(w.PublicFaces)
|
||||
for _, face := range w.PublicFaces {
|
||||
if !faceEnabled(face) {
|
||||
continue
|
||||
}
|
||||
port := face.TargetPort
|
||||
if port == 0 {
|
||||
port = cfg.Port
|
||||
}
|
||||
fqdn := fqdnFor(face, settings.Domain)
|
||||
if labels := deps.Proxy.ContainerLabels(fqdn, port); labels != nil {
|
||||
if cc.Labels == nil {
|
||||
cc.Labels = map[string]string{}
|
||||
}
|
||||
for k, v := range labels {
|
||||
cc.Labels[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dockerID, err := deps.Docker.CreateContainer(ctx, cc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("image source: create container: %w", err)
|
||||
}
|
||||
|
||||
row := store.Container{
|
||||
WorkloadID: w.ID,
|
||||
WorkloadKind: "image",
|
||||
Role: "image",
|
||||
ContainerID: dockerID,
|
||||
ImageRef: imageRef,
|
||||
ImageTag: tag,
|
||||
Host: "local",
|
||||
State: "stopped",
|
||||
Port: cfg.Port,
|
||||
Subdomain: primary.Subdomain,
|
||||
}
|
||||
created, err := deps.Store.CreateContainer(row)
|
||||
if err != nil {
|
||||
_ = deps.Docker.RemoveContainer(ctx, dockerID, true)
|
||||
return fmt.Errorf("image source: persist container row: %w", err)
|
||||
}
|
||||
|
||||
// Cleanup helper: roll back only the NEW container we just created.
|
||||
// Old containers are left running so a failed deploy is non-disruptive.
|
||||
rollbackNew := func(reason string, src error) error {
|
||||
_ = deps.Docker.RemoveContainer(ctx, dockerID, true)
|
||||
if delErr := deps.Store.DeleteContainer(created.ID); delErr != nil && !errors.Is(delErr, store.ErrNotFound) {
|
||||
slog.Warn("image source: rollback delete row",
|
||||
"workload", w.ID, "row", created.ID, "stage", reason, "error", delErr)
|
||||
}
|
||||
return fmt.Errorf("image source: %s: %w", reason, src)
|
||||
}
|
||||
|
||||
if err := deps.Docker.StartContainer(ctx, dockerID); err != nil {
|
||||
return rollbackNew("start container", err)
|
||||
}
|
||||
if err := deps.Store.UpdateContainerState(created.ID, "running"); err != nil {
|
||||
slog.Warn("image source: update container state", "workload", w.ID, "error", err)
|
||||
}
|
||||
|
||||
// Optional in-network healthcheck. Failure rolls back the new
|
||||
// container; the old one keeps serving via its existing proxy face.
|
||||
if cfg.Healthcheck != "" && deps.Health != nil {
|
||||
healthURL := fmt.Sprintf("http://%s:%d%s", containerName, cfg.Port, cfg.Healthcheck)
|
||||
if err := deps.Health.Check(ctx, healthURL); err != nil {
|
||||
return rollbackNew(fmt.Sprintf("health check %s", healthURL), err)
|
||||
}
|
||||
}
|
||||
|
||||
// Switch each public face to the new container. ConfigureRoute is
|
||||
// upsert-style at the proxy provider, so the old route is replaced
|
||||
// in-place by FQDN — no traffic gap. Per-face route IDs are
|
||||
// collected and stored on the container row's extra_json so Teardown
|
||||
// can drop every route (not just the primary).
|
||||
faceRoutes := map[string]string{} // fqdn → routeID
|
||||
for i, face := range w.PublicFaces {
|
||||
if !faceEnabled(face) {
|
||||
continue
|
||||
}
|
||||
port := face.TargetPort
|
||||
if port == 0 {
|
||||
port = cfg.Port
|
||||
}
|
||||
fqdn := fqdnFor(face, settings.Domain)
|
||||
|
||||
forwardHost := containerName
|
||||
forwardPort := port
|
||||
if settings.NpmRemote && settings.ProxyProvider == "npm" {
|
||||
if settings.ServerIP == "" {
|
||||
return rollbackNew("configure proxy", fmt.Errorf("NPM remote mode requires settings.server_ip"))
|
||||
}
|
||||
forwardHost = settings.ServerIP
|
||||
hostPort, err := deps.Docker.InspectContainerPort(ctx, dockerID, fmt.Sprintf("%d/tcp", port))
|
||||
if err != nil {
|
||||
return rollbackNew("inspect host port", err)
|
||||
}
|
||||
forwardPort = int(hostPort)
|
||||
}
|
||||
|
||||
accessListID := settings.NpmAccessListID
|
||||
if face.AccessListID > 0 {
|
||||
accessListID = face.AccessListID
|
||||
}
|
||||
|
||||
routeID, err := deps.Proxy.ConfigureRoute(ctx, fqdn, forwardHost, forwardPort, proxy.RouteOptions{
|
||||
SSLCertificateID: settings.SSLCertificateID,
|
||||
AccessListID: accessListID,
|
||||
})
|
||||
if err != nil {
|
||||
// Roll back any face routes we've already configured this
|
||||
// deploy so a partial failure doesn't leak orphan rules at
|
||||
// the proxy provider.
|
||||
for prevFQDN, prevRouteID := range faceRoutes {
|
||||
_ = prevFQDN
|
||||
if dErr := deps.Proxy.DeleteRoute(ctx, prevRouteID); dErr != nil {
|
||||
slog.Warn("image source: rollback proxy route",
|
||||
"workload", w.ID, "route", prevRouteID, "error", dErr)
|
||||
}
|
||||
}
|
||||
return rollbackNew(fmt.Sprintf("configure proxy face[%d]", i), err)
|
||||
}
|
||||
faceRoutes[fqdn] = routeID
|
||||
|
||||
if i == 0 {
|
||||
created.ProxyRouteID = routeID
|
||||
created.Subdomain = face.Subdomain
|
||||
}
|
||||
|
||||
// Best-effort DNS. Skipped under wildcard DNS (deps.DNS == nil).
|
||||
if deps.DNS != nil && settings.PublicIP != "" {
|
||||
if _, err := deps.DNS.EnsureRecord(ctx, fqdn, settings.PublicIP); err != nil {
|
||||
slog.Warn("image source: ensure DNS", "fqdn", fqdn, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the per-face route map on the container row so Teardown
|
||||
// and the next blue-green redeploy can find every configured face.
|
||||
if len(faceRoutes) > 0 {
|
||||
extra := containerExtra{ProxyRoutes: faceRoutes}
|
||||
if b, err := json.Marshal(extra); err == nil {
|
||||
created.ExtraJSON = string(b)
|
||||
}
|
||||
}
|
||||
if err := deps.Store.UpdateContainer(created); err != nil {
|
||||
slog.Warn("image source: update container with routes", "workload", w.ID, "error", err)
|
||||
}
|
||||
|
||||
// Now the new container is live behind the proxy. Enforce
|
||||
// MaxInstances by removing oldest surplus rows (which includes the
|
||||
// pre-deploy "blue" container when MaxInstances=1).
|
||||
maxInstances := cfg.MaxInstances
|
||||
if maxInstances <= 0 {
|
||||
maxInstances = 1
|
||||
}
|
||||
enforceMaxInstances(ctx, deps, w, created.ID, maxInstances)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// enforceMaxInstances trims older containers down to `keep` total for this
|
||||
// workload, preserving the just-deployed row (justDeployedRowID) at the
|
||||
// top. Best-effort: failures are logged, not propagated — the new deploy
|
||||
// already succeeded and we don't want to roll it back because cleanup of
|
||||
// an old container hiccupped.
|
||||
func enforceMaxInstances(ctx context.Context, deps plugin.Deps, w plugin.Workload, justDeployedRowID string, keep int) {
|
||||
rows, err := deps.Store.ListContainersByWorkload(w.ID)
|
||||
if err != nil {
|
||||
slog.Warn("image source: list for max-instances", "workload", w.ID, "error", err)
|
||||
return
|
||||
}
|
||||
// Sort newest first by CreatedAt, with the just-deployed row pinned
|
||||
// at index 0 regardless of clock skew.
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if rows[i].ID == justDeployedRowID {
|
||||
return true
|
||||
}
|
||||
if rows[j].ID == justDeployedRowID {
|
||||
return false
|
||||
}
|
||||
return rows[i].CreatedAt > rows[j].CreatedAt
|
||||
})
|
||||
if len(rows) <= keep {
|
||||
return
|
||||
}
|
||||
for _, victim := range rows[keep:] {
|
||||
if victim.ID == justDeployedRowID {
|
||||
continue
|
||||
}
|
||||
if victim.ContainerID != "" {
|
||||
if err := deps.Docker.RemoveContainer(ctx, victim.ContainerID, true); err != nil {
|
||||
slog.Warn("image source: remove old container",
|
||||
"workload", w.ID, "container", victim.ContainerID, "error", err)
|
||||
}
|
||||
}
|
||||
// The proxy route was already replaced by ConfigureRoute earlier
|
||||
// (same FQDN, new target). The old route ID, if any, is still
|
||||
// valid in the proxy provider's DB but now points at a removed
|
||||
// container. Delete it to keep the proxy clean. Best-effort.
|
||||
if victim.ProxyRouteID != "" && victim.ProxyRouteID != findCurrentRouteID(rows, justDeployedRowID) {
|
||||
if err := deps.Proxy.DeleteRoute(ctx, victim.ProxyRouteID); err != nil {
|
||||
slog.Warn("image source: delete old proxy route",
|
||||
"workload", w.ID, "route", victim.ProxyRouteID, "error", err)
|
||||
}
|
||||
}
|
||||
if err := deps.Store.DeleteContainer(victim.ID); err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||
slog.Warn("image source: delete old container row",
|
||||
"workload", w.ID, "row", victim.ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// findCurrentRouteID returns the route ID stored on the just-deployed
|
||||
// row, so we don't accidentally delete the live face.
|
||||
func findCurrentRouteID(rows []store.Container, justDeployedRowID string) string {
|
||||
for _, r := range rows {
|
||||
if r.ID == justDeployedRowID {
|
||||
return r.ProxyRouteID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Teardown stops and removes every container, proxy route, and DNS
|
||||
// record owned by this workload. Idempotent. Reads extra_json off each
|
||||
// row so non-primary face routes are cleaned up too — without this a
|
||||
// multi-face workload would leak every face beyond the primary at
|
||||
// delete-time.
|
||||
func (*source) Teardown(ctx context.Context, deps plugin.Deps, w plugin.Workload) error {
|
||||
rows, err := deps.Store.ListContainersByWorkload(w.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("image source: list containers: %w", err)
|
||||
}
|
||||
settings, _ := deps.Store.GetSettings()
|
||||
|
||||
for _, c := range rows {
|
||||
if c.ContainerID != "" {
|
||||
if err := deps.Docker.RemoveContainer(ctx, c.ContainerID, true); err != nil {
|
||||
slog.Warn("image source: remove docker container", "workload", w.ID, "container", c.ContainerID, "error", err)
|
||||
}
|
||||
}
|
||||
// Collect every route to delete: the primary (c.ProxyRouteID)
|
||||
// plus any extras stashed under extra_json.proxy_routes. Dedup
|
||||
// because the primary is also re-listed in the extras map.
|
||||
toDelete := map[string]string{} // fqdn → routeID
|
||||
if c.ProxyRouteID != "" {
|
||||
toDelete[c.Subdomain] = c.ProxyRouteID // key is opaque; we only iterate values
|
||||
}
|
||||
if c.ExtraJSON != "" && c.ExtraJSON != "{}" {
|
||||
var ex containerExtra
|
||||
if jErr := json.Unmarshal([]byte(c.ExtraJSON), &ex); jErr == nil {
|
||||
for fqdn, rid := range ex.ProxyRoutes {
|
||||
toDelete[fqdn] = rid
|
||||
}
|
||||
}
|
||||
}
|
||||
seenRoute := map[string]struct{}{}
|
||||
for _, rid := range toDelete {
|
||||
if _, dup := seenRoute[rid]; dup {
|
||||
continue
|
||||
}
|
||||
seenRoute[rid] = struct{}{}
|
||||
if err := deps.Proxy.DeleteRoute(ctx, rid); err != nil {
|
||||
slog.Warn("image source: delete proxy route",
|
||||
"workload", w.ID, "route", rid, "error", err)
|
||||
}
|
||||
}
|
||||
if deps.DNS != nil && c.Subdomain != "" && settings.Domain != "" {
|
||||
fqdn := c.Subdomain + "." + settings.Domain
|
||||
if err := deps.DNS.DeleteRecord(ctx, fqdn); err != nil {
|
||||
slog.Warn("image source: delete DNS", "fqdn", fqdn, "error", err)
|
||||
}
|
||||
}
|
||||
if err := deps.Store.DeleteContainer(c.ID); err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||
slog.Warn("image source: delete container row", "id", c.ID, "error", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// containerExtra is the shape stored under container.extra_json by the
|
||||
// image source. Kept versionless on purpose — additive only, unknown
|
||||
// keys must be ignored by older deployers reading rows written by newer
|
||||
// ones.
|
||||
type containerExtra struct {
|
||||
ProxyRoutes map[string]string `json:"proxy_routes,omitempty"`
|
||||
}
|
||||
|
||||
// Reconcile syncs the containers index for this workload with reality.
|
||||
// MVP: just refreshes State from Docker. Future versions can re-deploy
|
||||
// when the running container disagrees with the desired source config.
|
||||
func (*source) Reconcile(ctx context.Context, deps plugin.Deps, w plugin.Workload) error {
|
||||
rows, err := deps.Store.ListContainersByWorkload(w.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("image source: list containers: %w", err)
|
||||
}
|
||||
for _, c := range rows {
|
||||
if c.ContainerID == "" {
|
||||
continue
|
||||
}
|
||||
running, err := deps.Docker.IsContainerRunning(ctx, c.ContainerID)
|
||||
if err != nil {
|
||||
// Most likely "no such container" — mark as missing so the UI
|
||||
// surfaces it and the next deploy recreates.
|
||||
if err := deps.Store.UpdateContainerState(c.ID, "missing"); err != nil {
|
||||
slog.Warn("image source: mark missing", "id", c.ID, "error", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
desired := "running"
|
||||
if !running {
|
||||
desired = "stopped"
|
||||
}
|
||||
if c.State != desired {
|
||||
if err := deps.Store.UpdateContainerState(c.ID, desired); err != nil {
|
||||
slog.Warn("image source: state sync", "id", c.ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildRegistryAuth returns a Docker registry auth string for the named
|
||||
// registry, or "" when no auth is configured. Username is taken from
|
||||
// reg.Owner when present; falls back to the token for registries that
|
||||
// accept token-as-username (Docker Hub PATs, GHCR, etc.).
|
||||
func buildRegistryAuth(deps plugin.Deps, registryName string) (string, error) {
|
||||
if registryName == "" {
|
||||
return "", nil
|
||||
}
|
||||
reg, err := deps.Store.GetRegistryByName(registryName)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get registry %s: %w", registryName, err)
|
||||
}
|
||||
if reg.Token == "" {
|
||||
return "", nil
|
||||
}
|
||||
token, err := crypto.Decrypt(deps.EncKey, reg.Token)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decrypt registry token: %w", err)
|
||||
}
|
||||
username := reg.Owner
|
||||
if username == "" {
|
||||
username = token
|
||||
}
|
||||
return docker.EncodeRegistryAuth(username, token, reg.URL)
|
||||
}
|
||||
|
||||
// buildEnv flattens cfg.Env plus the workload_env overrides into the
|
||||
// KEY=VALUE list Docker expects. workload_env wins on key conflict and
|
||||
// encrypted rows are decrypted lazily so plaintext never lives in the
|
||||
// store output. If a decrypt fails the value is skipped with a warning —
|
||||
// failing the whole deploy because one rotated key bricked one env entry
|
||||
// would be a worse outcome than the missing variable.
|
||||
func buildEnv(deps plugin.Deps, w plugin.Workload, cfg Config) []string {
|
||||
merged := make(map[string]string, len(cfg.Env))
|
||||
for k, v := range cfg.Env {
|
||||
merged[k] = v
|
||||
}
|
||||
overrides, err := deps.Store.ListWorkloadEnv(w.ID)
|
||||
if err != nil {
|
||||
slog.Warn("image source: list workload env", "workload", w.ID, "error", err)
|
||||
} else {
|
||||
for _, e := range overrides {
|
||||
value := e.Value
|
||||
if e.Encrypted {
|
||||
decrypted, err := crypto.Decrypt(deps.EncKey, e.Value)
|
||||
if err != nil {
|
||||
slog.Warn("image source: decrypt env value",
|
||||
"workload", w.ID, "key", e.Key, "error", err)
|
||||
continue
|
||||
}
|
||||
value = decrypted
|
||||
}
|
||||
merged[e.Key] = value
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(merged))
|
||||
for k, v := range merged {
|
||||
out = append(out, k+"="+v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// computeMounts resolves a workload's VolumeMounts into mount.Mount
|
||||
// values. Both inline `cfg.Volumes` and persisted `workload_volumes` are
|
||||
// considered — persisted rows win on target conflict so the operator's
|
||||
// last UI-side edit takes precedence over whatever shipped with the
|
||||
// config blob.
|
||||
//
|
||||
// All VolumeScope values are honored:
|
||||
//
|
||||
// - absolute → host bind (validated against settings.AllowedVolumePaths)
|
||||
// - ephemeral → tmpfs (no host path)
|
||||
// - instance → per-tag dir under <workload>/instance-<tag>/<source>
|
||||
// - stage → shared per-workload dir (alias of project)
|
||||
// - project → shared per-workload dir
|
||||
// - project_named → workload-scoped Docker named volume
|
||||
// - named → globally-scoped Docker named volume
|
||||
//
|
||||
// Volumes with empty target or unresolvable scope are skipped with a
|
||||
// warning rather than failing the whole deploy — a misconfigured volume
|
||||
// should not brick an otherwise-valid CI push.
|
||||
func computeMounts(deps plugin.Deps, w plugin.Workload, cfg Config, imageTag string, settings store.Settings) []mount.Mount {
|
||||
byTarget := map[string]VolumeMount{}
|
||||
for _, v := range cfg.Volumes {
|
||||
if v.Target == "" {
|
||||
continue
|
||||
}
|
||||
byTarget[v.Target] = v
|
||||
}
|
||||
if persisted, err := deps.Store.ListWorkloadVolumes(w.ID); err == nil {
|
||||
for _, p := range persisted {
|
||||
byTarget[p.Target] = VolumeMount{
|
||||
Source: p.Source,
|
||||
Target: p.Target,
|
||||
Scope: p.Scope,
|
||||
Name: p.Name,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
slog.Warn("image source: list workload volumes", "workload", w.ID, "error", err)
|
||||
}
|
||||
|
||||
params := volume.ResolveWorkloadParams{
|
||||
BasePath: settings.BaseVolumePath,
|
||||
WorkloadID: w.ID,
|
||||
WorkloadName: w.Name,
|
||||
ImageTag: imageTag,
|
||||
AllowedVolumePaths: settings.AllowedVolumePaths,
|
||||
}
|
||||
|
||||
out := make([]mount.Mount, 0, len(byTarget))
|
||||
for _, v := range byTarget {
|
||||
if v.Target == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
switch v.Scope {
|
||||
case string(store.VolumeScopeEphemeral):
|
||||
out = append(out, mount.Mount{Type: mount.TypeTmpfs, Target: v.Target})
|
||||
continue
|
||||
case string(store.VolumeScopeNamed), string(store.VolumeScopeProjectNamed):
|
||||
// Docker named volumes use the volume name as Source. We
|
||||
// scope project_named entries to the workload by prefixing
|
||||
// the name so two workloads can both claim "data" without
|
||||
// sharing storage.
|
||||
name := v.Name
|
||||
if name == "" {
|
||||
slog.Warn("image source: named volume missing name",
|
||||
"workload", w.ID, "target", v.Target)
|
||||
continue
|
||||
}
|
||||
if v.Scope == string(store.VolumeScopeProjectNamed) {
|
||||
name = workloadNamedVolume(w, name)
|
||||
}
|
||||
out = append(out, mount.Mount{Type: mount.TypeVolume, Source: name, Target: v.Target})
|
||||
continue
|
||||
}
|
||||
|
||||
// Everything else resolves to a host path (absolute, instance,
|
||||
// stage, project). Empty source on absolute is invalid; for the
|
||||
// others "source" is the per-scope subdirectory.
|
||||
wv := store.WorkloadVolume{
|
||||
Source: v.Source,
|
||||
Target: v.Target,
|
||||
Scope: v.Scope,
|
||||
Name: v.Name,
|
||||
}
|
||||
path, err := volume.ResolveWorkloadPath(wv, params)
|
||||
if err != nil {
|
||||
slog.Warn("image source: resolve volume",
|
||||
"workload", w.ID, "target", v.Target, "scope", v.Scope, "error", err)
|
||||
continue
|
||||
}
|
||||
out = append(out, mount.Mount{Type: mount.TypeBind, Source: path, Target: v.Target})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// workloadNamedVolume builds the Docker volume name for a project_named
|
||||
// mount. The "tf-" prefix and short-id suffix keep volumes from one
|
||||
// workload separate from another's, even when they share a logical
|
||||
// volume name.
|
||||
func workloadNamedVolume(w plugin.Workload, name string) string {
|
||||
idShort := w.ID
|
||||
if len(idShort) > 8 {
|
||||
idShort = idShort[:8]
|
||||
}
|
||||
clean := strings.Trim(nameSanitizer.ReplaceAllString(name, "-"), "-")
|
||||
return "tf-" + idShort + "-" + clean
|
||||
}
|
||||
|
||||
// buildContainerName generates a deterministic container name keyed on
|
||||
// workload + tag. The scheme intentionally diverges from the legacy
|
||||
// "dw-{project}-{stage}-{tag}" scheme so plugin-managed containers are
|
||||
// trivially distinguishable in `docker ps`.
|
||||
var nameSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_.-]`)
|
||||
|
||||
func buildContainerName(workloadName, workloadID, tag string, ts time.Time) string {
|
||||
clean := func(s string) string {
|
||||
return strings.Trim(nameSanitizer.ReplaceAllString(s, "-"), "-")
|
||||
}
|
||||
idShort := workloadID
|
||||
if len(idShort) > 8 {
|
||||
idShort = idShort[:8]
|
||||
}
|
||||
// Suffix is a millisecond-resolution monotonic stamp so two deploys
|
||||
// can never collide on container name (blue-green needs the new
|
||||
// container to start while the old one is still bound to the same
|
||||
// "tf-name-id-tag" prefix).
|
||||
suffix := fmt.Sprintf("%x", ts.UnixMilli())
|
||||
return fmt.Sprintf("tf-%s-%s-%s-%s", clean(workloadName), idShort, clean(tag), suffix)
|
||||
}
|
||||
|
||||
// faceEnabled is true for any face that should yield a proxy route. A
|
||||
// face with empty subdomain AND empty domain is treated as disabled.
|
||||
func faceEnabled(f plugin.PublicFace) bool {
|
||||
return f.Subdomain != "" || f.Domain != ""
|
||||
}
|
||||
|
||||
func fqdnFor(f plugin.PublicFace, defaultDomain string) string {
|
||||
domain := f.Domain
|
||||
if domain == "" {
|
||||
domain = defaultDomain
|
||||
}
|
||||
if f.Subdomain == "" {
|
||||
return domain
|
||||
}
|
||||
return f.Subdomain + "." + domain
|
||||
}
|
||||
|
||||
func primaryFace(faces []plugin.PublicFace) plugin.PublicFace {
|
||||
for _, f := range faces {
|
||||
if faceEnabled(f) {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return plugin.PublicFace{}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
)
|
||||
|
||||
func TestBuildContainerName(t *testing.T) {
|
||||
ts := time.Unix(1700000000, 0)
|
||||
name := buildContainerName("My App", "abcd1234-5678-1234-abcd-deadbeef0000", "v1.2.3", ts)
|
||||
|
||||
if !strings.HasPrefix(name, "tf-My-App-abcd1234-v1.2.3-") {
|
||||
t.Errorf("name=%q lost expected prefix", name)
|
||||
}
|
||||
if strings.Contains(name, " ") {
|
||||
t.Errorf("name=%q contains space — sanitizer regressed", name)
|
||||
}
|
||||
if strings.Contains(name, "/") {
|
||||
t.Errorf("name=%q contains slash — sanitizer regressed", name)
|
||||
}
|
||||
// Suffix is monotonic ms hex — two adjacent timestamps must produce
|
||||
// different names so blue-green can run two containers side-by-side.
|
||||
other := buildContainerName("My App", "abcd1234-5678-1234-abcd-deadbeef0000", "v1.2.3", ts.Add(time.Millisecond))
|
||||
if other == name {
|
||||
t.Errorf("expected distinct names across timestamps, got %q twice", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildContainerNameShortID(t *testing.T) {
|
||||
// Workload IDs shorter than 8 chars must not panic on slicing.
|
||||
name := buildContainerName("app", "ab", "tag", time.Unix(1700000000, 0))
|
||||
if !strings.HasPrefix(name, "tf-app-ab-tag-") {
|
||||
t.Errorf("unexpected short-ID name: %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFaceEnabled(t *testing.T) {
|
||||
cases := []struct {
|
||||
face plugin.PublicFace
|
||||
want bool
|
||||
}{
|
||||
{plugin.PublicFace{}, false},
|
||||
{plugin.PublicFace{Subdomain: "api"}, true},
|
||||
{plugin.PublicFace{Domain: "example.com"}, true},
|
||||
{plugin.PublicFace{Subdomain: "api", Domain: "example.com"}, true},
|
||||
}
|
||||
for i, tc := range cases {
|
||||
if got := faceEnabled(tc.face); got != tc.want {
|
||||
t.Errorf("case %d face=%+v: got %v want %v", i, tc.face, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFqdnFor(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
face plugin.PublicFace
|
||||
defDom string
|
||||
want string
|
||||
}{
|
||||
{"subdomain + face domain", plugin.PublicFace{Subdomain: "api", Domain: "example.com"}, "default.io", "api.example.com"},
|
||||
{"subdomain inherits default", plugin.PublicFace{Subdomain: "api"}, "default.io", "api.default.io"},
|
||||
{"root domain only", plugin.PublicFace{Domain: "example.com"}, "default.io", "example.com"},
|
||||
{"root of default", plugin.PublicFace{}, "default.io", "default.io"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := fqdnFor(tc.face, tc.defDom); got != tc.want {
|
||||
t.Errorf("fqdnFor: got %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrimaryFace(t *testing.T) {
|
||||
t.Run("returns first enabled", func(t *testing.T) {
|
||||
faces := []plugin.PublicFace{
|
||||
{}, // disabled
|
||||
{Subdomain: "api"}, // first enabled
|
||||
{Domain: "second.example.com"},
|
||||
}
|
||||
got := primaryFace(faces)
|
||||
if got.Subdomain != "api" {
|
||||
t.Errorf("expected first enabled, got %+v", got)
|
||||
}
|
||||
})
|
||||
t.Run("empty when none enabled", func(t *testing.T) {
|
||||
got := primaryFace([]plugin.PublicFace{{}, {}})
|
||||
if got.Subdomain != "" || got.Domain != "" {
|
||||
t.Errorf("expected zero face, got %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
src := &source{}
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty rejected", "", true},
|
||||
{"missing image rejected", `{"port":8080}`, true},
|
||||
{"valid minimal", `{"image":"owner/app","port":8080}`, false},
|
||||
{"port out of range", `{"image":"x","port":99999}`, true},
|
||||
{"volume missing target rejected", `{"image":"x","volumes":[{"source":"/a","scope":"absolute"}]}`, true},
|
||||
{"volume missing scope rejected", `{"image":"x","volumes":[{"source":"/a","target":"/b"}]}`, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := src.Validate([]byte(tc.body))
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("Validate(%q) err=%v want err=%v", tc.body, err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// Package static implements the "static" source: a git-folder-backed
|
||||
// deployable that can serve plain files or run a Deno backend. Builds an
|
||||
// image from the cloned folder and runs one container.
|
||||
//
|
||||
// The full deploy pipeline lives in internal/staticsite (git providers,
|
||||
// markdown rendering, Dockerfile codegen, Deno scaffolding, image build,
|
||||
// proxy registration) and is wired in via a function variable so that
|
||||
// neither this package nor staticsite has to depend on the other.
|
||||
//
|
||||
// cmd/server/main.go (or any caller with access to both packages)
|
||||
// populates DeployFn / TeardownFn / ReconcileFn at startup; until then,
|
||||
// Source methods return an explicit error so misconfiguration surfaces
|
||||
// loudly instead of silently failing.
|
||||
package static
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
)
|
||||
|
||||
// Config is the per-workload source config blob. Mirrors the fields that
|
||||
// used to live on the static_sites table, less anything moved to Workload
|
||||
// (notification config, webhook secrets, public_face).
|
||||
type Config struct {
|
||||
Provider string `json:"provider"` // "gitea" | "github" | "gitlab"; "" = autodetect
|
||||
BaseURL string `json:"base_url"` // e.g. https://git.example.com
|
||||
RepoOwner string `json:"repo_owner"`
|
||||
RepoName string `json:"repo_name"`
|
||||
Branch string `json:"branch"`
|
||||
FolderPath string `json:"folder_path"` // path within repo
|
||||
AccessToken string `json:"access_token"` // encrypted; optional for public repos
|
||||
Mode string `json:"mode"` // "static" | "deno"
|
||||
RenderMarkdown bool `json:"render_markdown"`
|
||||
StorageEnabled bool `json:"storage_enabled"`
|
||||
StorageLimitMB int `json:"storage_limit_mb"`
|
||||
}
|
||||
|
||||
// Backend captures the deploy lifecycle of a static site. main.go wires
|
||||
// an implementation that adapts internal/staticsite.Manager to this
|
||||
// interface; the plugin contract sees only this shape so it stays
|
||||
// independent of any specific manager type.
|
||||
type Backend interface {
|
||||
Deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plugin.DeploymentIntent) error
|
||||
Teardown(ctx context.Context, deps plugin.Deps, w plugin.Workload) error
|
||||
Reconcile(ctx context.Context, deps plugin.Deps, w plugin.Workload) error
|
||||
}
|
||||
|
||||
var (
|
||||
backendMu sync.RWMutex
|
||||
backend Backend
|
||||
backendSet atomic.Bool
|
||||
)
|
||||
|
||||
// SetBackend wires the staticsite-package adapter into this Source AND
|
||||
// registers the source with the plugin registry. MUST be called exactly
|
||||
// once from cmd/server/main.go before any plugin invocation. Subsequent
|
||||
// calls panic — a swapped backend at runtime is a trust-boundary
|
||||
// inversion (a future plugin loaded via blank import could replace
|
||||
// deploy/teardown logic that handles git tokens).
|
||||
func SetBackend(b Backend) {
|
||||
if !backendSet.CompareAndSwap(false, true) {
|
||||
panic("static: backend already wired (SetBackend may be called once)")
|
||||
}
|
||||
backendMu.Lock()
|
||||
backend = b
|
||||
backendMu.Unlock()
|
||||
plugin.RegisterSource(&source{})
|
||||
}
|
||||
|
||||
func currentBackend() (Backend, error) {
|
||||
backendMu.RLock()
|
||||
defer backendMu.RUnlock()
|
||||
if backend == nil {
|
||||
return nil, fmt.Errorf("static source: backend not wired; call static.SetBackend from main.go")
|
||||
}
|
||||
return backend, nil
|
||||
}
|
||||
|
||||
type source struct{}
|
||||
|
||||
// Static source registers itself only after SetBackend is called from
|
||||
// main.go. Eager init() registration would advertise "static" via
|
||||
// /api/hooks/kinds before there is anything to dispatch to — frontends
|
||||
// would render it in pickers and operators would hit "backend not wired"
|
||||
// at deploy time. Lazy registration keeps the kind invisible until it's
|
||||
// actually usable.
|
||||
|
||||
func (*source) Kind() string { return "static" }
|
||||
|
||||
func (*source) SchemaSample() any {
|
||||
return Config{
|
||||
Provider: "gitea",
|
||||
BaseURL: "https://git.example.com",
|
||||
RepoOwner: "owner",
|
||||
RepoName: "pages",
|
||||
Branch: "main",
|
||||
FolderPath: "",
|
||||
Mode: "static",
|
||||
}
|
||||
}
|
||||
|
||||
func (*source) Validate(cfg json.RawMessage) error {
|
||||
var c Config
|
||||
if len(cfg) == 0 {
|
||||
return fmt.Errorf("static source: config is required")
|
||||
}
|
||||
if err := json.Unmarshal(cfg, &c); err != nil {
|
||||
return fmt.Errorf("static source: invalid json: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(c.RepoOwner) == "" || strings.TrimSpace(c.RepoName) == "" {
|
||||
return fmt.Errorf("static source: repo_owner and repo_name are required")
|
||||
}
|
||||
if c.Mode != "" && c.Mode != "static" && c.Mode != "deno" {
|
||||
return fmt.Errorf("static source: mode must be \"static\" or \"deno\"")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*source) Deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plugin.DeploymentIntent) error {
|
||||
b, err := currentBackend()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Deploy(ctx, deps, w, intent)
|
||||
}
|
||||
|
||||
func (*source) Teardown(ctx context.Context, deps plugin.Deps, w plugin.Workload) error {
|
||||
b, err := currentBackend()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Teardown(ctx, deps, w)
|
||||
}
|
||||
|
||||
func (*source) Reconcile(ctx context.Context, deps plugin.Deps, w plugin.Workload) error {
|
||||
b, err := currentBackend()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Reconcile(ctx, deps, w)
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -0,0 +1,123 @@
|
||||
// Package git implements the "git" trigger: matches inbound git push or
|
||||
// tag-create events from Gitea, GitHub, or GitLab against a repo + ref
|
||||
// filter.
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
)
|
||||
|
||||
// Config is the per-workload trigger config. Repo is "owner/name" (must
|
||||
// match the event repo). Mode controls whether branch pushes or tag
|
||||
// pushes fire the deploy. Branch is exact-matched when Mode=="push";
|
||||
// TagPattern is glob-matched when Mode=="tag".
|
||||
type Config struct {
|
||||
Repo string `json:"repo"`
|
||||
Mode string `json:"mode"` // "push" | "tag"
|
||||
Branch string `json:"branch"`
|
||||
TagPattern string `json:"tag_pattern"`
|
||||
}
|
||||
|
||||
type trigger struct{}
|
||||
|
||||
func init() { plugin.RegisterTrigger(&trigger{}) }
|
||||
|
||||
func (*trigger) Kind() string { return "git" }
|
||||
|
||||
func (*trigger) SchemaSample() any {
|
||||
return Config{
|
||||
Repo: "owner/repo",
|
||||
Mode: "push",
|
||||
Branch: "main",
|
||||
}
|
||||
}
|
||||
|
||||
func (*trigger) Validate(cfg json.RawMessage) error {
|
||||
var c Config
|
||||
if len(cfg) == 0 {
|
||||
return fmt.Errorf("git trigger: config is required")
|
||||
}
|
||||
if err := json.Unmarshal(cfg, &c); err != nil {
|
||||
return fmt.Errorf("git trigger: invalid json: %w", err)
|
||||
}
|
||||
switch c.Mode {
|
||||
case "push":
|
||||
// Branch is optional ("" means any branch).
|
||||
case "tag":
|
||||
pattern := c.TagPattern
|
||||
if pattern == "" {
|
||||
pattern = "*"
|
||||
}
|
||||
if _, err := path.Match(pattern, "probe"); err != nil {
|
||||
return fmt.Errorf("git trigger: invalid tag_pattern %q: %w", pattern, err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("git trigger: mode must be \"push\" or \"tag\"")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*trigger) Match(ctx context.Context, deps plugin.Deps, w plugin.Workload, evt plugin.InboundEvent) (*plugin.DeploymentIntent, error) {
|
||||
if evt.Git == nil {
|
||||
return nil, nil
|
||||
}
|
||||
cfg, err := plugin.TriggerConfigOf[Config](w)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("git trigger: decode config: %w", err)
|
||||
}
|
||||
if cfg.Repo != "" && !strings.EqualFold(cfg.Repo, evt.Git.Repo) {
|
||||
return nil, nil
|
||||
}
|
||||
if !refMatches(cfg, evt.Git.Ref) {
|
||||
return nil, nil
|
||||
}
|
||||
meta := map[string]string{
|
||||
"repo": evt.Git.Repo,
|
||||
"vendor": evt.Git.Vendor,
|
||||
"ref": evt.Git.Ref,
|
||||
"pusher": evt.Git.Pusher,
|
||||
}
|
||||
if evt.Git.Branch != "" {
|
||||
meta["branch"] = evt.Git.Branch
|
||||
}
|
||||
if evt.Git.Tag != "" {
|
||||
meta["tag"] = evt.Git.Tag
|
||||
}
|
||||
return &plugin.DeploymentIntent{
|
||||
Reason: "git-push",
|
||||
Reference: evt.Git.CommitSHA,
|
||||
Metadata: meta,
|
||||
TriggeredAt: time.Now().UTC(),
|
||||
TriggeredBy: "git-webhook",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func refMatches(cfg Config, ref string) bool {
|
||||
switch cfg.Mode {
|
||||
case "push":
|
||||
branch, ok := strings.CutPrefix(ref, "refs/heads/")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return cfg.Branch == "" || cfg.Branch == branch
|
||||
case "tag":
|
||||
tag, ok := strings.CutPrefix(ref, "refs/tags/")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
pattern := cfg.TagPattern
|
||||
if pattern == "" {
|
||||
pattern = "*"
|
||||
}
|
||||
matched, err := path.Match(pattern, tag)
|
||||
return err == nil && matched
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
)
|
||||
|
||||
func mustConfig(t *testing.T, c Config) json.RawMessage {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal config: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
tr := &trigger{}
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg json.RawMessage
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty body rejected", nil, true},
|
||||
{"missing mode rejected", mustConfig(t, Config{Repo: "owner/repo"}), true},
|
||||
{"push mode valid", mustConfig(t, Config{Repo: "owner/repo", Mode: "push", Branch: "main"}), false},
|
||||
{"push mode without branch (any-branch)", mustConfig(t, Config{Repo: "owner/repo", Mode: "push"}), false},
|
||||
{"tag mode valid", mustConfig(t, Config{Repo: "owner/repo", Mode: "tag", TagPattern: "v*"}), false},
|
||||
{"tag mode no pattern (wildcard fallback)", mustConfig(t, Config{Repo: "owner/repo", Mode: "tag"}), false},
|
||||
{"tag mode bad glob", mustConfig(t, Config{Repo: "owner/repo", Mode: "tag", TagPattern: "v[oops"}), true},
|
||||
{"unknown mode", mustConfig(t, Config{Repo: "owner/repo", Mode: "merge"}), true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tr.Validate(tc.cfg)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("Validate(%s) err=%v want err=%v", tc.name, err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefMatches(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg Config
|
||||
ref string
|
||||
want bool
|
||||
}{
|
||||
{"push main matches", Config{Mode: "push", Branch: "main"}, "refs/heads/main", true},
|
||||
{"push main rejects other branch", Config{Mode: "push", Branch: "main"}, "refs/heads/dev", false},
|
||||
{"push tag is rejected in push mode", Config{Mode: "push", Branch: "main"}, "refs/tags/v1.0.0", false},
|
||||
{"push any-branch", Config{Mode: "push"}, "refs/heads/whatever", true},
|
||||
{"tag mode v* matches v1.2.3", Config{Mode: "tag", TagPattern: "v*"}, "refs/tags/v1.2.3", true},
|
||||
{"tag mode v* rejects latest", Config{Mode: "tag", TagPattern: "v*"}, "refs/tags/latest", false},
|
||||
{"tag mode rejects heads ref", Config{Mode: "tag", TagPattern: "v*"}, "refs/heads/main", false},
|
||||
{"tag mode empty pattern matches any tag", Config{Mode: "tag"}, "refs/tags/whatever", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := refMatches(tc.cfg, tc.ref); got != tc.want {
|
||||
t.Errorf("refMatches(%+v, %q) = %v, want %v", tc.cfg, tc.ref, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatch(t *testing.T) {
|
||||
tr := &trigger{}
|
||||
wl := plugin.Workload{
|
||||
ID: "wkl-1",
|
||||
TriggerConfig: mustConfig(t, Config{Repo: "Owner/Repo", Mode: "push", Branch: "main"}),
|
||||
}
|
||||
|
||||
t.Run("wrong event kind", func(t *testing.T) {
|
||||
evt := plugin.InboundEvent{Kind: "image-push"}
|
||||
intent, err := tr.Match(context.Background(), plugin.Deps{}, wl, evt)
|
||||
if err != nil || intent != nil {
|
||||
t.Fatalf("expected nil intent, got intent=%v err=%v", intent, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("matching push fires intent with sha", func(t *testing.T) {
|
||||
// Branch is populated by the webhook ingress alongside Ref; the
|
||||
// trigger reads either independently. Set both here to mirror the
|
||||
// real wire shape.
|
||||
evt := plugin.InboundEvent{
|
||||
Kind: "git-push",
|
||||
Git: &plugin.GitEvent{
|
||||
Repo: "owner/repo",
|
||||
Ref: "refs/heads/main",
|
||||
Branch: "main",
|
||||
CommitSHA: "deadbeef",
|
||||
Pusher: "alice",
|
||||
},
|
||||
}
|
||||
intent, err := tr.Match(context.Background(), plugin.Deps{}, wl, evt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if intent == nil {
|
||||
t.Fatal("expected non-nil intent")
|
||||
}
|
||||
if intent.Reference != "deadbeef" {
|
||||
t.Errorf("intent.Reference = %q, want deadbeef", intent.Reference)
|
||||
}
|
||||
if intent.Reason != "git-push" {
|
||||
t.Errorf("intent.Reason = %q, want git-push", intent.Reason)
|
||||
}
|
||||
if intent.Metadata["branch"] != "main" {
|
||||
t.Errorf("expected branch=main in metadata, got %q", intent.Metadata["branch"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("repo case-insensitive comparison", func(t *testing.T) {
|
||||
evt := plugin.InboundEvent{
|
||||
Kind: "git-push",
|
||||
Git: &plugin.GitEvent{Repo: "OWNER/REPO", Ref: "refs/heads/main"},
|
||||
}
|
||||
intent, err := tr.Match(context.Background(), plugin.Deps{}, wl, evt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if intent == nil {
|
||||
t.Fatal("expected case-insensitive repo match")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wrong repo returns nil", func(t *testing.T) {
|
||||
evt := plugin.InboundEvent{
|
||||
Kind: "git-push",
|
||||
Git: &plugin.GitEvent{Repo: "other/repo", Ref: "refs/heads/main"},
|
||||
}
|
||||
intent, err := tr.Match(context.Background(), plugin.Deps{}, wl, evt)
|
||||
if err != nil || intent != nil {
|
||||
t.Fatalf("expected nil intent, got intent=%v err=%v", intent, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Package manual implements the "manual" trigger: any ManualEvent fires a
|
||||
// deploy. No per-workload config — the trigger always matches its kind.
|
||||
package manual
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
)
|
||||
|
||||
type trigger struct{}
|
||||
|
||||
func init() { plugin.RegisterTrigger(&trigger{}) }
|
||||
|
||||
func (*trigger) Kind() string { return "manual" }
|
||||
|
||||
func (*trigger) SchemaSample() any { return struct{}{} }
|
||||
|
||||
func (*trigger) Validate(cfg json.RawMessage) error {
|
||||
// Manual triggers have no config; accept empty or a small valid JSON
|
||||
// blob. The cap prevents an admin from pinning a 1 MiB blob to a
|
||||
// trigger row that gets serialized on every read.
|
||||
if len(cfg) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(cfg) > 1024 {
|
||||
return fmt.Errorf("manual trigger: config must be empty or a small JSON value (got %d bytes)", len(cfg))
|
||||
}
|
||||
if !json.Valid(cfg) {
|
||||
return fmt.Errorf("manual trigger: invalid json")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*trigger) Match(ctx context.Context, deps plugin.Deps, w plugin.Workload, evt plugin.InboundEvent) (*plugin.DeploymentIntent, error) {
|
||||
if evt.Kind != "manual" || evt.Manual == nil {
|
||||
return nil, nil
|
||||
}
|
||||
actor := evt.Manual.Actor
|
||||
if actor == "" {
|
||||
actor = "manual"
|
||||
}
|
||||
meta := map[string]string{}
|
||||
if evt.Manual.Note != "" {
|
||||
meta["note"] = evt.Manual.Note
|
||||
}
|
||||
return &plugin.DeploymentIntent{
|
||||
Reason: "manual",
|
||||
Reference: evt.Manual.Reference,
|
||||
Metadata: meta,
|
||||
TriggeredAt: time.Now().UTC(),
|
||||
TriggeredBy: actor,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package manual
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
)
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
tr := &trigger{}
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg json.RawMessage
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty body accepted", nil, false},
|
||||
{"empty object accepted", json.RawMessage(`{}`), false},
|
||||
{"valid small object accepted", json.RawMessage(`{"note":"hello"}`), false},
|
||||
{"invalid json rejected", json.RawMessage(`not json`), true},
|
||||
{"oversize rejected", json.RawMessage(`{"big":"` + strings.Repeat("x", 1100) + `"}`), true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tr.Validate(tc.cfg)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("Validate(%s) err=%v want err=%v", tc.name, err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatch(t *testing.T) {
|
||||
tr := &trigger{}
|
||||
wl := plugin.Workload{ID: "wkl-1"}
|
||||
|
||||
t.Run("wrong kind ignored", func(t *testing.T) {
|
||||
evt := plugin.InboundEvent{Kind: "image-push"}
|
||||
intent, err := tr.Match(context.Background(), plugin.Deps{}, wl, evt)
|
||||
if err != nil || intent != nil {
|
||||
t.Fatalf("expected nil intent, got intent=%v err=%v", intent, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("manual fires with actor + note", func(t *testing.T) {
|
||||
evt := plugin.InboundEvent{
|
||||
Kind: "manual",
|
||||
Manual: &plugin.ManualEvent{Actor: "alice", Reference: "v1.0.0", Note: "rollback"},
|
||||
}
|
||||
intent, err := tr.Match(context.Background(), plugin.Deps{}, wl, evt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if intent == nil {
|
||||
t.Fatal("expected non-nil intent")
|
||||
}
|
||||
if intent.TriggeredBy != "alice" {
|
||||
t.Errorf("TriggeredBy = %q, want alice", intent.TriggeredBy)
|
||||
}
|
||||
if intent.Reference != "v1.0.0" {
|
||||
t.Errorf("Reference = %q, want v1.0.0", intent.Reference)
|
||||
}
|
||||
if intent.Metadata["note"] != "rollback" {
|
||||
t.Errorf("note metadata = %q, want rollback", intent.Metadata["note"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing actor falls back", func(t *testing.T) {
|
||||
evt := plugin.InboundEvent{
|
||||
Kind: "manual",
|
||||
Manual: &plugin.ManualEvent{Reference: "v2"},
|
||||
}
|
||||
intent, err := tr.Match(context.Background(), plugin.Deps{}, wl, evt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if intent.TriggeredBy != "manual" {
|
||||
t.Errorf("TriggeredBy = %q, want manual", intent.TriggeredBy)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Package registry implements the "registry" trigger: matches inbound image
|
||||
// push events from container registries (Docker Hub, Gitea, ghcr, generic
|
||||
// webhooks, polling) against a repo + tag-pattern filter.
|
||||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
)
|
||||
|
||||
// Config is the per-workload trigger config blob. Image is the
|
||||
// fully-qualified image reference the workload deploys (e.g.
|
||||
// "registry.example.com/owner/app"); a push of any matching tag fires a
|
||||
// deploy. TagPattern is a path.Match glob ("*" matches all).
|
||||
type Config struct {
|
||||
Image string `json:"image"`
|
||||
TagPattern string `json:"tag_pattern"`
|
||||
}
|
||||
|
||||
type trigger struct{}
|
||||
|
||||
func init() { plugin.RegisterTrigger(&trigger{}) }
|
||||
|
||||
func (*trigger) Kind() string { return "registry" }
|
||||
|
||||
func (*trigger) SchemaSample() any {
|
||||
return Config{
|
||||
Image: "registry.example.com/owner/app",
|
||||
TagPattern: "v*",
|
||||
}
|
||||
}
|
||||
|
||||
func (*trigger) Validate(cfg json.RawMessage) error {
|
||||
var c Config
|
||||
if len(cfg) == 0 {
|
||||
return fmt.Errorf("registry trigger: config is required")
|
||||
}
|
||||
if err := json.Unmarshal(cfg, &c); err != nil {
|
||||
return fmt.Errorf("registry trigger: invalid json: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(c.Image) == "" {
|
||||
return fmt.Errorf("registry trigger: image is required")
|
||||
}
|
||||
pattern := c.TagPattern
|
||||
if pattern == "" {
|
||||
pattern = "*"
|
||||
}
|
||||
if _, err := path.Match(pattern, "probe"); err != nil {
|
||||
return fmt.Errorf("registry trigger: invalid tag_pattern %q: %w", pattern, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*trigger) Match(ctx context.Context, deps plugin.Deps, w plugin.Workload, evt plugin.InboundEvent) (*plugin.DeploymentIntent, error) {
|
||||
if evt.Kind != "image-push" || evt.Image == nil {
|
||||
return nil, nil
|
||||
}
|
||||
cfg, err := plugin.TriggerConfigOf[Config](w)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("registry trigger: decode config: %w", err)
|
||||
}
|
||||
if !imageMatches(cfg.Image, fullRepo(evt.Image)) {
|
||||
return nil, nil
|
||||
}
|
||||
pattern := cfg.TagPattern
|
||||
if pattern == "" {
|
||||
pattern = "*"
|
||||
}
|
||||
matched, err := path.Match(pattern, evt.Image.Tag)
|
||||
if err != nil || !matched {
|
||||
return nil, nil
|
||||
}
|
||||
return &plugin.DeploymentIntent{
|
||||
Reason: "registry-push",
|
||||
Reference: evt.Image.Tag,
|
||||
Metadata: map[string]string{"digest": evt.Image.Digest, "repo": evt.Image.Repo},
|
||||
TriggeredAt: time.Now().UTC(),
|
||||
TriggeredBy: "registry-webhook",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func fullRepo(e *plugin.ImagePushEvent) string {
|
||||
if e.Registry == "" {
|
||||
return e.Repo
|
||||
}
|
||||
return e.Registry + "/" + e.Repo
|
||||
}
|
||||
|
||||
// imageMatches: registry host case-insensitive, path/owner/name exact.
|
||||
// Single-segment refs (e.g. Docker Hub officials like "nginx") have no
|
||||
// `/` and match by exact equality of the bare name.
|
||||
func imageMatches(want, got string) bool {
|
||||
if want == got {
|
||||
return true
|
||||
}
|
||||
wIdx := strings.IndexByte(want, '/')
|
||||
gIdx := strings.IndexByte(got, '/')
|
||||
// Both single-segment: equality already failed above, so no match.
|
||||
if wIdx < 0 && gIdx < 0 {
|
||||
return false
|
||||
}
|
||||
// One side single-segment, the other qualified — does not match.
|
||||
if wIdx < 0 || gIdx < 0 {
|
||||
return false
|
||||
}
|
||||
wHost, wPath := want[:wIdx], want[wIdx:]
|
||||
gHost, gPath := got[:gIdx], got[gIdx:]
|
||||
return strings.EqualFold(wHost, gHost) && wPath == gPath
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
)
|
||||
|
||||
func mustConfig(t *testing.T, c Config) json.RawMessage {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal config: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
tr := &trigger{}
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg json.RawMessage
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty body rejected", nil, true},
|
||||
{"missing image rejected", mustConfig(t, Config{TagPattern: "*"}), true},
|
||||
{"valid wildcard", mustConfig(t, Config{Image: "owner/app", TagPattern: "*"}), false},
|
||||
{"valid with glob", mustConfig(t, Config{Image: "registry.example.com/owner/app", TagPattern: "v*"}), false},
|
||||
{"invalid glob", mustConfig(t, Config{Image: "owner/app", TagPattern: "v[oops"}), true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tr.Validate(tc.cfg)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("Validate(%s) err=%v want err=%v", tc.name, err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageMatches(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
want, got string
|
||||
shouldMatch bool
|
||||
}{
|
||||
{"exact qualified", "registry.example.com/owner/app", "registry.example.com/owner/app", true},
|
||||
{"host case-insensitive", "REGISTRY.example.com/owner/app", "registry.example.com/owner/app", true},
|
||||
{"path mismatch", "registry.example.com/owner/app", "registry.example.com/owner/other", false},
|
||||
{"different registry", "a.example.com/owner/app", "b.example.com/owner/app", false},
|
||||
// Single-segment images (Docker Hub officials) — recently fixed.
|
||||
{"both single-segment equal", "nginx", "nginx", true},
|
||||
{"both single-segment unequal", "nginx", "postgres", false},
|
||||
{"want single, got qualified", "nginx", "library/nginx", false},
|
||||
{"want qualified, got single", "library/nginx", "nginx", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := imageMatches(tc.want, tc.got); got != tc.shouldMatch {
|
||||
t.Errorf("imageMatches(%q, %q) = %v, want %v", tc.want, tc.got, got, tc.shouldMatch)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatch(t *testing.T) {
|
||||
tr := &trigger{}
|
||||
cfg := mustConfig(t, Config{Image: "registry.example.com/owner/app", TagPattern: "v*"})
|
||||
wl := plugin.Workload{
|
||||
ID: "wkl-1",
|
||||
TriggerConfig: cfg,
|
||||
}
|
||||
|
||||
t.Run("wrong event kind returns nil", func(t *testing.T) {
|
||||
evt := plugin.InboundEvent{Kind: "git-push"}
|
||||
intent, err := tr.Match(context.Background(), plugin.Deps{}, wl, evt)
|
||||
if err != nil || intent != nil {
|
||||
t.Fatalf("expected nil intent, got intent=%v err=%v", intent, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("matching push produces intent", func(t *testing.T) {
|
||||
evt := plugin.InboundEvent{
|
||||
Kind: "image-push",
|
||||
Image: &plugin.ImagePushEvent{
|
||||
Registry: "registry.example.com",
|
||||
Repo: "owner/app",
|
||||
Tag: "v1.2.3",
|
||||
},
|
||||
}
|
||||
intent, err := tr.Match(context.Background(), plugin.Deps{}, wl, evt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if intent == nil {
|
||||
t.Fatal("expected non-nil intent")
|
||||
}
|
||||
if intent.Reference != "v1.2.3" {
|
||||
t.Errorf("intent.Reference = %q, want v1.2.3", intent.Reference)
|
||||
}
|
||||
if intent.Reason != "registry-push" {
|
||||
t.Errorf("intent.Reason = %q, want registry-push", intent.Reason)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tag outside glob returns nil", func(t *testing.T) {
|
||||
evt := plugin.InboundEvent{
|
||||
Kind: "image-push",
|
||||
Image: &plugin.ImagePushEvent{
|
||||
Registry: "registry.example.com",
|
||||
Repo: "owner/app",
|
||||
Tag: "latest", // doesn't match v*
|
||||
},
|
||||
}
|
||||
intent, err := tr.Match(context.Background(), plugin.Deps{}, wl, evt)
|
||||
if err != nil || intent != nil {
|
||||
t.Fatalf("expected nil intent for tag=latest, got intent=%v err=%v", intent, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wrong repo returns nil", func(t *testing.T) {
|
||||
evt := plugin.InboundEvent{
|
||||
Kind: "image-push",
|
||||
Image: &plugin.ImagePushEvent{
|
||||
Registry: "registry.example.com",
|
||||
Repo: "owner/other",
|
||||
Tag: "v1.0.0",
|
||||
},
|
||||
}
|
||||
intent, err := tr.Match(context.Background(), plugin.Deps{}, wl, evt)
|
||||
if err != nil || intent != nil {
|
||||
t.Fatalf("expected nil intent for wrong repo, got intent=%v err=%v", intent, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty pattern matches anything", func(t *testing.T) {
|
||||
wlAny := plugin.Workload{
|
||||
ID: "wkl-any",
|
||||
TriggerConfig: mustConfig(t, Config{Image: "owner/app"}),
|
||||
}
|
||||
evt := plugin.InboundEvent{
|
||||
Kind: "image-push",
|
||||
Image: &plugin.ImagePushEvent{Repo: "owner/app", Tag: "latest"},
|
||||
}
|
||||
intent, err := tr.Match(context.Background(), plugin.Deps{}, wlAny, evt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if intent == nil {
|
||||
t.Fatal("expected match with empty pattern")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DeploymentIntent is the bridge between a Trigger (which decides "deploy
|
||||
// this") and a Source (which knows how to deploy). Reference is the
|
||||
// source-interpreted handle: an image tag for image sources, a git sha for
|
||||
// compose/static sources, "" for manual.
|
||||
type DeploymentIntent struct {
|
||||
Reason string // "registry-push" | "git-push" | "manual" | "cron" | "promote"
|
||||
Reference string // tag, sha, or "" — Source decides
|
||||
Metadata map[string]string // extra context (branch name, actor, etc.)
|
||||
TriggeredAt time.Time
|
||||
TriggeredBy string // username, "system", "webhook:<delivery-id>"
|
||||
}
|
||||
|
||||
// PublicFace describes one externally-routable face of a Workload. A
|
||||
// Workload may have several (e.g. compose stack with web + admin services).
|
||||
// The proxy provider is configured kind-agnostically from this shape.
|
||||
type PublicFace struct {
|
||||
Subdomain string // e.g. "myapp"; "" means root of Domain
|
||||
Domain string // "" inherits from settings.domain
|
||||
TargetService string // for compose: which service receives traffic; "" = single-container default
|
||||
TargetPort int // 0 = use container's primary exposed port
|
||||
AccessListID int // NPM access list, 0 = inherit
|
||||
EnableSSL bool
|
||||
}
|
||||
|
||||
// InboundEvent is what an upstream signal (webhook, poll, manual click)
|
||||
// looks like to a Trigger.Match call. Triggers consult Kind first to
|
||||
// decide whether the event is interesting, then read the matching payload
|
||||
// field. RawBody / Headers are kept so trigger plugins can perform their
|
||||
// own signature verification or vendor-specific parsing.
|
||||
type InboundEvent struct {
|
||||
Kind string // "image-push" | "git-push" | "git-tag" | "manual" | "cron-tick"
|
||||
Image *ImagePushEvent
|
||||
Git *GitEvent
|
||||
Manual *ManualEvent
|
||||
RawBody []byte
|
||||
Headers map[string][]string
|
||||
}
|
||||
|
||||
// ImagePushEvent is normalized across registry vendors (generic, Gitea,
|
||||
// Docker Hub, ghcr, ...). Vendor-specific quirks are resolved by the
|
||||
// webhook ingress before construction.
|
||||
type ImagePushEvent struct {
|
||||
Registry string // hostname; "" for default registry
|
||||
Repo string // owner/name
|
||||
Tag string
|
||||
Digest string // optional
|
||||
}
|
||||
|
||||
// GitEvent covers both push (commits) and tag-create flavors. Vendor is
|
||||
// "gitea" | "github" | "gitlab" | "" (autodetected).
|
||||
type GitEvent struct {
|
||||
Vendor string
|
||||
Repo string // owner/name
|
||||
Ref string // refs/heads/main or refs/tags/v1.2.3
|
||||
Branch string // populated for branch refs
|
||||
Tag string // populated for tag refs
|
||||
CommitSHA string
|
||||
Pusher string
|
||||
}
|
||||
|
||||
// ManualEvent represents a user-initiated deploy from the UI or API.
|
||||
type ManualEvent struct {
|
||||
Actor string
|
||||
Reference string // optional override (force a specific tag / sha / revision)
|
||||
Note string
|
||||
}
|
||||
|
||||
// SourceConfigOf decodes the workload's SourceConfig blob into the typed
|
||||
// shape a specific Source uses. Kept here so callers do not duplicate the
|
||||
// boilerplate.
|
||||
func SourceConfigOf[T any](w Workload) (T, error) {
|
||||
var out T
|
||||
if len(w.SourceConfig) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
err := json.Unmarshal(w.SourceConfig, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// TriggerConfigOf is the symmetric helper for TriggerConfig.
|
||||
func TriggerConfigOf[T any](w Workload) (T, error) {
|
||||
var out T
|
||||
if len(w.TriggerConfig) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
err := json.Unmarshal(w.TriggerConfig, &out)
|
||||
return out, err
|
||||
}
|
||||
Reference in New Issue
Block a user