diff --git a/docs/WORKLOAD_REFACTOR_TODO.md b/docs/WORKLOAD_REFACTOR_TODO.md index 23c7674..af72d55 100644 --- a/docs/WORKLOAD_REFACTOR_TODO.md +++ b/docs/WORKLOAD_REFACTOR_TODO.md @@ -7,11 +7,27 @@ test coverage on triggers / image helpers / webhook parser / store upserts are **already landed and live**. What follows is what's still pending, in priority order. +> ## Current focus (read this first) +> +> **Triggers as first-class reusable entities — DONE** (2026-05-16). The +> trigger-split arc shipped end-to-end: `triggers` + `workload_trigger_bindings` +> tables, boot-time backfill, fan-out webhook handler at +> `/api/webhook/triggers/{secret}` with bounded concurrency, `/api/triggers` +> CRUD + `/api/bindings/{id}` + workload-side bind endpoints, full `/triggers` +> frontend (list, new, detail), workload-page bindings panel + per-binding +> override editor, i18n EN+RU. +> +> **Next on Priority 1** is the **static source inline port** (~2150 LOC +> across 8 files; details in the section below). After that, the +> **hard legacy cutover** (drop `/api/projects`, `/api/stacks`, `/api/sites`, +> `/api/stages` + their tables and frontends) clears the deck. + ## Status at a glance | Item | Priority | Status | | ---- | -------- | ------ | -| Static source inline port | 1 | **PENDING** — only remaining blocker for hard cutover | +| Triggers as first-class reusable entities | 1 | **DONE** (2026-05-16) | +| Static source inline port | 1 | **PENDING — current focus** | | Hard legacy cutover | 1 | **PENDING** — gated by static port (volume scopes blocker is resolved) | | Generalized volume scopes | 2 | DONE | | Kind-aware editors (compose / image / static) | 2 | DONE | @@ -21,7 +37,6 @@ order. | i18n for `/apps/*` page strings | 3 | **PARTIAL** — Log Rules panel + Observability surfaces i18n'd; `apps.*` namespace still pending | | Docs / codemap entries for `internal/workload/plugin/` | 3 | **PENDING** | | API-handler / dispatcher / compose-source / static-backend tests | 4 | **PENDING** | -| Triggers as first-class reusable entities (post-cutover) | 5 | **PENDING** | Cross-references to the adjacent Observability work (Event Triggers + Log Scanner backend + drop-counter stats panel) live in @@ -29,6 +44,110 @@ Scanner backend + drop-counter stats panel) live in ## Priority 1 — Architecture unlock +### ~~Triggers as first-class reusable entities~~ — DONE (2026-05-16) + +Trigger config used to live embedded in the workload row +(`workload.trigger_kind` + `workload.trigger_config`). One workload owned +exactly one trigger; one trigger served exactly one workload. The split +makes a Trigger its own record so one inbound webhook / registry watcher / +schedule / git-push filter fans out to many workloads. + +**Schema + store** — `triggers` + `workload_trigger_bindings` tables with +`ON DELETE CASCADE`. `binding_config` JSON merges on top of `trigger.config` +(top-level merge, binding wins). Boot-time backfill lifts every existing +embedded trigger into a standalone trigger row + binding inside a +per-workload transaction so a partial failure rolls back cleanly. Trigger +names are id-suffixed unconditionally to dodge the (name, kind) collision +race. `store.ErrUnique` sentinel translates SQLite UNIQUE violations at +the store boundary; API handlers use `errors.Is` instead of substring +match. `MergeJSONConfig` always returns a freshly allocated slice (no +aliasing under fan-out). + +**Webhook fan-out** — new `POST /api/webhook/triggers/{secret}` resolves +to one Trigger and fans out to every enabled binding via a bounded worker +pool (`maxTriggerFanOutConcurrency = 4`). Per-binding errors are isolated +(one broken workload doesn't block siblings). Outcome accounting splits +deployed / skipped / no-match / errored cleanly. Legacy +`POST /api/webhook/workloads/{secret}` route dropped (clean break per the +workload-first memory; the boot backfill kept secrets resolvable at the +new path). + +**API** — `/api/triggers` CRUD, `/api/triggers/{id}/webhook`, +`/api/triggers/{id}/bindings` (list + bind), `/api/bindings/{id}` for +update and delete, and `/api/workloads/{id}/triggers` (list + bind, +accepts either `trigger_id` or inline `{kind, name, config, ...}`). +Inline-create path +runs trigger insert + binding insert inside one transaction +(`CreateTriggerWithBindingTx`) so a binding failure can't leak an orphan +trigger. `validateBindingConfig` enforces 8 KiB cap and runs the trigger +plugin's `Validate()` against the merged shape on every bind/update. +List endpoints use `LEFT JOIN ... GROUP BY` (`ListTriggersWithBindingCount`, +`ListBindingsForTriggerWithNames`, `ListBindingsForWorkloadWithNames`) — +no per-row N+1. + +**Plugin contract unchanged** — `Trigger.Match` still takes `(Workload, +InboundEvent)`. The fan-out path uses `plugin.WithEffectiveTrigger` to +stuff the merged config into a copied workload before the call, so the +existing `registry`, `git`, `manual` plugins work unchanged. + +**Reconciler** — gate dropped from `(SourceKind != "" && TriggerKind != "")` +to `SourceKind != ""`. A workload with a Source but no triggers still +gets `Source.Reconcile` called every tick (manual-only deploys are +common during early setup). + +**Frontend** — new pages under `web/src/routes/triggers/`: + +- `+page.svelte` — list with kind chips, binding count, webhook status, + empty state. +- `new/+page.svelte` — wizard with kind picker (cards), name, kind-aware + config form (registry / git / manual + JSON fallback), webhook toggles. +- `[id]/+page.svelte` — editable per-kind form, webhook URL panel + (origin-prefixed, copy + ConfirmDialog-gated rotate), bindings list + with per-row enabled `` + ConfirmDialog-gated unbind, + danger-zone delete. + +**Workload UI** — embedded trigger fields removed. + +- `apps/new/+page.svelte` — wizard now has Trigger step with NEW / PICK / + SKIP modes; bind happens after `createPluginWorkload` succeeds. +- `apps/[id]/+page.svelte` — Bindings panel above Containers, "Add trigger" + modal with Inline / Pick-existing tabs, **per-binding override editor** + (inline disclosure with read-only base config, editable JSON override, + merged preview, 8 KiB byte cap, save / reset-to-inherit). Per-row + "OVERRIDES n FIELDS" badge surfaces deviation from the trigger. + +**Shared component** — `web/src/lib/components/TriggerKindForm.svelte` +hosts the kind picker + name + per-kind config + JSON fallback + webhook +toggles. Reused on both `/triggers/new` and the workload Add-trigger modal. + +**i18n** — full EN + RU coverage under `redeployTriggers.*` (standalone +pages), `apps.detail.bindings.*` (workload bindings panel including +`override.*`), `apps.new.triggers.*` (wizard mode picker), `nav.triggers`. +The existing `/event-triggers` nav label was disambiguated to "Event +Triggers" to coexist with the new `/triggers` entry. + +**Compliance** — three pre-existing raw `` +instances in `apps/new` + `apps/[id]` (render-markdown, env-encrypted) +replaced with `` to honor the project rule. + +**Touch points (final):** + +- `internal/store/triggers.go`, `workload_trigger_bindings.go`, `models.go`, + `store.go` (schema + backfill + `translateSQLError`). +- `internal/workload/plugin/binding.go` (`MergeJSONConfig`, + `WithEffectiveTrigger`). +- `internal/webhook/trigger_handler.go` + `handler.go` (route mount, + legacy route removed). +- `internal/reconciler/reconciler.go` (trigger gate dropped). +- `internal/api/triggers.go` + `router.go` (REST surface). +- `web/src/routes/triggers/`, `web/src/routes/apps/{new,[id]}`, + `web/src/lib/components/TriggerKindForm.svelte`, `web/src/lib/api.ts`, + `web/src/lib/i18n/{en,ru}.json`, `web/src/routes/+layout.svelte`. + +**Reviews shipped through go-reviewer + security-reviewer + +typescript-reviewer subagents** — 0 CRITICAL; 5 HIGH and 4 MEDIUM +findings addressed inline before merge. + ### Static source inline port — ~2150 LOC across 8 files The current `internal/workload/plugin/source/static/` delegates to @@ -228,73 +347,6 @@ Solid pure-function coverage landed in the prior turn. Still missing: short-circuit. (Both pure; just need fixtures.) - **Static source Backend adapter** in `cmd/server/static_backend.go`. -## Priority 5 — Post-cutover roadmap - -### Triggers as first-class reusable entities - -Today a trigger's config lives embedded in the workload row -(`workload.trigger_kind` plus `workload.trigger_config` JSON via the plugin -contract). One workload owns exactly one trigger; one trigger serves exactly -one workload. This couples two concepts that users increasingly want -orthogonal: - -- One **inbound webhook** fanning out to several workloads (a single CI push - rebuilds dev + staging together). -- One **registry watcher** driving multiple workloads off the same image - (different tag filters per binding, shared poll state). -- One **schedule** kicking off a batch of jobs. -- One **git push** filter shared by sibling stack services. - -**Direction:** promote triggers to their own table with a join. - -- `triggers` — `id`, `kind` (registry / git / webhook / schedule / manual / - log_scan), `config` JSON, `secret`, `created_at`, audit fields. -- `workload_trigger_bindings` — `workload_id`, `trigger_id`, `binding_config` - JSON (per-binding overrides: tag filter, path filter, branch filter), plus - ordering / enabled flag. - -The dispatcher seam stays unchanged — `deployer.DispatchPlugin` still receives -a `(Workload, TriggerEvent)` pair; the only change is that the event's source -is resolved through the binding row instead of the workload row. - -**UX principle: first-class on the backend, inline by default in the UI.** -The workload create/edit form still has an "Add trigger" control that creates -a fresh trigger record in one step, so the 1:1 case (git push → this workload) -feels unchanged from today. Reuse is **opt-in** via a "Pick existing trigger" -picker on the same control. Triggers also get their own list/detail pages under -`/triggers` so the fan-out cases are discoverable and centrally manageable -(rotate secret once, audit once). - -**Per-kind modal applies, same rule as Source plugins** — the create/edit -form for a trigger switches body by `kind` (git: repo / branch / path; -registry: image / tag regex; webhook: secret + payload preview; schedule: -cron). Backend cheap, UI requires a paired hand-rolled form per kind. Treat -"ship the kind-aware form" as part of done for any new trigger kind. - -**Migration:** clean break (no migration) per the workload-first memory — -at cutover, each workload's embedded trigger config becomes a single -auto-created trigger record with a single binding row. No user-visible change -on day one; reuse becomes possible thereafter. - -**Sequencing:** lands **after** the Priority 1 hard cutover. The embedded -trigger config works fine for the 1:1 case that dominates today; the -static-source inline port is the higher-value blocker. Treat this as the -next major arc once cutover ships. - -**Touch points to expect:** - -- `internal/workload/plugin/trigger/*` — kind handlers stay; only their input - shape changes (read from binding + trigger row, not workload row). -- `internal/store/` — new `triggers` + `workload_trigger_bindings` tables and - CRUD; remove `trigger_kind` / `trigger_config` from the workload row. -- `internal/api/workloads.go` — adapt the workload create/edit handlers to - accept either "inline new trigger" or "bind existing trigger" payloads. -- New `/api/triggers` surface + `/triggers` frontend pages. -- `internal/webhook/handler.go` — inbound webhook now resolves to a trigger, - fans out to all bound workloads. -- `internal/reconciler/reconciler.go` — registry watchers iterate triggers, - not workloads; each trigger may fire N bindings. - ## Open architectural questions ### Stages chain vs explicit Stage entity diff --git a/internal/api/router.go b/internal/api/router.go index 4683595..9921cc1 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -430,6 +430,12 @@ func (s *Server) Router() chi.Router { // running image tag onto this workload's default_tag. r.Get("/chain", s.getWorkloadChain) r.With(auth.AdminOnly).Post("/promote-from/{sourceID}", s.promoteFromWorkload) + + // Trigger bindings on this workload — the symmetric view + // of /triggers/{id}/bindings keyed on the workload side + // so the workload detail page is one round-trip. + r.Get("/triggers", s.listBindingsForWorkload) + r.With(auth.AdminOnly).Post("/triggers", s.bindTriggerToWorkload) }) // Global container index, joined to workload + app names. @@ -446,6 +452,26 @@ func (s *Server) Router() chi.Router { r.Delete("/apps/{id}", s.deleteApp) }) + // First-class Triggers (redeploy signal sources). One trigger + // (registry / git / webhook / manual / schedule / log_scan) + // fans out to many workloads via workload_trigger_bindings. + // Reads are open to authenticated users; mutations + secret + // rotation are admin-gated. + r.Get("/triggers", s.listTriggers) + r.Get("/triggers/{id}", s.getTrigger) + r.Get("/triggers/{id}/bindings", s.listBindingsForTrigger) + r.Group(func(r chi.Router) { + r.Use(auth.AdminOnly) + r.Post("/triggers", s.createTrigger) + r.Put("/triggers/{id}", s.updateTrigger) + r.Delete("/triggers/{id}", s.deleteTrigger) + r.Get("/triggers/{id}/webhook", s.getTriggerWebhook) + r.Post("/triggers/{id}/webhook/regenerate", s.regenerateTriggerWebhook) + r.Post("/triggers/{id}/bindings", s.bindWorkloadToTrigger) + r.Put("/bindings/{bid}", s.updateBinding) + r.Delete("/bindings/{bid}", s.deleteBinding) + }) + // Event triggers: filter+action rules over the event_log // stream. Read endpoints are available to any authenticated // user; mutations + test-dispatch are admin-gated since they diff --git a/internal/api/triggers.go b/internal/api/triggers.go new file mode 100644 index 0000000..a66ad38 --- /dev/null +++ b/internal/api/triggers.go @@ -0,0 +1,628 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "github.com/alexei/tinyforge/internal/store" + "github.com/alexei/tinyforge/internal/workload/plugin" +) + +// triggerView is the response shape for /api/triggers. Webhook secrets +// are never serialized — read them via the dedicated /webhook subresource +// where the canonical URL is composed. +type triggerView struct { + ID string `json:"id"` + Kind string `json:"kind"` + Name string `json:"name"` + Config json.RawMessage `json:"config"` + WebhookEnabled bool `json:"webhook_enabled"` + WebhookRequireSignature bool `json:"webhook_require_signature"` + BindingCount int `json:"binding_count"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func (s *Server) toTriggerView(t store.Trigger) triggerView { + count, err := s.store.CountBindingsForTrigger(t.ID) + if err != nil { + slog.Warn("triggerView: count bindings", "trigger", t.ID, "error", err) + } + return triggerView{ + ID: t.ID, + Kind: t.Kind, + Name: t.Name, + Config: json.RawMessage(t.Config), + WebhookEnabled: t.WebhookSecret != "", + WebhookRequireSignature: t.WebhookRequireSignature, + BindingCount: count, + CreatedAt: t.CreatedAt, + UpdatedAt: t.UpdatedAt, + } +} + +// toTriggerViewWithCount is the join-aware variant used by listTriggers +// to avoid one COUNT(*) per row. Kept distinct from toTriggerView so +// single-row paths (get/create/update) keep the simple call shape. +func toTriggerViewWithCount(row store.TriggerWithBindingCount) triggerView { + return triggerView{ + ID: row.ID, + Kind: row.Kind, + Name: row.Name, + Config: json.RawMessage(row.Config), + WebhookEnabled: row.WebhookSecret != "", + WebhookRequireSignature: row.WebhookRequireSignature, + BindingCount: row.BindingCount, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} + +// triggerRequest is the create/update body. Config is opaque per kind. +// Auto-generates a webhook secret on create when WebhookEnabled is true; +// the secret is exposed only via the /webhook subresource. +type triggerRequest struct { + Kind string `json:"kind"` + Name string `json:"name"` + Config json.RawMessage `json:"config"` + WebhookEnabled bool `json:"webhook_enabled"` + WebhookRequireSignature bool `json:"webhook_require_signature"` +} + +// Same per-blob caps used on the workload pluginWorkloadRequest path — +// triggers and workload trigger configs share the same plugin Validate() +// call, so the byte budget should match. +const maxTriggerStandaloneConfigBytes = 16 << 10 + +func (s *Server) listTriggers(w http.ResponseWriter, r *http.Request) { + kind := r.URL.Query().Get("kind") + rows, err := s.store.ListTriggersWithBindingCount(kind) + if err != nil { + slog.Error("list triggers", "error", err) + respondError(w, http.StatusInternalServerError, "list triggers") + return + } + out := make([]triggerView, 0, len(rows)) + for _, t := range rows { + out = append(out, toTriggerViewWithCount(t)) + } + respondJSON(w, http.StatusOK, out) +} + +func (s *Server) getTrigger(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + t, err := s.store.GetTriggerByID(id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "trigger") + return + } + respondError(w, http.StatusInternalServerError, "get trigger") + return + } + respondJSON(w, http.StatusOK, s.toTriggerView(t)) +} + +// buildTriggerFromRequest assembles a store.Trigger ready for insert. +// Centralized so the standalone create endpoint and the inline-bind +// endpoint cannot drift on secret-generation defaults. +func buildTriggerFromRequest(req triggerRequest) store.Trigger { + t := store.Trigger{ + Kind: req.Kind, + Name: strings.TrimSpace(req.Name), + Config: string(req.Config), + WebhookRequireSignature: req.WebhookRequireSignature, + } + if req.WebhookEnabled { + t.WebhookSecret = generateWebhookSecret() + t.WebhookSigningSecret = generateWebhookSecret() + } + return t +} + +func (s *Server) createTrigger(w http.ResponseWriter, r *http.Request) { + var req triggerRequest + if !decodeJSONStrict(w, r, &req) { + return + } + if err := validateTriggerRequest(req); err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + created, err := s.store.CreateTrigger(buildTriggerFromRequest(req)) + if err != nil { + slog.Error("create trigger", "error", err) + // UNIQUE name collision is the most common user-facing failure. + if errors.Is(err, store.ErrUnique) { + respondError(w, http.StatusConflict, "a trigger with this name already exists") + return + } + respondError(w, http.StatusInternalServerError, "create trigger") + return + } + respondJSON(w, http.StatusCreated, s.toTriggerView(created)) +} + +func (s *Server) updateTrigger(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + existing, err := s.store.GetTriggerByID(id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "trigger") + return + } + respondError(w, http.StatusInternalServerError, "get trigger") + return + } + var req triggerRequest + if !decodeJSONStrict(w, r, &req) { + return + } + // Kind is immutable on update. Mirror the value from the existing + // row so validateTriggerRequest can still verify the config blob. + req.Kind = existing.Kind + if err := validateTriggerRequest(req); err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + if req.Name != "" { + existing.Name = strings.TrimSpace(req.Name) + } + if len(req.Config) > 0 { + existing.Config = string(req.Config) + } + existing.WebhookRequireSignature = req.WebhookRequireSignature + wasEnabled := existing.WebhookSecret != "" + if req.WebhookEnabled && !wasEnabled { + // false→true transition: rotate both secrets so re-enabling + // after a disable does not silently revive an old leaked URL. + existing.WebhookSecret = generateWebhookSecret() + existing.WebhookSigningSecret = generateWebhookSecret() + } + if !req.WebhookEnabled { + existing.WebhookSecret = "" + existing.WebhookSigningSecret = "" + } + if err := s.store.UpdateTrigger(existing); err != nil { + slog.Error("update trigger", "error", err) + if errors.Is(err, store.ErrUnique) { + respondError(w, http.StatusConflict, "a trigger with this name already exists") + return + } + respondError(w, http.StatusInternalServerError, "update trigger") + return + } + respondJSON(w, http.StatusOK, s.toTriggerView(existing)) +} + +func (s *Server) deleteTrigger(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if err := s.store.DeleteTrigger(id); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "trigger") + return + } + respondError(w, http.StatusInternalServerError, "delete trigger") + return + } + respondJSON(w, http.StatusOK, map[string]string{"deleted": id}) +} + +// triggerWebhookView surfaces the inbound URL for a trigger. Returns +// empty path / secret when the trigger has webhook ingress disabled. +type triggerWebhookView struct { + URL string `json:"url"` + Secret string `json:"secret"` + WebhookRequireSignature bool `json:"webhook_require_signature"` +} + +func (s *Server) getTriggerWebhook(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + t, err := s.store.GetTriggerByID(id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "trigger") + return + } + respondError(w, http.StatusInternalServerError, "get trigger") + return + } + view := triggerWebhookView{ + Secret: t.WebhookSecret, + WebhookRequireSignature: t.WebhookRequireSignature, + } + if t.WebhookSecret != "" { + view.URL = "/api/webhook/triggers/" + t.WebhookSecret + } + respondJSON(w, http.StatusOK, view) +} + +func (s *Server) regenerateTriggerWebhook(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + secret := generateWebhookSecret() + if err := s.store.SetTriggerWebhookSecret(id, secret); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "trigger") + return + } + respondError(w, http.StatusInternalServerError, "rotate webhook secret") + return + } + respondJSON(w, http.StatusOK, map[string]string{ + "secret": secret, + "url": "/api/webhook/triggers/" + secret, + }) +} + +// maxBindingConfigBytes caps a per-binding override blob. Smaller than +// the full trigger config — bindings should be lightweight tweaks +// (tag pattern, branch filter), not whole replacement configs. +const maxBindingConfigBytes = 8 << 10 + +// validateBindingConfig enforces the size cap and runs the trigger +// plugin's Validate() against the merged (trigger.config + binding) +// shape so a malformed override is caught at write time instead of +// silently breaking webhook fan-out at deploy time. +func validateBindingConfig(trg store.Trigger, bindingConfig json.RawMessage) error { + if len(bindingConfig) > maxBindingConfigBytes { + return fmt.Errorf("binding_config exceeds %d bytes", maxBindingConfigBytes) + } + merged, err := plugin.MergeJSONConfig(json.RawMessage(trg.Config), bindingConfig) + if err != nil { + return fmt.Errorf("binding_config: %w", err) + } + tp, err := plugin.GetTrigger(trg.Kind) + if err != nil { + return err + } + return tp.Validate(merged) +} + +// validateTriggerRequest type-checks the trigger via the registered +// plugin. Accepts an empty config only when the plugin allows it (e.g. +// the manual trigger). +func validateTriggerRequest(req triggerRequest) error { + if strings.TrimSpace(req.Kind) == "" { + return fmt.Errorf("kind is required") + } + if strings.TrimSpace(req.Name) == "" { + return fmt.Errorf("name is required") + } + if len(req.Config) > maxTriggerStandaloneConfigBytes { + return fmt.Errorf("config exceeds %d bytes", maxTriggerStandaloneConfigBytes) + } + tp, err := plugin.GetTrigger(req.Kind) + if err != nil { + return err + } + return tp.Validate(req.Config) +} + +// bindingView shapes one binding for the /api/triggers/{id}/bindings +// listing. Includes the workload's name to avoid an N+1 round-trip on +// the frontend. +type bindingView struct { + ID string `json:"id"` + WorkloadID string `json:"workload_id"` + WorkloadName string `json:"workload_name"` + TriggerID string `json:"trigger_id"` + BindingConfig json.RawMessage `json:"binding_config"` + Enabled bool `json:"enabled"` + SortOrder int `json:"sort_order"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func (s *Server) toBindingView(b store.WorkloadTriggerBinding) bindingView { + name := "" + if w, err := s.store.GetWorkloadByID(b.WorkloadID); err == nil { + name = w.Name + } + return bindingView{ + ID: b.ID, + WorkloadID: b.WorkloadID, + WorkloadName: name, + TriggerID: b.TriggerID, + BindingConfig: json.RawMessage(b.BindingConfig), + Enabled: b.Enabled, + SortOrder: b.SortOrder, + CreatedAt: b.CreatedAt, + UpdatedAt: b.UpdatedAt, + } +} + +func (s *Server) listBindingsForTrigger(w http.ResponseWriter, r *http.Request) { + tid := chi.URLParam(r, "id") + if _, err := s.store.GetTriggerByID(tid); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "trigger") + return + } + respondError(w, http.StatusInternalServerError, "get trigger") + return + } + rows, err := s.store.ListBindingsForTriggerWithNames(tid) + if err != nil { + respondError(w, http.StatusInternalServerError, "list bindings") + return + } + out := make([]bindingView, 0, len(rows)) + for _, b := range rows { + out = append(out, bindingView{ + ID: b.ID, + WorkloadID: b.WorkloadID, + WorkloadName: b.WorkloadName, + TriggerID: b.TriggerID, + BindingConfig: json.RawMessage(b.BindingConfig), + Enabled: b.Enabled, + SortOrder: b.SortOrder, + CreatedAt: b.CreatedAt, + UpdatedAt: b.UpdatedAt, + }) + } + respondJSON(w, http.StatusOK, out) +} + +// bindingRequest is shared by trigger-side bind (POST .../bindings) and +// workload-side bind (POST workloads/{id}/triggers). +type bindingRequest struct { + WorkloadID string `json:"workload_id"` + TriggerID string `json:"trigger_id"` + BindingConfig json.RawMessage `json:"binding_config"` + Enabled *bool `json:"enabled"` + SortOrder int `json:"sort_order"` +} + +func (s *Server) bindWorkloadToTrigger(w http.ResponseWriter, r *http.Request) { + tid := chi.URLParam(r, "id") + var req bindingRequest + if !decodeJSONStrict(w, r, &req) { + return + } + if req.WorkloadID == "" { + respondError(w, http.StatusBadRequest, "workload_id is required") + return + } + trg, err := s.store.GetTriggerByID(tid) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "trigger") + return + } + respondError(w, http.StatusInternalServerError, "get trigger") + return + } + if _, err := s.store.GetWorkloadByID(req.WorkloadID); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "workload") + return + } + respondError(w, http.StatusInternalServerError, "get workload") + return + } + if err := validateBindingConfig(trg, req.BindingConfig); err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + b := store.WorkloadTriggerBinding{ + WorkloadID: req.WorkloadID, + TriggerID: tid, + BindingConfig: string(req.BindingConfig), + Enabled: enabled, + SortOrder: req.SortOrder, + } + created, err := s.store.CreateBinding(b) + if err != nil { + if errors.Is(err, store.ErrUnique) { + respondError(w, http.StatusConflict, "this workload is already bound to this trigger") + return + } + slog.Error("create binding", "error", err) + respondError(w, http.StatusInternalServerError, "create binding") + return + } + respondJSON(w, http.StatusCreated, s.toBindingView(created)) +} + +func (s *Server) updateBinding(w http.ResponseWriter, r *http.Request) { + bid := chi.URLParam(r, "bid") + existing, err := s.store.GetBindingByID(bid) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "binding") + return + } + respondError(w, http.StatusInternalServerError, "get binding") + return + } + var req bindingRequest + if !decodeJSONStrict(w, r, &req) { + return + } + if len(req.BindingConfig) > 0 { + trg, terr := s.store.GetTriggerByID(existing.TriggerID) + if terr != nil { + slog.Error("update binding: trigger lookup", "trigger", existing.TriggerID, "error", terr) + respondError(w, http.StatusInternalServerError, "trigger lookup") + return + } + if err := validateBindingConfig(trg, req.BindingConfig); err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + existing.BindingConfig = string(req.BindingConfig) + } + if req.Enabled != nil { + existing.Enabled = *req.Enabled + } + existing.SortOrder = req.SortOrder + if err := s.store.UpdateBinding(existing); err != nil { + respondError(w, http.StatusInternalServerError, "update binding") + return + } + respondJSON(w, http.StatusOK, s.toBindingView(existing)) +} + +// listBindingsForWorkload is the workload-side mirror of +// listBindingsForTrigger. Returns every trigger bound to the workload +// in sort_order so the detail page can render them inline. +func (s *Server) listBindingsForWorkload(w http.ResponseWriter, r *http.Request) { + wid := chi.URLParam(r, "id") + if _, err := s.store.GetWorkloadByID(wid); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "workload") + return + } + respondError(w, http.StatusInternalServerError, "get workload") + return + } + rows, err := s.store.ListBindingsForWorkloadWithNames(wid) + if err != nil { + respondError(w, http.StatusInternalServerError, "list bindings") + return + } + type item struct { + bindingView + TriggerKind string `json:"trigger_kind"` + TriggerName string `json:"trigger_name"` + } + out := make([]item, 0, len(rows)) + for _, b := range rows { + out = append(out, item{ + bindingView: bindingView{ + ID: b.ID, + WorkloadID: b.WorkloadID, + TriggerID: b.TriggerID, + BindingConfig: json.RawMessage(b.BindingConfig), + Enabled: b.Enabled, + SortOrder: b.SortOrder, + CreatedAt: b.CreatedAt, + UpdatedAt: b.UpdatedAt, + }, + TriggerKind: b.TriggerKind, + TriggerName: b.TriggerName, + }) + } + respondJSON(w, http.StatusOK, out) +} + +// workloadBindRequest covers the two UX flows: bind an existing trigger +// (TriggerID present) or inline-create one in the same call (TriggerID +// empty + Inline populated). The inline form keeps the 1:1 case feeling +// unchanged from the embedded-trigger era. +type workloadBindRequest struct { + TriggerID string `json:"trigger_id"` + BindingConfig json.RawMessage `json:"binding_config"` + Enabled *bool `json:"enabled"` + SortOrder int `json:"sort_order"` + Inline *triggerRequest `json:"inline"` +} + +func (s *Server) bindTriggerToWorkload(w http.ResponseWriter, r *http.Request) { + wid := chi.URLParam(r, "id") + if _, err := s.store.GetWorkloadByID(wid); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "workload") + return + } + respondError(w, http.StatusInternalServerError, "get workload") + return + } + var req workloadBindRequest + if !decodeJSONStrict(w, r, &req) { + return + } + if req.TriggerID == "" && req.Inline == nil { + respondError(w, http.StatusBadRequest, "either trigger_id or inline trigger is required") + return + } + + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + + // Inline path: create trigger + binding atomically so a binding + // failure cannot leak a half-built trigger row. + if req.TriggerID == "" { + if err := validateTriggerRequest(*req.Inline); err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + _, b, err := s.store.CreateTriggerWithBindingTx( + buildTriggerFromRequest(*req.Inline), + store.WorkloadTriggerBinding{ + WorkloadID: wid, + BindingConfig: string(req.BindingConfig), + Enabled: enabled, + SortOrder: req.SortOrder, + }, + ) + if err != nil { + if errors.Is(err, store.ErrUnique) { + respondError(w, http.StatusConflict, "a trigger with this name already exists") + return + } + slog.Error("inline trigger+binding tx", "error", err) + respondError(w, http.StatusInternalServerError, "create inline trigger+binding") + return + } + respondJSON(w, http.StatusCreated, s.toBindingView(b)) + return + } + + // Existing-trigger path: just bind. + trg, err := s.store.GetTriggerByID(req.TriggerID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "trigger") + return + } + respondError(w, http.StatusInternalServerError, "get trigger") + return + } + if err := validateBindingConfig(trg, req.BindingConfig); err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + b, err := s.store.CreateBinding(store.WorkloadTriggerBinding{ + WorkloadID: wid, + TriggerID: req.TriggerID, + BindingConfig: string(req.BindingConfig), + Enabled: enabled, + SortOrder: req.SortOrder, + }) + if err != nil { + if errors.Is(err, store.ErrUnique) { + respondError(w, http.StatusConflict, "this workload is already bound to this trigger") + return + } + slog.Error("create binding from workload side", "error", err) + respondError(w, http.StatusInternalServerError, "create binding") + return + } + respondJSON(w, http.StatusCreated, s.toBindingView(b)) +} + +func (s *Server) deleteBinding(w http.ResponseWriter, r *http.Request) { + bid := chi.URLParam(r, "bid") + if err := s.store.DeleteBinding(bid); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "binding") + return + } + respondError(w, http.StatusInternalServerError, "delete binding") + return + } + respondJSON(w, http.StatusOK, map[string]string{"deleted": bid}) +} diff --git a/internal/reconciler/reconciler.go b/internal/reconciler/reconciler.go index 12c28d7..5b3dbaa 100644 --- a/internal/reconciler/reconciler.go +++ b/internal/reconciler/reconciler.go @@ -134,10 +134,16 @@ func (r *Reconciler) ReconcileOnce(ctx context.Context) error { 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. +// reconcilePluginWorkloads iterates every workload row that has a +// Source plugin and asks the dispatcher to invoke Source.Reconcile. +// Failures are logged per-workload — one workload's broken state must +// not stop sweeping the rest. +// +// Trigger configuration is no longer required to reconcile: a workload +// with a Source but no trigger bindings is still a deployed thing whose +// container state must stay in sync (manual-only deploys are common +// during early setup). After the trigger-split refactor triggers live +// in their own table, so the only gate here is SourceKind. // // No-op when the plugin dispatcher hasn't been wired (boot-time race, // disabled deployments, tests). @@ -151,7 +157,7 @@ func (r *Reconciler) reconcilePluginWorkloads(ctx context.Context) { return } for _, w := range rows { - if w.SourceKind == "" || w.TriggerKind == "" { + if w.SourceKind == "" { continue } pw := toPluginWorkload(w) diff --git a/internal/store/models.go b/internal/store/models.go index 77f66f8..4523fdb 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -510,6 +510,43 @@ type Container struct { UpdatedAt string `json:"updated_at"` } +// Trigger is a first-class redeploy signal source. Triggers were embedded +// in workload rows (workload.trigger_kind / trigger_config) until the +// trigger-split refactor; they are now standalone records bound to +// workloads via WorkloadTriggerBinding so a single trigger (a webhook, +// registry watcher, schedule, git push) can fan out to many workloads. +// +// Webhook secrets live here, not on the workload — the inbound webhook +// URL identifies a trigger, which then resolves its bindings to decide +// which workloads to fire. +type Trigger struct { + ID string `json:"id"` + Kind string `json:"kind"` // registry | git | manual | schedule | log_scan | ... + Name string `json:"name"` // human-readable, unique + Config string `json:"config"` // JSON-encoded, decoded by the matching plugin + WebhookSecret string `json:"-"` // URL-identifier secret; never serialized + WebhookSigningSecret string `json:"-"` // HMAC key; never serialized + WebhookRequireSignature bool `json:"webhook_require_signature"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// WorkloadTriggerBinding joins a Workload to a Trigger. BindingConfig is +// the per-binding override applied on top of Trigger.Config (top-level +// JSON merge: binding fields win). Empty BindingConfig means "use the +// trigger's config verbatim". Enabled false skips the binding without +// deleting it (useful for paused stages). +type WorkloadTriggerBinding struct { + ID string `json:"id"` + WorkloadID string `json:"workload_id"` + TriggerID string `json:"trigger_id"` + BindingConfig string `json:"binding_config"` // JSON-encoded; "{}" = none + Enabled bool `json:"enabled"` + SortOrder int `json:"sort_order"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + // App is an optional grouping of workloads (e.g., "my-saas" = web project + worker stack + redis stack). // Schema lives here from day one so future UI work is unblocked, but no UI is wired in v1. type App struct { diff --git a/internal/store/store.go b/internal/store/store.go index 5b9535c..07ca893 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -4,15 +4,41 @@ import ( "database/sql" "errors" "fmt" + "log/slog" "strings" "time" + "github.com/google/uuid" _ "modernc.org/sqlite" ) // ErrNotFound is returned when a requested entity does not exist. var ErrNotFound = errors.New("not found") +// ErrUnique is returned when a write violates a UNIQUE constraint. +// Translating the driver-specific message at the store boundary lets +// callers use errors.Is instead of fragile substring matching on +// err.Error(); the SQLite driver's wording is not part of any contract. +var ErrUnique = errors.New("unique constraint violation") + +// translateSQLError maps a driver-level error onto one of the store's +// sentinel errors when possible. Returns the original error unchanged +// when no mapping applies. The returned error wraps the original via +// %w so callers that need the raw message still have it. +func translateSQLError(err error) error { + if err == nil { + return nil + } + msg := err.Error() + // modernc.org/sqlite returns text like + // "constraint failed: UNIQUE constraint failed: triggers.name (2067)" + // Match case-insensitively in case the driver wording shifts. + if strings.Contains(strings.ToUpper(msg), "UNIQUE") { + return fmt.Errorf("%w: %v", ErrUnique, err) + } + return err +} + // Store wraps the SQLite database connection and provides access to all query methods. type Store struct { db *sql.DB @@ -274,6 +300,34 @@ func (s *Store) runMigrations() error { updated_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(workload_id, target) )`, + // triggers: first-class redeploy signal sources. Webhook secrets + // move from workload onto the trigger so one webhook URL can fan + // out to multiple workloads via workload_trigger_bindings. + `CREATE TABLE IF NOT EXISTS triggers ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + name TEXT NOT NULL UNIQUE, + config TEXT NOT NULL DEFAULT '{}', + webhook_secret TEXT NOT NULL DEFAULT '', + webhook_signing_secret TEXT NOT NULL DEFAULT '', + webhook_require_signature INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )`, + // workload_trigger_bindings: many-to-many between workloads and + // triggers. binding_config is the per-binding override applied on + // top of trigger.config (top-level JSON merge, binding wins). + `CREATE TABLE IF NOT EXISTS workload_trigger_bindings ( + id TEXT PRIMARY KEY, + workload_id TEXT NOT NULL REFERENCES workloads(id) ON DELETE CASCADE, + trigger_id TEXT NOT NULL REFERENCES triggers(id) ON DELETE CASCADE, + binding_config TEXT NOT NULL DEFAULT '{}', + enabled INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(workload_id, trigger_id) + )`, } for _, t := range workloadTables { if _, err := s.db.Exec(t); err != nil { @@ -454,6 +508,11 @@ func (s *Store) runMigrations() error { `CREATE INDEX IF NOT EXISTS idx_containers_stage_id ON containers(stage_id) WHERE stage_id != ''`, `CREATE INDEX IF NOT EXISTS idx_workload_env_workload ON workload_env(workload_id)`, `CREATE INDEX IF NOT EXISTS idx_workload_volumes_workload ON workload_volumes(workload_id)`, + // Trigger-split indexes (2026-05-16). + `CREATE INDEX IF NOT EXISTS idx_triggers_kind ON triggers(kind)`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_triggers_webhook_secret ON triggers(webhook_secret) WHERE webhook_secret != ''`, + `CREATE INDEX IF NOT EXISTS idx_bindings_workload ON workload_trigger_bindings(workload_id)`, + `CREATE INDEX IF NOT EXISTS idx_bindings_trigger ON workload_trigger_bindings(trigger_id)`, } for _, idx := range indexes { if _, err := s.db.Exec(idx); err != nil { @@ -474,6 +533,127 @@ func (s *Store) runMigrations() error { } } + if err := s.backfillTriggersFromWorkloads(); err != nil { + slog.Warn("trigger backfill", "error", err) + } + + return nil +} + +// backfillTriggersFromWorkloads converts embedded trigger config on +// workload rows into standalone trigger + binding rows. Runs once per +// boot and is idempotent — only workloads with non-empty trigger_kind +// AND no existing binding produce a new trigger record. +// +// Each per-workload backfill runs inside a transaction so a partial +// failure (binding insert fails after trigger insert succeeds) rolls +// back cleanly; otherwise an orphan trigger row would survive forever +// because the next boot's bindings-count check sees zero bindings and +// tries to re-insert under the same UNIQUE name. +// +// Trigger names are unconditionally suffixed with the workload's id +// short-prefix to make collisions impossible across two workloads with +// identical (name, kind) — the "Foo [registry]" + "Foo [registry]" case +// would otherwise have one of them silently dropped. +// +// Why on every boot: the trigger-split refactor is a clean break (no +// formal migration). Existing dev databases have triggers embedded in +// workloads.trigger_kind / trigger_config; this lifts them into the new +// shape so URLs and behavior survive the upgrade. +func (s *Store) backfillTriggersFromWorkloads() error { + rows, err := s.db.Query( + `SELECT id, name, trigger_kind, trigger_config, + webhook_secret, webhook_signing_secret, webhook_require_signature + FROM workloads + WHERE trigger_kind != ''`, + ) + if err != nil { + return fmt.Errorf("scan workloads for backfill: %w", err) + } + defer rows.Close() + + type embedded struct { + id, name, kind, config string + webhookSecret, webhookSigningSecret string + requireSig int + } + var pending []embedded + for rows.Next() { + var e embedded + if err := rows.Scan(&e.id, &e.name, &e.kind, &e.config, + &e.webhookSecret, &e.webhookSigningSecret, &e.requireSig); err != nil { + return fmt.Errorf("scan workload row: %w", err) + } + pending = append(pending, e) + } + if err := rows.Err(); err != nil { + return err + } + + for _, e := range pending { + if err := s.backfillOneTrigger(e.id, e.name, e.kind, e.config, + e.webhookSecret, e.webhookSigningSecret, e.requireSig); err != nil { + slog.Warn("trigger backfill: workload skipped", + "workload", e.id, "error", err) + } + } + return nil +} + +// backfillOneTrigger lifts one embedded trigger into its own row + binding +// inside a single transaction. Idempotent: a workload that already has at +// least one binding is left alone. +func (s *Store) backfillOneTrigger(workloadID, workloadName, kind, config, + webhookSecret, webhookSigningSecret string, requireSig int) error { + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("begin: %w", err) + } + defer func() { _ = tx.Rollback() }() + + var existing int + if err := tx.QueryRow( + `SELECT COUNT(*) FROM workload_trigger_bindings WHERE workload_id = ?`, + workloadID, + ).Scan(&existing); err != nil { + return fmt.Errorf("count bindings: %w", err) + } + if existing > 0 { + return nil + } + + idShort := workloadID + if len(idShort) > 8 { + idShort = idShort[:8] + } + triggerName := fmt.Sprintf("%s [%s] %s", workloadName, kind, idShort) + triggerID := uuid.New().String() + now := Now() + if _, err := tx.Exec( + `INSERT INTO triggers (id, kind, name, config, + webhook_secret, webhook_signing_secret, webhook_require_signature, + created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + triggerID, kind, triggerName, config, + webhookSecret, webhookSigningSecret, requireSig, + now, now, + ); err != nil { + return fmt.Errorf("insert trigger: %w", err) + } + + bindingID := uuid.New().String() + if _, err := tx.Exec( + `INSERT INTO workload_trigger_bindings + (id, workload_id, trigger_id, binding_config, enabled, sort_order, created_at, updated_at) + VALUES (?, ?, ?, '{}', 1, 0, ?, ?)`, + bindingID, workloadID, triggerID, now, now, + ); err != nil { + return fmt.Errorf("insert binding: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit: %w", err) + } return nil } diff --git a/internal/store/triggers.go b/internal/store/triggers.go new file mode 100644 index 0000000..443821f --- /dev/null +++ b/internal/store/triggers.go @@ -0,0 +1,303 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + + "github.com/google/uuid" +) + +const triggerColumns = `id, kind, name, config, + webhook_secret, webhook_signing_secret, webhook_require_signature, + created_at, updated_at` + +func scanTrigger(s rowScanner) (Trigger, error) { + var t Trigger + var requireSig int + if err := s.Scan(&t.ID, &t.Kind, &t.Name, &t.Config, + &t.WebhookSecret, &t.WebhookSigningSecret, &requireSig, + &t.CreatedAt, &t.UpdatedAt); err != nil { + return Trigger{}, err + } + t.WebhookRequireSignature = requireSig != 0 + return t, nil +} + +// CreateTrigger inserts a new trigger row. Kind + Name are required. +// Config is normalized to "{}" when empty; webhook secret is left empty +// unless the caller pre-populates it. +func (s *Store) CreateTrigger(t Trigger) (Trigger, error) { + if t.ID == "" { + t.ID = uuid.New().String() + } + if t.Config == "" { + t.Config = "{}" + } + t.CreatedAt = Now() + t.UpdatedAt = t.CreatedAt + _, err := s.db.Exec( + `INSERT INTO triggers (`+triggerColumns+`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + t.ID, t.Kind, t.Name, t.Config, + t.WebhookSecret, t.WebhookSigningSecret, BoolToInt(t.WebhookRequireSignature), + t.CreatedAt, t.UpdatedAt, + ) + if err != nil { + return Trigger{}, fmt.Errorf("insert trigger: %w", translateSQLError(err)) + } + return t, nil +} + +// GetTriggerByID returns one trigger. +func (s *Store) GetTriggerByID(id string) (Trigger, error) { + t, err := scanTrigger(s.db.QueryRow( + `SELECT `+triggerColumns+` FROM triggers WHERE id = ?`, id, + )) + if errors.Is(err, sql.ErrNoRows) { + return Trigger{}, fmt.Errorf("trigger %s: %w", id, ErrNotFound) + } + if err != nil { + return Trigger{}, fmt.Errorf("query trigger: %w", err) + } + return t, nil +} + +// GetTriggerByWebhookSecret resolves a trigger by its inbound webhook +// secret. Empty input is treated as not-found to avoid accidental matches +// against rows whose webhook is disabled. +func (s *Store) GetTriggerByWebhookSecret(secret string) (Trigger, error) { + if secret == "" { + return Trigger{}, fmt.Errorf("empty secret: %w", ErrNotFound) + } + t, err := scanTrigger(s.db.QueryRow( + `SELECT `+triggerColumns+` FROM triggers WHERE webhook_secret = ?`, secret, + )) + if errors.Is(err, sql.ErrNoRows) { + return Trigger{}, ErrNotFound + } + if err != nil { + return Trigger{}, fmt.Errorf("query trigger by webhook secret: %w", err) + } + return t, nil +} + +// GetTriggerByName resolves a trigger by its unique human name. +func (s *Store) GetTriggerByName(name string) (Trigger, error) { + if name == "" { + return Trigger{}, fmt.Errorf("empty name: %w", ErrNotFound) + } + t, err := scanTrigger(s.db.QueryRow( + `SELECT `+triggerColumns+` FROM triggers WHERE name = ?`, name, + )) + if errors.Is(err, sql.ErrNoRows) { + return Trigger{}, ErrNotFound + } + if err != nil { + return Trigger{}, fmt.Errorf("query trigger by name: %w", err) + } + return t, nil +} + +// ListTriggers returns all triggers, ordered by name. Optional kind +// filter — empty string returns everything. +func (s *Store) ListTriggers(kind string) ([]Trigger, error) { + var rows *sql.Rows + var err error + if kind == "" { + rows, err = s.db.Query(`SELECT ` + triggerColumns + ` FROM triggers ORDER BY name`) + } else { + rows, err = s.db.Query(`SELECT `+triggerColumns+` FROM triggers WHERE kind = ? ORDER BY name`, kind) + } + if err != nil { + return nil, fmt.Errorf("query triggers: %w", err) + } + defer rows.Close() + + out := []Trigger{} + for rows.Next() { + t, err := scanTrigger(rows) + if err != nil { + return nil, fmt.Errorf("scan trigger: %w", err) + } + out = append(out, t) + } + return out, rows.Err() +} + +// TriggerWithBindingCount projects a Trigger plus its current binding +// count in a single round-trip. Used by /api/triggers list rendering so +// the response avoids one COUNT(*) per trigger row. +type TriggerWithBindingCount struct { + Trigger + BindingCount int +} + +// ListTriggersWithBindingCount returns every trigger ordered by name +// with the count of its bindings joined in. Optional kind filter. +func (s *Store) ListTriggersWithBindingCount(kind string) ([]TriggerWithBindingCount, error) { + const base = ` + SELECT t.id, t.kind, t.name, t.config, + t.webhook_secret, t.webhook_signing_secret, t.webhook_require_signature, + t.created_at, t.updated_at, + COALESCE(b.cnt, 0) + FROM triggers t + LEFT JOIN ( + SELECT trigger_id, COUNT(*) AS cnt + FROM workload_trigger_bindings + GROUP BY trigger_id + ) b ON b.trigger_id = t.id` + var rows *sql.Rows + var err error + if kind == "" { + rows, err = s.db.Query(base + ` ORDER BY t.name`) + } else { + rows, err = s.db.Query(base+` WHERE t.kind = ? ORDER BY t.name`, kind) + } + if err != nil { + return nil, fmt.Errorf("query triggers with binding count: %w", err) + } + defer rows.Close() + + out := []TriggerWithBindingCount{} + for rows.Next() { + var t Trigger + var requireSig int + var count int + if err := rows.Scan(&t.ID, &t.Kind, &t.Name, &t.Config, + &t.WebhookSecret, &t.WebhookSigningSecret, &requireSig, + &t.CreatedAt, &t.UpdatedAt, &count); err != nil { + return nil, fmt.Errorf("scan trigger+count: %w", err) + } + t.WebhookRequireSignature = requireSig != 0 + out = append(out, TriggerWithBindingCount{Trigger: t, BindingCount: count}) + } + return out, rows.Err() +} + +// UpdateTrigger updates the mutable fields of a trigger. Kind is +// immutable post-create — changing kinds would invalidate every +// binding's interpretation of binding_config. +func (s *Store) UpdateTrigger(t Trigger) error { + t.UpdatedAt = Now() + if t.Config == "" { + t.Config = "{}" + } + result, err := s.db.Exec( + `UPDATE triggers SET name=?, config=?, + webhook_secret=?, webhook_signing_secret=?, webhook_require_signature=?, + updated_at=? + WHERE id=?`, + t.Name, t.Config, + t.WebhookSecret, t.WebhookSigningSecret, BoolToInt(t.WebhookRequireSignature), + t.UpdatedAt, t.ID, + ) + if err != nil { + return fmt.Errorf("update trigger: %w", translateSQLError(err)) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("trigger %s: %w", t.ID, ErrNotFound) + } + return nil +} + +// DeleteTrigger removes a trigger row. Bindings cascade away via FK. +func (s *Store) DeleteTrigger(id string) error { + result, err := s.db.Exec(`DELETE FROM triggers WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete trigger: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("trigger %s: %w", id, ErrNotFound) + } + return nil +} + +// CreateTriggerWithBindingTx atomically creates a trigger row and +// binds it to a workload. Used by the workload-side inline-create-and- +// bind endpoint so a binding-insert failure does not leave an orphan +// trigger row behind. Returns the persisted trigger and binding. +func (s *Store) CreateTriggerWithBindingTx(t Trigger, b WorkloadTriggerBinding) (Trigger, WorkloadTriggerBinding, error) { + tx, err := s.db.Begin() + if err != nil { + return Trigger{}, WorkloadTriggerBinding{}, fmt.Errorf("begin: %w", err) + } + defer func() { _ = tx.Rollback() }() + + if t.ID == "" { + t.ID = uuid.New().String() + } + if t.Config == "" { + t.Config = "{}" + } + t.CreatedAt = Now() + t.UpdatedAt = t.CreatedAt + if _, err := tx.Exec( + `INSERT INTO triggers (`+triggerColumns+`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + t.ID, t.Kind, t.Name, t.Config, + t.WebhookSecret, t.WebhookSigningSecret, BoolToInt(t.WebhookRequireSignature), + t.CreatedAt, t.UpdatedAt, + ); err != nil { + return Trigger{}, WorkloadTriggerBinding{}, fmt.Errorf("insert trigger: %w", translateSQLError(err)) + } + + if b.ID == "" { + b.ID = uuid.New().String() + } + if b.BindingConfig == "" { + b.BindingConfig = "{}" + } + b.TriggerID = t.ID + b.CreatedAt = t.CreatedAt + b.UpdatedAt = t.UpdatedAt + if _, err := tx.Exec( + `INSERT INTO workload_trigger_bindings (`+bindingColumns+`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + b.ID, b.WorkloadID, b.TriggerID, b.BindingConfig, + BoolToInt(b.Enabled), b.SortOrder, b.CreatedAt, b.UpdatedAt, + ); err != nil { + return Trigger{}, WorkloadTriggerBinding{}, fmt.Errorf("insert binding: %w", translateSQLError(err)) + } + + if err := tx.Commit(); err != nil { + return Trigger{}, WorkloadTriggerBinding{}, fmt.Errorf("commit: %w", err) + } + return t, b, nil +} + +// SetTriggerWebhookSecret rotates the inbound webhook URL secret. Pass +// empty string to disable webhook ingress for this trigger. +func (s *Store) SetTriggerWebhookSecret(id, secret string) error { + result, err := s.db.Exec( + `UPDATE triggers SET webhook_secret=?, updated_at=? WHERE id=?`, + secret, Now(), id, + ) + if err != nil { + return fmt.Errorf("update trigger webhook_secret: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("trigger %s: %w", id, ErrNotFound) + } + return nil +} + +// EnsureTriggerWebhookSecret returns the current secret, generating one +// lazily for triggers that have none. Mirrors the workload helper. +func (s *Store) EnsureTriggerWebhookSecret(id string) (string, error) { + t, err := s.GetTriggerByID(id) + if err != nil { + return "", err + } + if t.WebhookSecret != "" { + return t.WebhookSecret, nil + } + secret := generateWebhookSecret() + if err := s.SetTriggerWebhookSecret(id, secret); err != nil { + return "", err + } + return secret, nil +} diff --git a/internal/store/workload_trigger_bindings.go b/internal/store/workload_trigger_bindings.go new file mode 100644 index 0000000..61d6e9a --- /dev/null +++ b/internal/store/workload_trigger_bindings.go @@ -0,0 +1,270 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + + "github.com/google/uuid" +) + +const bindingColumns = `id, workload_id, trigger_id, binding_config, + enabled, sort_order, created_at, updated_at` + +func scanBinding(s rowScanner) (WorkloadTriggerBinding, error) { + var b WorkloadTriggerBinding + var enabled int + if err := s.Scan(&b.ID, &b.WorkloadID, &b.TriggerID, &b.BindingConfig, + &enabled, &b.SortOrder, &b.CreatedAt, &b.UpdatedAt); err != nil { + return WorkloadTriggerBinding{}, err + } + b.Enabled = enabled != 0 + return b, nil +} + +// CreateBinding inserts a binding row. The (workload_id, trigger_id) pair +// must be unique — re-binding an existing pair is an UpdateBinding call, +// not an insert. +func (s *Store) CreateBinding(b WorkloadTriggerBinding) (WorkloadTriggerBinding, error) { + if b.ID == "" { + b.ID = uuid.New().String() + } + if b.BindingConfig == "" { + b.BindingConfig = "{}" + } + b.CreatedAt = Now() + b.UpdatedAt = b.CreatedAt + _, err := s.db.Exec( + `INSERT INTO workload_trigger_bindings (`+bindingColumns+`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + b.ID, b.WorkloadID, b.TriggerID, b.BindingConfig, + BoolToInt(b.Enabled), b.SortOrder, b.CreatedAt, b.UpdatedAt, + ) + if err != nil { + return WorkloadTriggerBinding{}, fmt.Errorf("insert binding: %w", translateSQLError(err)) + } + return b, nil +} + +// GetBindingByID returns one binding by its primary key. +func (s *Store) GetBindingByID(id string) (WorkloadTriggerBinding, error) { + b, err := scanBinding(s.db.QueryRow( + `SELECT `+bindingColumns+` FROM workload_trigger_bindings WHERE id = ?`, id, + )) + if errors.Is(err, sql.ErrNoRows) { + return WorkloadTriggerBinding{}, fmt.Errorf("binding %s: %w", id, ErrNotFound) + } + if err != nil { + return WorkloadTriggerBinding{}, fmt.Errorf("query binding: %w", err) + } + return b, nil +} + +// ListBindingsForWorkload returns every trigger bound to a workload, +// ordered by sort_order then created_at for stable display. +func (s *Store) ListBindingsForWorkload(workloadID string) ([]WorkloadTriggerBinding, error) { + rows, err := s.db.Query( + `SELECT `+bindingColumns+` FROM workload_trigger_bindings + WHERE workload_id = ? ORDER BY sort_order, created_at`, + workloadID, + ) + if err != nil { + return nil, fmt.Errorf("query bindings for workload: %w", err) + } + defer rows.Close() + + out := []WorkloadTriggerBinding{} + for rows.Next() { + b, err := scanBinding(rows) + if err != nil { + return nil, fmt.Errorf("scan binding: %w", err) + } + out = append(out, b) + } + return out, rows.Err() +} + +// ListBindingsForTrigger returns every workload bound to a trigger, +// ordered by sort_order. Used by the webhook fan-out path. +func (s *Store) ListBindingsForTrigger(triggerID string) ([]WorkloadTriggerBinding, error) { + rows, err := s.db.Query( + `SELECT `+bindingColumns+` FROM workload_trigger_bindings + WHERE trigger_id = ? ORDER BY sort_order, created_at`, + triggerID, + ) + if err != nil { + return nil, fmt.Errorf("query bindings for trigger: %w", err) + } + defer rows.Close() + + out := []WorkloadTriggerBinding{} + for rows.Next() { + b, err := scanBinding(rows) + if err != nil { + return nil, fmt.Errorf("scan binding: %w", err) + } + out = append(out, b) + } + return out, rows.Err() +} + +// GetBindingByPair returns the binding for an exact (workload, trigger) +// pair. ErrNotFound when missing. +func (s *Store) GetBindingByPair(workloadID, triggerID string) (WorkloadTriggerBinding, error) { + b, err := scanBinding(s.db.QueryRow( + `SELECT `+bindingColumns+` FROM workload_trigger_bindings + WHERE workload_id = ? AND trigger_id = ?`, + workloadID, triggerID, + )) + if errors.Is(err, sql.ErrNoRows) { + return WorkloadTriggerBinding{}, ErrNotFound + } + if err != nil { + return WorkloadTriggerBinding{}, fmt.Errorf("query binding by pair: %w", err) + } + return b, nil +} + +// UpdateBinding updates the mutable fields of a binding (binding_config, +// enabled, sort_order). The (workload_id, trigger_id) pair is immutable +// — to re-target, delete and re-insert. +func (s *Store) UpdateBinding(b WorkloadTriggerBinding) error { + b.UpdatedAt = Now() + if b.BindingConfig == "" { + b.BindingConfig = "{}" + } + result, err := s.db.Exec( + `UPDATE workload_trigger_bindings + SET binding_config=?, enabled=?, sort_order=?, updated_at=? + WHERE id=?`, + b.BindingConfig, BoolToInt(b.Enabled), b.SortOrder, b.UpdatedAt, b.ID, + ) + if err != nil { + return fmt.Errorf("update binding: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("binding %s: %w", b.ID, ErrNotFound) + } + return nil +} + +// DeleteBinding removes a binding row. +func (s *Store) DeleteBinding(id string) error { + result, err := s.db.Exec( + `DELETE FROM workload_trigger_bindings WHERE id = ?`, id, + ) + if err != nil { + return fmt.Errorf("delete binding: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("binding %s: %w", id, ErrNotFound) + } + return nil +} + +// DeleteBindingsForWorkload removes every binding for a workload. +// Idempotent — returns nil even if no rows existed. +func (s *Store) DeleteBindingsForWorkload(workloadID string) error { + _, err := s.db.Exec( + `DELETE FROM workload_trigger_bindings WHERE workload_id = ?`, + workloadID, + ) + if err != nil { + return fmt.Errorf("delete bindings for workload: %w", err) + } + return nil +} + +// BindingWithNames carries a binding plus the human names of its +// workload + trigger so the API listing endpoints render in one +// round-trip instead of N+1 lookups. +type BindingWithNames struct { + WorkloadTriggerBinding + WorkloadName string + TriggerKind string + TriggerName string +} + +// ListBindingsForTriggerWithNames is the join-aware variant of +// ListBindingsForTrigger that also surfaces the workload's name. +func (s *Store) ListBindingsForTriggerWithNames(triggerID string) ([]BindingWithNames, error) { + rows, err := s.db.Query( + `SELECT b.id, b.workload_id, b.trigger_id, b.binding_config, + b.enabled, b.sort_order, b.created_at, b.updated_at, + COALESCE(w.name, '') + FROM workload_trigger_bindings b + LEFT JOIN workloads w ON w.id = b.workload_id + WHERE b.trigger_id = ? + ORDER BY b.sort_order, b.created_at`, + triggerID, + ) + if err != nil { + return nil, fmt.Errorf("query bindings+names for trigger: %w", err) + } + defer rows.Close() + out := []BindingWithNames{} + for rows.Next() { + var b WorkloadTriggerBinding + var enabled int + var workloadName string + if err := rows.Scan(&b.ID, &b.WorkloadID, &b.TriggerID, &b.BindingConfig, + &enabled, &b.SortOrder, &b.CreatedAt, &b.UpdatedAt, &workloadName); err != nil { + return nil, fmt.Errorf("scan binding+name: %w", err) + } + b.Enabled = enabled != 0 + out = append(out, BindingWithNames{WorkloadTriggerBinding: b, WorkloadName: workloadName}) + } + return out, rows.Err() +} + +// ListBindingsForWorkloadWithNames is the join-aware variant of +// ListBindingsForWorkload that also surfaces the trigger's kind + name. +func (s *Store) ListBindingsForWorkloadWithNames(workloadID string) ([]BindingWithNames, error) { + rows, err := s.db.Query( + `SELECT b.id, b.workload_id, b.trigger_id, b.binding_config, + b.enabled, b.sort_order, b.created_at, b.updated_at, + COALESCE(t.kind, ''), COALESCE(t.name, '') + FROM workload_trigger_bindings b + LEFT JOIN triggers t ON t.id = b.trigger_id + WHERE b.workload_id = ? + ORDER BY b.sort_order, b.created_at`, + workloadID, + ) + if err != nil { + return nil, fmt.Errorf("query bindings+names for workload: %w", err) + } + defer rows.Close() + out := []BindingWithNames{} + for rows.Next() { + var b WorkloadTriggerBinding + var enabled int + var kind, name string + if err := rows.Scan(&b.ID, &b.WorkloadID, &b.TriggerID, &b.BindingConfig, + &enabled, &b.SortOrder, &b.CreatedAt, &b.UpdatedAt, &kind, &name); err != nil { + return nil, fmt.Errorf("scan binding+trigger names: %w", err) + } + b.Enabled = enabled != 0 + out = append(out, BindingWithNames{ + WorkloadTriggerBinding: b, + TriggerKind: kind, + TriggerName: name, + }) + } + return out, rows.Err() +} + +// CountBindingsForTrigger returns the number of bindings a trigger has. +// Used by the UI to decide whether deleting a trigger is safe. +func (s *Store) CountBindingsForTrigger(triggerID string) (int, error) { + var n int + err := s.db.QueryRow( + `SELECT COUNT(*) FROM workload_trigger_bindings WHERE trigger_id = ?`, + triggerID, + ).Scan(&n) + if err != nil { + return 0, fmt.Errorf("count bindings for trigger: %w", err) + } + return n, nil +} diff --git a/internal/webhook/handler.go b/internal/webhook/handler.go index ca8a8eb..e9bfe5e 100644 --- a/internal/webhook/handler.go +++ b/internal/webhook/handler.go @@ -13,7 +13,6 @@ import ( "net/http" "strings" "sync" - "time" "github.com/go-chi/chi/v5" @@ -297,11 +296,16 @@ func (h *Handler) Drain() { // // POST /{secret} — per-project deploy trigger (legacy) // POST /sites/{secret} — per-site sync trigger (legacy) -// POST /workloads/{secret} — plugin-native workload trigger +// POST /triggers/{secret} — first-class trigger fan-out to all bound workloads +// +// The legacy POST /workloads/{secret} route was dropped in the +// trigger-split refactor. Existing inbound webhook secrets were lifted +// into trigger rows by the boot backfill, so the same secret value +// works at /triggers/{secret} after the upgrade. func (h *Handler) Route() chi.Router { r := chi.NewRouter() r.Post("/sites/{secret}", h.handleSiteWebhook) - r.Post("/workloads/{secret}", h.handlePluginWorkloadWebhook) + r.Post("/triggers/{secret}", h.handleTriggerWebhook) r.Post("/{secret}", h.handleWebhook) return r } @@ -675,170 +679,6 @@ func (h *Handler) handleSiteWebhook(w http.ResponseWriter, r *http.Request) { }) } -// handlePluginWorkloadWebhook processes an inbound webhook for a -// plugin-native workload. -// -// URL: POST /api/webhook/workloads/{secret} -// -// The secret resolves to exactly one workload row whose Source + -// Trigger kinds determine how the payload is interpreted. The body -// shape is the same as the legacy project/site webhooks (Image for -// registry pushes, Ref for git pushes) — Gitea / GitHub / generic -// registry CIs can target this URL without payload changes. The -// workload's configured Trigger plugin then decides whether the event -// fires a deploy. -func (h *Handler) handlePluginWorkloadWebhook(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - delivery := store.WebhookDelivery{ - TargetType: "workload", - SourceIP: clientIP(r), - SignatureState: sigStateUnconfigured, - StatusCode: http.StatusOK, - Outcome: outcomeSkip, - } - defer func() { h.recordDelivery(delivery) }() - - if h.plugins == nil { - delivery.StatusCode = http.StatusServiceUnavailable - delivery.Outcome = outcomeError - delivery.Detail = "plugin dispatcher not wired" - respondWebhookError(w, http.StatusServiceUnavailable, "plugin dispatcher not wired") - return - } - - secret := chi.URLParam(r, "secret") - if secret == "" { - delivery.StatusCode = http.StatusNotFound - delivery.Outcome = outcomeNotFound - http.NotFound(w, r) - return - } - - wl, err := h.store.GetWorkloadByWebhookSecret(secret) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - delivery.StatusCode = http.StatusNotFound - delivery.Outcome = outcomeNotFound - delivery.Detail = "unknown webhook secret" - http.NotFound(w, r) - return - } - slog.Error("webhook: workload lookup failed", "error", err) - delivery.StatusCode = http.StatusNotFound - delivery.Outcome = outcomeError - delivery.Detail = "lookup failed" - http.NotFound(w, r) - return - } - if wl.SourceKind == "" || wl.TriggerKind == "" { - // Legacy workload row whose secret happens to also be valid on the - // legacy path. Tell the caller they hit the wrong route rather - // than silently 404-ing — avoids head-scratching. - delivery.StatusCode = http.StatusBadRequest - delivery.Outcome = outcomeBadRequest - delivery.Detail = "workload is legacy; use the project or site route" - respondWebhookError(w, http.StatusBadRequest, "workload is not plugin-native") - return - } - delivery.TargetID = wl.ID - delivery.TargetName = wl.Name - - body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodyBytes)) - if err != nil { - delivery.StatusCode = http.StatusBadRequest - delivery.Outcome = outcomeBadRequest - delivery.Detail = "failed to read request body" - respondWebhookError(w, http.StatusBadRequest, "failed to read request body") - return - } - delivery.BodySize = len(body) - - header := r.Header.Get(signatureHeader) - verified, attempted := verifyHMAC(wl.WebhookSigningSecret, body, header) - delivery.SignatureState = signatureStateFor(wl.WebhookSigningSecret, header, verified, attempted) - if wl.WebhookRequireSignature && !verified { - slog.Warn("webhook: workload signature required but invalid/missing", "workload", wl.Name) - delivery.StatusCode = http.StatusUnauthorized - delivery.Outcome = outcomeRejected - delivery.Detail = "invalid or missing signature" - respondWebhookError(w, http.StatusUnauthorized, "invalid or missing signature") - return - } - if attempted && !verified { - slog.Warn("webhook: workload bad signature", "workload", wl.Name) - delivery.StatusCode = http.StatusUnauthorized - delivery.Outcome = outcomeRejected - delivery.Detail = "invalid signature" - respondWebhookError(w, http.StatusUnauthorized, "invalid signature") - return - } - - evt, err := buildInboundEvent(body, r.Header) - if err != nil { - delivery.StatusCode = http.StatusBadRequest - delivery.Outcome = outcomeBadRequest - delivery.Detail = err.Error() - respondWebhookError(w, http.StatusBadRequest, err.Error()) - return - } - - trig, err := plugin.GetTrigger(wl.TriggerKind) - if err != nil { - slog.Warn("webhook: trigger plugin not registered", - "workload", wl.Name, "trigger", wl.TriggerKind, "error", err) - delivery.StatusCode = http.StatusInternalServerError - delivery.Outcome = outcomeError - delivery.Detail = "trigger plugin missing" - respondWebhookError(w, http.StatusInternalServerError, "trigger plugin missing") - return - } - - pwl := toPluginWorkload(wl) - intent, err := trig.Match(ctx, h.plugins.PluginDeps(), pwl, evt) - if err != nil { - slog.Warn("webhook: trigger match error", - "workload", wl.Name, "trigger", wl.TriggerKind, "error", err) - delivery.StatusCode = http.StatusInternalServerError - delivery.Outcome = outcomeError - delivery.Detail = "trigger match error" - respondWebhookError(w, http.StatusInternalServerError, "trigger match error") - return - } - if intent == nil { - delivery.Detail = "trigger declined (no match)" - respondWebhookJSON(w, http.StatusOK, map[string]any{ - "success": true, "deploy": false, "workload": wl.Name, - "reason": "trigger declined", - }) - return - } - if intent.TriggeredAt.IsZero() { - intent.TriggeredAt = time.Now().UTC() - } - if intent.TriggeredBy == "" { - intent.TriggeredBy = "webhook" - } - - if err := h.plugins.DispatchPlugin(ctx, pwl, *intent); err != nil { - slog.Warn("webhook: plugin dispatch failed", - "workload", wl.Name, "error", err) - delivery.StatusCode = http.StatusInternalServerError - delivery.Outcome = outcomeError - delivery.Detail = "dispatch failed; see server logs" - respondWebhookError(w, http.StatusInternalServerError, "dispatch failed; see server logs") - return - } - delivery.Outcome = outcomeDeploy - delivery.Detail = fmt.Sprintf("reason=%s ref=%s", intent.Reason, intent.Reference) - slog.Info("webhook: triggered plugin deploy", - "workload", wl.Name, "trigger", wl.TriggerKind, "reason", intent.Reason) - respondWebhookJSON(w, http.StatusOK, map[string]any{ - "success": true, "deploy": true, - "workload": wl.Name, "reference": intent.Reference, - }) -} - // buildInboundEvent normalizes the incoming webhook body into the // plugin.InboundEvent shape. The dispatch order is: // diff --git a/internal/webhook/trigger_handler.go b/internal/webhook/trigger_handler.go new file mode 100644 index 0000000..91cb117 --- /dev/null +++ b/internal/webhook/trigger_handler.go @@ -0,0 +1,288 @@ +package webhook + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "sync" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/alexei/tinyforge/internal/store" + "github.com/alexei/tinyforge/internal/workload/plugin" +) + +// maxTriggerFanOutConcurrency caps how many bindings dispatch in +// parallel for a single trigger webhook. Sequential fan-out would hold +// the request goroutine for the sum of every binding's deploy time — +// minutes for an N-binding trigger. Bounding to 4 keeps wall-clock +// roughly N/4 * deploy_time without saturating the docker daemon (which +// already serializes pulls). +const maxTriggerFanOutConcurrency = 4 + +// bindingResult is the per-binding entry in the trigger fan-out +// response body. +type bindingResult struct { + Workload string `json:"workload"` + Deployed bool `json:"deployed"` + Reason string `json:"reason,omitempty"` +} + +// handleTriggerWebhook processes an inbound webhook for a first-class +// Trigger record. The secret resolves to one Trigger; the Trigger then +// fans out to every enabled workload binding. Each binding gets its +// effective config (trigger.config + binding.binding_config merged) and +// runs through the trigger plugin's Match independently — one binding +// firing does not affect another. +// +// URL: POST /api/webhook/triggers/{secret} +// +// Response shape: aggregate counts so a CI can tell at a glance whether +// any deploys fired (status 200 + deploys=N) without parsing per-binding +// detail. Errors per-binding are logged at warn level but do not fail +// the whole request — one broken workload should not block the others. +func (h *Handler) handleTriggerWebhook(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + delivery := store.WebhookDelivery{ + TargetType: "trigger", + SourceIP: clientIP(r), + SignatureState: sigStateUnconfigured, + StatusCode: http.StatusOK, + Outcome: outcomeSkip, + } + defer func() { h.recordDelivery(delivery) }() + + if h.plugins == nil { + delivery.StatusCode = http.StatusServiceUnavailable + delivery.Outcome = outcomeError + delivery.Detail = "plugin dispatcher not wired" + respondWebhookError(w, http.StatusServiceUnavailable, "plugin dispatcher not wired") + return + } + + secret := chi.URLParam(r, "secret") + if secret == "" { + delivery.StatusCode = http.StatusNotFound + delivery.Outcome = outcomeNotFound + http.NotFound(w, r) + return + } + + trg, err := h.store.GetTriggerByWebhookSecret(secret) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + delivery.StatusCode = http.StatusNotFound + delivery.Outcome = outcomeNotFound + delivery.Detail = "unknown webhook secret" + http.NotFound(w, r) + return + } + slog.Error("webhook: trigger lookup failed", "error", err) + delivery.StatusCode = http.StatusNotFound + delivery.Outcome = outcomeError + delivery.Detail = "lookup failed" + http.NotFound(w, r) + return + } + delivery.TargetID = trg.ID + delivery.TargetName = trg.Name + + body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodyBytes)) + if err != nil { + delivery.StatusCode = http.StatusBadRequest + delivery.Outcome = outcomeBadRequest + delivery.Detail = "failed to read request body" + respondWebhookError(w, http.StatusBadRequest, "failed to read request body") + return + } + delivery.BodySize = len(body) + + header := r.Header.Get(signatureHeader) + verified, attempted := verifyHMAC(trg.WebhookSigningSecret, body, header) + delivery.SignatureState = signatureStateFor(trg.WebhookSigningSecret, header, verified, attempted) + if trg.WebhookRequireSignature && !verified { + slog.Warn("webhook: trigger signature required but invalid/missing", "trigger", trg.Name) + delivery.StatusCode = http.StatusUnauthorized + delivery.Outcome = outcomeRejected + delivery.Detail = "invalid or missing signature" + respondWebhookError(w, http.StatusUnauthorized, "invalid or missing signature") + return + } + if attempted && !verified { + slog.Warn("webhook: trigger bad signature", "trigger", trg.Name) + delivery.StatusCode = http.StatusUnauthorized + delivery.Outcome = outcomeRejected + delivery.Detail = "invalid signature" + respondWebhookError(w, http.StatusUnauthorized, "invalid signature") + return + } + + evt, err := buildInboundEvent(body, r.Header) + if err != nil { + delivery.StatusCode = http.StatusBadRequest + delivery.Outcome = outcomeBadRequest + delivery.Detail = err.Error() + respondWebhookError(w, http.StatusBadRequest, err.Error()) + return + } + + trigPlugin, err := plugin.GetTrigger(trg.Kind) + if err != nil { + slog.Warn("webhook: trigger plugin not registered", + "trigger", trg.Name, "kind", trg.Kind, "error", err) + delivery.StatusCode = http.StatusInternalServerError + delivery.Outcome = outcomeError + delivery.Detail = "trigger plugin missing" + respondWebhookError(w, http.StatusInternalServerError, "trigger plugin missing") + return + } + + bindings, err := h.store.ListBindingsForTrigger(trg.ID) + if err != nil { + slog.Error("webhook: list bindings failed", "trigger", trg.Name, "error", err) + delivery.StatusCode = http.StatusInternalServerError + delivery.Outcome = outcomeError + delivery.Detail = "list bindings failed" + respondWebhookError(w, http.StatusInternalServerError, "list bindings failed") + return + } + + results := h.fanOutBindings(ctx, trg, trigPlugin, bindings, evt) + var deployed, skipped, noMatch, errored int + for _, r := range results { + switch { + case r.Deployed: + deployed++ + case r.Reason == "binding disabled": + skipped++ + case r.Reason == "no match": + noMatch++ + default: + errored++ + } + } + + switch { + case deployed > 0: + delivery.Outcome = outcomeDeploy + delivery.Detail = fmt.Sprintf("deployed=%d of %d (errored=%d, skipped=%d)", + deployed, len(results), errored, skipped) + case errored > 0: + delivery.Outcome = outcomeError + delivery.Detail = fmt.Sprintf("errored=%d of %d", errored, len(results)) + case skipped == len(results): + delivery.Detail = "all bindings disabled" + case noMatch == len(results)-skipped: + delivery.Detail = "no binding matched" + default: + delivery.Detail = fmt.Sprintf("matched=0 skipped=%d errored=%d", skipped, errored) + } + respondWebhookJSON(w, http.StatusOK, map[string]any{ + "success": true, + "trigger": trg.Name, + "deployed": deployed, + "bindings": results, + }) +} + +// fanOutBindings dispatches every binding through fireBinding with at +// most maxTriggerFanOutConcurrency goroutines in flight. Order of the +// returned slice matches the input bindings slice so callers can rely +// on positional correlation. +// +// Disabled bindings short-circuit on the orchestrator goroutine — they +// don't take a worker slot, leaving the pool free for real dispatches. +// Workload-missing rows are recorded as errors and also skip the pool. +func (h *Handler) fanOutBindings( + ctx context.Context, + trg store.Trigger, + trigPlugin plugin.Trigger, + bindings []store.WorkloadTriggerBinding, + evt plugin.InboundEvent, +) []bindingResult { + results := make([]bindingResult, len(bindings)) + concurrency := maxTriggerFanOutConcurrency + if len(bindings) < concurrency { + concurrency = len(bindings) + } + if concurrency < 1 { + concurrency = 1 + } + sem := make(chan struct{}, concurrency) + var wg sync.WaitGroup + for i, b := range bindings { + if !b.Enabled { + results[i] = bindingResult{Workload: b.WorkloadID, Deployed: false, Reason: "binding disabled"} + continue + } + row, lookupErr := h.store.GetWorkloadByID(b.WorkloadID) + if lookupErr != nil { + slog.Warn("webhook: bound workload missing", + "trigger", trg.Name, "workload", b.WorkloadID, "error", lookupErr) + results[i] = bindingResult{Workload: b.WorkloadID, Deployed: false, Reason: "workload missing"} + continue + } + wg.Add(1) + sem <- struct{}{} + go func(idx int, binding store.WorkloadTriggerBinding, wl store.Workload) { + defer wg.Done() + defer func() { <-sem }() + fired, reason := h.fireBinding(ctx, trg, trigPlugin, wl, binding, evt) + results[idx] = bindingResult{Workload: wl.Name, Deployed: fired, Reason: reason} + }(i, b, row) + } + wg.Wait() + return results +} + +// fireBinding runs Match for one binding and dispatches if intent. +// Returns (fired, human-readable reason). Errors are logged but the +// reason is kept generic on the wire so a malformed binding does not +// leak internals. +func (h *Handler) fireBinding( + ctx context.Context, + trg store.Trigger, + trigPlugin plugin.Trigger, + row store.Workload, + b store.WorkloadTriggerBinding, + evt plugin.InboundEvent, +) (bool, string) { + pwl := toPluginWorkload(row) + pwl, err := plugin.WithEffectiveTrigger(pwl, trg.Kind, + json.RawMessage(trg.Config), json.RawMessage(b.BindingConfig)) + if err != nil { + slog.Warn("webhook: merge effective trigger config failed", + "trigger", trg.Name, "workload", row.Name, "error", err) + return false, "config merge error" + } + intent, err := trigPlugin.Match(ctx, h.plugins.PluginDeps(), pwl, evt) + if err != nil { + slog.Warn("webhook: trigger match error", + "trigger", trg.Name, "workload", row.Name, "error", err) + return false, "match error" + } + if intent == nil { + return false, "no match" + } + if intent.TriggeredAt.IsZero() { + intent.TriggeredAt = time.Now().UTC() + } + if intent.TriggeredBy == "" { + intent.TriggeredBy = "trigger-webhook" + } + if err := h.plugins.DispatchPlugin(ctx, pwl, *intent); err != nil { + slog.Warn("webhook: dispatch failed", + "trigger", trg.Name, "workload", row.Name, "error", err) + return false, "dispatch failed" + } + slog.Info("webhook: triggered deploy via trigger fan-out", + "trigger", trg.Name, "workload", row.Name, "reason", intent.Reason) + return true, intent.Reason +} + diff --git a/internal/workload/plugin/binding.go b/internal/workload/plugin/binding.go new file mode 100644 index 0000000..f8ec463 --- /dev/null +++ b/internal/workload/plugin/binding.go @@ -0,0 +1,57 @@ +package plugin + +import ( + "encoding/json" + "fmt" +) + +// MergeJSONConfig merges override on top of base at the top-level +// (binding fields win, base fields fill the rest). Both inputs are +// expected to be JSON objects ("{}" when empty); arrays or scalars are +// rejected because trigger and binding configs are always objects. +// +// Always returns a freshly allocated slice so callers may freely mutate +// the result without affecting base. The fast-path (override empty) +// returns a defensive copy of base for the same reason. +func MergeJSONConfig(base, override json.RawMessage) (json.RawMessage, error) { + if len(override) == 0 || string(override) == "{}" { + if len(base) == 0 { + return json.RawMessage("{}"), nil + } + return append(json.RawMessage(nil), base...), nil + } + if len(base) == 0 || string(base) == "{}" { + return append(json.RawMessage(nil), override...), nil + } + baseMap := map[string]json.RawMessage{} + if err := json.Unmarshal(base, &baseMap); err != nil { + return nil, fmt.Errorf("merge config: base is not a JSON object: %w", err) + } + overMap := map[string]json.RawMessage{} + if err := json.Unmarshal(override, &overMap); err != nil { + return nil, fmt.Errorf("merge config: override is not a JSON object: %w", err) + } + for k, v := range overMap { + baseMap[k] = v + } + out, err := json.Marshal(baseMap) + if err != nil { + return nil, fmt.Errorf("merge config: re-marshal: %w", err) + } + return out, nil +} + +// WithEffectiveTrigger returns a copy of w with TriggerKind and +// TriggerConfig set from a resolved (trigger, binding) pair. The +// existing Trigger.Match contract reads w.TriggerConfig via +// TriggerConfigOf[T], so this is the seam that lets the trigger plugins +// stay unchanged after the trigger-split refactor. +func WithEffectiveTrigger(w Workload, kind string, triggerConfig, bindingConfig json.RawMessage) (Workload, error) { + merged, err := MergeJSONConfig(triggerConfig, bindingConfig) + if err != nil { + return Workload{}, err + } + w.TriggerKind = kind + w.TriggerConfig = merged + return w, nil +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index c25e6f7..fe37ca2 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1186,6 +1186,128 @@ export function getHookKindSchema(kind: string, signal?: AbortSignal): Promise(`/api/hooks/kinds/${kind}/schema`, signal); } +// ── Triggers (first-class redeploy signal sources) ────────────────── + +export interface RedeployTrigger { + id: string; + kind: string; + name: string; + config: unknown; + webhook_enabled: boolean; + webhook_require_signature: boolean; + binding_count: number; + created_at: string; + updated_at: string; +} + +export interface TriggerWebhook { + url: string; + secret: string; + webhook_require_signature: boolean; +} + +export interface TriggerBinding { + id: string; + workload_id: string; + workload_name: string; + trigger_id: string; + binding_config: unknown; + enabled: boolean; + sort_order: number; + created_at: string; + updated_at: string; +} + +export interface WorkloadTriggerBinding extends TriggerBinding { + trigger_kind: string; + trigger_name: string; +} + +export interface TriggerInput { + kind: string; + name: string; + config: unknown; + webhook_enabled: boolean; + webhook_require_signature: boolean; +} + +export interface BindingInput { + workload_id: string; + binding_config?: unknown; + enabled?: boolean; + sort_order?: number; +} + +export interface WorkloadBindInput { + trigger_id?: string; + binding_config?: unknown; + enabled?: boolean; + sort_order?: number; + inline?: TriggerInput; +} + +export function listTriggers(kind?: string, signal?: AbortSignal): Promise { + const path = kind ? `/api/triggers?kind=${encodeURIComponent(kind)}` : '/api/triggers'; + return get(path, signal); +} + +export function getTrigger(id: string, signal?: AbortSignal): Promise { + return get(`/api/triggers/${id}`, signal); +} + +export function createTrigger(body: TriggerInput): Promise { + return post('/api/triggers', body); +} + +export function updateTrigger(id: string, body: TriggerInput): Promise { + return put(`/api/triggers/${id}`, body); +} + +export function deleteTrigger(id: string): Promise<{ deleted: string }> { + return del<{ deleted: string }>(`/api/triggers/${id}`); +} + +export function getTriggerWebhook(id: string, signal?: AbortSignal): Promise { + return get(`/api/triggers/${id}/webhook`, signal); +} + +export function regenerateTriggerWebhook(id: string): Promise<{ secret: string; url: string }> { + return post<{ secret: string; url: string }>(`/api/triggers/${id}/webhook/regenerate`); +} + +export function listBindingsForTrigger(id: string, signal?: AbortSignal): Promise { + return get(`/api/triggers/${id}/bindings`, signal); +} + +export function bindWorkloadToTrigger(triggerId: string, body: BindingInput): Promise { + return post(`/api/triggers/${triggerId}/bindings`, body); +} + +export function listBindingsForWorkload( + workloadId: string, + signal?: AbortSignal +): Promise { + return get(`/api/workloads/${workloadId}/triggers`, signal); +} + +export function bindTriggerToWorkload( + workloadId: string, + body: WorkloadBindInput +): Promise { + return post(`/api/workloads/${workloadId}/triggers`, body); +} + +export function updateBinding( + id: string, + body: { binding_config?: unknown; enabled?: boolean; sort_order?: number } +): Promise { + return put(`/api/bindings/${id}`, body); +} + +export function deleteBinding(id: string): Promise<{ deleted: string }> { + return del<{ deleted: string }>(`/api/bindings/${id}`); +} + export interface WorkloadChainNode { id: string; name: string; diff --git a/web/src/lib/components/TriggerKindForm.svelte b/web/src/lib/components/TriggerKindForm.svelte new file mode 100644 index 0000000..1a0d487 --- /dev/null +++ b/web/src/lib/components/TriggerKindForm.svelte @@ -0,0 +1,714 @@ + + + + + +
+ {#if showKindPicker} +
+ {$t('redeployTriggers.form.kindLabel')} +
+ {#each KNOWN_KINDS as k (k)} + + {/each} +
+
+ {:else} +
+ {$t(`redeployTriggers.kindShort.${state.kind}`)} + + {$t(`redeployTriggers.kind.${state.kind}`) === `redeployTriggers.kind.${state.kind}` + ? state.kind + : $t(`redeployTriggers.kind.${state.kind}`)} + +
+ {/if} + + {#if showName} +
+ + +
+ {/if} + +
+ + {$t('redeployTriggers.form.configLabel')} + {$t(`redeployTriggers.kindShort.${state.kind}`)} + + + + {#if state.useAdvancedJson} + + {:else if state.kind === 'registry'} + + + {:else if state.kind === 'git'} + +
+ {$t('redeployTriggers.form.mode')} +
+ + +
+
+ {#if state.gitMode === 'push'} + + {:else} + + {/if} + {:else if state.kind === 'manual'} +
+ MANUAL +

{$t('redeployTriggers.form.manualNote')}

+
+ {:else} +
+ ? +

{$t('redeployTriggers.form.unknownNote')}

+
+ {/if} +
+ + {#if showWebhook} +
+ + {$t('redeployTriggers.detail.webhook')} + OPTIONAL + +
+
+ {$t('redeployTriggers.form.webhookEnabled')} +

{$t('redeployTriggers.form.webhookEnabledHint')}

+
+ +
+ {#if state.webhookEnabled} +
+
+ {$t('redeployTriggers.form.webhookRequireSig')} +

{$t('redeployTriggers.form.webhookRequireSigHint')}

+
+ +
+ {/if} +
+ {/if} +
+ + diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index c124303..cdda7e4 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -17,6 +17,7 @@ "apps": "Apps", "eventTriggers": "Triggers", "logScanRules": "Log Rules", + "triggers": "Triggers", "projects": "Projects", "deploy": "Deploy", "proxies": "Proxies", @@ -1527,5 +1528,235 @@ "overriding": "Overriding…", "overrideTitle": "Create a per-workload override of this global rule" } + }, + "redeployTriggers": { + "section": "The Forge", + "title": "Redeploy triggers", + "titleNew": "Forge a new trigger", + "titleSingular": "Trigger", + "lede": "Sources of redeploy signals — registry pushes, git events, manual fires, schedules, webhooks, log matches. Each trigger lives once and fans out to every workload bound to it.", + "ledeNew": "Pick a kind, name it, and decide whether external systems may poke it via webhook. Bind it to one or more workloads from the workload page after creation.", + "ledeDetail": "Edit the trigger config, manage its webhook ingress, and review every workload listening to this signal.", + "stat": { + "total": "TOTAL", + "byKind": "{kind}", + "withWebhook": "WEBHOOK ON", + "boundWorkloads": "WORKLOADS" + }, + "kind": { + "registry": "Registry", + "git": "Git", + "manual": "Manual", + "schedule": "Schedule", + "webhook": "Webhook", + "logscan": "Log scan", + "unknown": "Unknown" + }, + "kindShort": { + "registry": "REG", + "git": "GIT", + "manual": "MAN", + "schedule": "CRN", + "webhook": "HK", + "logscan": "LOG", + "unknown": "?" + }, + "kindHint": { + "registry": "Watch a container image; fire when a new tag matching the pattern is pushed.", + "git": "Fire when a configured branch advances or a tag matching the pattern is created.", + "manual": "Fires only via the workload's Deploy button or POST /workloads/{id}/deploy.", + "schedule": "Fires on a fixed cron-style schedule.", + "webhook": "Pure webhook — fires when the ingress URL is hit.", + "logscan": "Fires when an upstream log-scan rule matches a tailed line.", + "unknown": "Unknown trigger kind — fall back to the raw JSON editor." + }, + "toolbar": { + "newButton": "New trigger", + "backToList": "Back to triggers" + }, + "filter": { + "all": "ALL", + "ariaLabel": "Filter by kind" + }, + "empty": { + "heading": "No triggers yet", + "body": "A trigger is the source of a redeploy signal — a registry watcher, git hook, manual button, scheduled fire, or webhook. Create one and bind it to as many workloads as you like.", + "cta": "Forge the first trigger" + }, + "list": { + "name": "Name", + "kind": "Kind", + "bindings": "Workloads", + "webhook": "Webhook", + "created": "Created", + "open": "Open", + "webhookOn": "ON", + "webhookOff": "—", + "noBindings": "—", + "bindingsCount": "{count}" + }, + "detail": { + "config": "Trigger configuration", + "configSub": "kind {kind} · id {id} · updated {updatedAt}", + "webhook": "Webhook ingress", + "webhookSub": "When enabled, external systems can fire this trigger by posting to the URL below. Each workload bound to it will be redeployed in turn.", + "webhookEnable": "Enable webhook ingress", + "webhookEnableHint": "When off, the trigger fires only via internal sources (its kind config) and the manual deploy button.", + "webhookRequireSig": "Require HMAC signature", + "webhookRequireSigHint": "Reject requests without a valid X-Hub-Signature-256 header. Recommended whenever the URL is reachable from the public internet.", + "webhookUrlLabel": "Ingress URL", + "webhookUrlNote": "Paste this into your CI / registry / GitHub webhook settings. The secret segment is the bearer — treat it like a password.", + "webhookCopy": "Copy", + "webhookCopied": "Copied", + "webhookRotate": "Rotate secret", + "webhookRotating": "Rotating…", + "webhookDisabledNote": "Webhook ingress is disabled. Toggle it on, save, and the URL will appear here.", + "bindings": "Bound workloads", + "bindingsSub": "Every workload listening to this trigger. To bind a new workload, open the workload page and add this trigger from there.", + "bindingsEmpty": "No workloads are bound to this trigger yet. Open a workload and bind this trigger from its Triggers panel.", + "bindingsListItem": { + "openWorkload": "Open workload", + "unbind": "Unbind" + }, + "bindingEnabledHint": "Disable to keep the binding but stop this trigger from redeploying that workload.", + "dangerZone": "Danger zone", + "dangerZoneSub": "Trigger deletion is immediate. All bindings to it are cascade-removed.", + "deleteButton": "Delete trigger", + "deleteTitle": "Delete trigger?", + "deleteMessage": "Trigger \"{name}\" will be removed immediately, along with {count} binding(s). This cannot be undone.", + "rotateTitle": "Rotate webhook secret?", + "rotateMessage": "The current ingress URL stops working immediately. Update every external integration with the new URL after rotation.", + "rotateConfirm": "Rotate now", + "unbindTitle": "Unbind workload?", + "unbindMessage": "Workload \"{name}\" will stop redeploying when this trigger fires. The workload itself is not deleted.", + "unbindConfirm": "Unbind" + }, + "form": { + "kindLabel": "Kind", + "kindHint": "Pick the source of the redeploy signal. The form below adapts to the kind.", + "name": "Name", + "namePlaceholder": "e.g. ghcr.io/me/api · main", + "required": "REQUIRED", + "configLabel": "Configuration", + "image": "Image reference", + "imagePlaceholder": "registry.example.com/owner/app", + "imageHint": "Full image reference without the tag — Tinyforge matches new tags pushed under it.", + "tagPattern": "Tag pattern", + "tagPatternPlaceholder": "*", + "tagPatternHint": "path.Match glob (e.g. v*, release-*). Use * to match every tag.", + "repo": "Repository", + "repoPlaceholder": "owner/name", + "repoHint": "Provider-agnostic owner/name slug as advertised by the git host.", + "mode": "Mode", + "modePush": "Push to branch", + "modeTag": "Tag created", + "branch": "Branch", + "branchPlaceholder": "main", + "branchHint": "Only push events advancing this branch fire the trigger.", + "manualNote": "Manual triggers carry no config. They fire only via the workload's Deploy button or POST /workloads/{id}/deploy.", + "unknownNote": "This kind has no built-in form yet. Use the JSON editor below; the server validates the shape.", + "advancedToggle": "Advanced JSON", + "advancedHint": "Power-user fallback — replaces the structured form with the raw config payload.", + "configJson": "Config JSON", + "configJsonHint": "Must parse as a valid JSON object. The shape is validated server-side per kind.", + "invalidJson": "Invalid JSON — server will reject.", + "webhookEnabled": "Enable webhook ingress now", + "webhookEnabledHint": "Generates a secret URL that external systems can hit to fire the trigger.", + "webhookRequireSig": "Require HMAC signature", + "webhookRequireSigHint": "Reject unsigned requests. The secret is the same one embedded in the URL — sign the body with HMAC-SHA256 and send it as X-Hub-Signature-256.", + "submit": "Forge trigger", + "submitting": "Forging…", + "cancel": "Cancel" + }, + "binding": { + "enabled": "Enabled", + "disabled": "Disabled" + } + }, + "apps": { + "new": { + "triggers": { + "section": "Trigger", + "sectionSub": "Optional. Pick how this app gets a redeploy signal — registry watcher, git event, or manual button.", + "modeInline": "Add a trigger", + "modeInlineHint": "Creates a brand-new trigger record bound to this app — fits the common 1:1 case.", + "modePick": "Pick existing trigger", + "modePickHint": "Bind an existing trigger so multiple apps share one signal.", + "modeSkip": "Skip — add later", + "modeSkipHint": "The app is created without any trigger binding. Manual deploys still work.", + "switchToPick": "Pick existing trigger →", + "switchToInline": "← Create a new trigger instead", + "switchToSkip": "Skip for now", + "pickPlaceholder": "Select a trigger…", + "pickEmpty": "No triggers exist yet — create one inline above, or visit /triggers.", + "pickLabel": "Existing trigger", + "pickHint": "The same trigger can be bound to many apps. Manage standalone triggers under /triggers.", + "pickWebhookOn": "WEBHOOK ON", + "skippedNote": "No trigger will be bound. You can add one from the app's Triggers panel after it's created.", + "bindError": "App created, but the trigger binding failed: {error}. Open the app's Triggers panel to retry." + } + }, + "detail": { + "manualDeploySub": "Bypasses configured triggers and dispatches through the source plugin directly.", + "chainTriggersZero": "no triggers", + "chainTriggersOne": "1 trigger", + "chainTriggersMany": "{count} triggers", + "bindings": { + "title": "Triggers", + "subEmpty": "No triggers bound. Manual deploys still work — add a trigger to wire up registry / git / webhook redeploys.", + "subCount": "{count} trigger bound", + "subCountMany": "{count} triggers bound", + "addButton": "Add trigger", + "openTrigger": "View trigger", + "unbindAction": "Unbind", + "rowEnabled": "Enabled", + "rowDisabled": "Disabled", + "rowEnableHint": "Disable to keep the binding but stop this trigger from redeploying the app.", + "loading": "Loading triggers…", + "loadError": "Failed to load trigger bindings", + "unbindTitle": "Unbind trigger?", + "unbindMessage": "Trigger \"{name}\" will stop redeploying this app. The trigger record itself is not deleted — it stays in /triggers and remains bound to any other apps.", + "unbindConfirm": "Unbind", + "modal": { + "title": "Add trigger", + "subtitle": "Bind a trigger to this app — create a new one inline, or pick an existing trigger to share.", + "tabInline": "Create new", + "tabPick": "Bind existing", + "submitInline": "Create & bind", + "submitPick": "Bind", + "submitting": "Binding…", + "cancel": "Cancel", + "error": "Bind failed", + "pickPlaceholder": "Select a trigger…", + "pickEmpty": "No triggers exist yet — switch to \"Create new\" to make one.", + "pickLabel": "Existing trigger", + "pickKind": "Filter by kind", + "pickKindAll": "All kinds" + }, + "override": { + "toggle": "Override", + "title": "Per-binding overrides", + "subtitle": "Override fields of the trigger's config for this app only. Top-level keys you set here win; everything else inherits from the trigger.", + "badgeOne": "OVERRIDES 1 FIELD", + "badgeMany": "OVERRIDES {count} FIELDS", + "badgeTitle": "This binding overrides one or more fields of the trigger's config.", + "baseLabel": "Trigger config", + "baseLoading": "Loading trigger config…", + "baseHint": "Read-only view of the parent trigger's config. Edit it from the trigger page if it should change for every binding.", + "editLabel": "Override (JSON object)", + "editHint": "Top-level merge: only keys present here override the trigger. Leave the editor as {} to inherit verbatim.", + "previewLabel": "Effective config", + "previewHint": "Preview of what this binding will see when the trigger fires (trigger config with the override merged on top).", + "invalidJson": "Override must be a JSON object.", + "tooLarge": "Override is {size} B — exceeds the {limit} B server limit.", + "errInvalidJson": "Cannot save: override is not a valid JSON object.", + "errTooLarge": "Cannot save: override exceeds the 8 KiB server limit.", + "saveButton": "Save override", + "saving": "Saving…", + "resetButton": "Reset to inherit", + "closeButton": "Close" + } + } + } } } diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index e3e3469..d007fab 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -17,6 +17,7 @@ "apps": "Приложения", "eventTriggers": "Триггеры", "logScanRules": "Лог-правила", + "triggers": "Триггеры", "projects": "Проекты", "deploy": "Деплой", "proxies": "Прокси", @@ -1527,5 +1528,235 @@ "overriding": "Переопределение…", "overrideTitle": "Создать переопределение глобального правила для этой нагрузки" } + }, + "redeployTriggers": { + "section": "Кузница", + "title": "Триггеры передеплоя", + "titleNew": "Новый триггер", + "titleSingular": "Триггер", + "lede": "Источники сигналов передеплоя — push в registry, события git, ручной запуск, расписания, webhook'и, совпадения в логах. Триггер создаётся один раз и веером раздаёт сигнал всем привязанным к нему нагрузкам.", + "ledeNew": "Выберите вид, дайте имя и решите, могут ли внешние системы дёргать его через webhook. Привязку к нагрузкам делайте со страницы нагрузки после создания.", + "ledeDetail": "Редактируйте конфигурацию триггера, управляйте webhook-приёмом и просматривайте все нагрузки, слушающие этот сигнал.", + "stat": { + "total": "ВСЕГО", + "byKind": "{kind}", + "withWebhook": "С WEBHOOK", + "boundWorkloads": "НАГРУЗОК" + }, + "kind": { + "registry": "Registry", + "git": "Git", + "manual": "Ручной", + "schedule": "Расписание", + "webhook": "Webhook", + "logscan": "Лог-скан", + "unknown": "Неизвестный" + }, + "kindShort": { + "registry": "REG", + "git": "GIT", + "manual": "MAN", + "schedule": "CRN", + "webhook": "HK", + "logscan": "LOG", + "unknown": "?" + }, + "kindHint": { + "registry": "Следит за образом контейнера; срабатывает при push нового тега, подходящего под шаблон.", + "git": "Срабатывает при продвижении указанной ветки или создании тега, подходящего под шаблон.", + "manual": "Срабатывает только через кнопку Deploy на странице нагрузки или POST /workloads/{id}/deploy.", + "schedule": "Срабатывает по фиксированному cron-расписанию.", + "webhook": "Чистый webhook — срабатывает при обращении к URL приёма.", + "logscan": "Срабатывает, когда правило сканирования логов совпадает со строкой.", + "unknown": "Неизвестный вид триггера — используйте сырой JSON-редактор." + }, + "toolbar": { + "newButton": "Новый триггер", + "backToList": "К списку триггеров" + }, + "filter": { + "all": "ВСЕ", + "ariaLabel": "Фильтр по виду" + }, + "empty": { + "heading": "Триггеров пока нет", + "body": "Триггер — источник сигнала передеплоя: registry-watcher, git-hook, ручная кнопка, расписание или webhook. Создайте один и привяжите к скольким угодно нагрузкам.", + "cta": "Создать первый триггер" + }, + "list": { + "name": "Имя", + "kind": "Вид", + "bindings": "Нагрузки", + "webhook": "Webhook", + "created": "Создан", + "open": "Открыть", + "webhookOn": "ВКЛ", + "webhookOff": "—", + "noBindings": "—", + "bindingsCount": "{count}" + }, + "detail": { + "config": "Конфигурация триггера", + "configSub": "вид {kind} · id {id} · обновлено {updatedAt}", + "webhook": "Webhook-приём", + "webhookSub": "Когда включено, внешние системы могут дёргать триггер по URL ниже. Каждая привязанная нагрузка будет передеплоена по очереди.", + "webhookEnable": "Включить webhook-приём", + "webhookEnableHint": "Когда выключено, триггер срабатывает только из внутренних источников (по конфигу его вида) и кнопки ручного деплоя.", + "webhookRequireSig": "Требовать HMAC-подпись", + "webhookRequireSigHint": "Отклонять запросы без корректного X-Hub-Signature-256. Рекомендуется, если URL доступен из публичной сети.", + "webhookUrlLabel": "URL приёма", + "webhookUrlNote": "Вставьте это в настройки CI / registry / webhook GitHub. Сегмент-секрет — это пароль, обращайтесь как с паролем.", + "webhookCopy": "Копировать", + "webhookCopied": "Скопировано", + "webhookRotate": "Сменить секрет", + "webhookRotating": "Смена…", + "webhookDisabledNote": "Webhook-приём выключен. Включите тумблер, сохраните — и URL появится здесь.", + "bindings": "Привязанные нагрузки", + "bindingsSub": "Все нагрузки, слушающие этот триггер. Чтобы привязать новую нагрузку, откройте её страницу и добавьте этот триггер оттуда.", + "bindingsEmpty": "К этому триггеру пока не привязана ни одна нагрузка. Откройте нагрузку и привяжите этот триггер из её панели «Триггеры».", + "bindingsListItem": { + "openWorkload": "Открыть нагрузку", + "unbind": "Отвязать" + }, + "bindingEnabledHint": "Выключите, чтобы оставить привязку, но запретить триггеру передеплоить эту нагрузку.", + "dangerZone": "Опасная зона", + "dangerZoneSub": "Удаление триггера происходит сразу. Все привязки к нему удаляются каскадом.", + "deleteButton": "Удалить триггер", + "deleteTitle": "Удалить триггер?", + "deleteMessage": "Триггер «{name}» будет удалён немедленно вместе с {count} привязкой(-ами). Действие необратимо.", + "rotateTitle": "Сменить секрет webhook?", + "rotateMessage": "Текущий URL приёма перестанет работать сразу. После смены обновите URL во всех внешних интеграциях.", + "rotateConfirm": "Сменить", + "unbindTitle": "Отвязать нагрузку?", + "unbindMessage": "Нагрузка «{name}» перестанет передеплоиваться при срабатывании этого триггера. Сама нагрузка не удаляется.", + "unbindConfirm": "Отвязать" + }, + "form": { + "kindLabel": "Вид", + "kindHint": "Выберите источник сигнала передеплоя. Форма ниже подстраивается под вид.", + "name": "Имя", + "namePlaceholder": "например, ghcr.io/me/api · main", + "required": "ОБЯЗАТЕЛЬНО", + "configLabel": "Конфигурация", + "image": "Ссылка на образ", + "imagePlaceholder": "registry.example.com/owner/app", + "imageHint": "Полная ссылка на образ без тега — Tinyforge ловит новые теги, выкладываемые под этой ссылкой.", + "tagPattern": "Шаблон тега", + "tagPatternPlaceholder": "*", + "tagPatternHint": "Glob path.Match (например, v*, release-*). * совпадает с любым тегом.", + "repo": "Репозиторий", + "repoPlaceholder": "owner/name", + "repoHint": "owner/name в формате git-хостинга, не зависит от провайдера.", + "mode": "Режим", + "modePush": "Push в ветку", + "modeTag": "Создание тега", + "branch": "Ветка", + "branchPlaceholder": "main", + "branchHint": "Только push'и, продвигающие эту ветку, дёргают триггер.", + "manualNote": "У ручных триггеров нет конфига. Они срабатывают только через кнопку Deploy на странице нагрузки или POST /workloads/{id}/deploy.", + "unknownNote": "У этого вида ещё нет встроенной формы. Используйте JSON-редактор ниже; сервер валидирует форму.", + "advancedToggle": "Расширенный JSON", + "advancedHint": "Запасной вариант для опытных пользователей — заменяет структурированную форму сырым payload'ом.", + "configJson": "JSON конфигурации", + "configJsonHint": "Должен распарситься как корректный JSON-объект. Структура проверяется сервером по виду.", + "invalidJson": "Некорректный JSON — сервер отклонит.", + "webhookEnabled": "Включить webhook-приём сразу", + "webhookEnabledHint": "Генерирует секретный URL, по которому внешние системы могут дёргать триггер.", + "webhookRequireSig": "Требовать HMAC-подпись", + "webhookRequireSigHint": "Отклонять неподписанные запросы. Секрет — тот же, что вшит в URL — подпишите тело HMAC-SHA256 и пришлите в X-Hub-Signature-256.", + "submit": "Создать триггер", + "submitting": "Создание…", + "cancel": "Отмена" + }, + "binding": { + "enabled": "Включена", + "disabled": "Выключена" + } + }, + "apps": { + "new": { + "triggers": { + "section": "Триггер", + "sectionSub": "Необязательно. Выберите, откуда придёт сигнал передеплоя — слежение за реестром, git-событие или ручная кнопка.", + "modeInline": "Создать триггер", + "modeInlineHint": "Создаёт новую запись триггера, привязанную к этому приложению — подходит для частого случая 1:1.", + "modePick": "Выбрать существующий", + "modePickHint": "Привязать существующий триггер, чтобы несколько приложений делили один сигнал.", + "modeSkip": "Пропустить — добавить позже", + "modeSkipHint": "Приложение создаётся без привязки триггера. Ручной деплой по-прежнему работает.", + "switchToPick": "Выбрать существующий →", + "switchToInline": "← Создать новый триггер", + "switchToSkip": "Пропустить", + "pickPlaceholder": "Выберите триггер…", + "pickEmpty": "Триггеров ещё нет — создайте один выше или перейдите в /triggers.", + "pickLabel": "Существующий триггер", + "pickHint": "Один триггер можно привязать к нескольким приложениям. Управление автономными триггерами — в /triggers.", + "pickWebhookOn": "ВЕБХУК ВКЛ", + "skippedNote": "Триггер не будет привязан. Добавьте его из панели «Триггеры» в карточке приложения после создания.", + "bindError": "Приложение создано, но привязка триггера не удалась: {error}. Откройте панель «Триггеры» в карточке, чтобы повторить." + } + }, + "detail": { + "manualDeploySub": "Обходит настроенные триггеры и отправляет деплой напрямую через source-плагин.", + "chainTriggersZero": "без триггеров", + "chainTriggersOne": "1 триггер", + "chainTriggersMany": "{count} триггер(ов)", + "bindings": { + "title": "Триггеры", + "subEmpty": "Триггеры не привязаны. Ручной деплой работает — добавьте триггер, чтобы подключить передеплой по реестру / git / вебхуку.", + "subCount": "{count} привязанный триггер", + "subCountMany": "{count} привязанных триггеров", + "addButton": "Добавить триггер", + "openTrigger": "Открыть триггер", + "unbindAction": "Отвязать", + "rowEnabled": "Включён", + "rowDisabled": "Выключен", + "rowEnableHint": "Отключите, чтобы сохранить привязку, но остановить передеплой этого приложения.", + "loading": "Загрузка триггеров…", + "loadError": "Не удалось загрузить привязки триггеров", + "unbindTitle": "Отвязать триггер?", + "unbindMessage": "Триггер «{name}» перестанет передеплоить это приложение. Сам триггер не удаляется — он остаётся в /triggers и сохраняет привязки к другим приложениям.", + "unbindConfirm": "Отвязать", + "modal": { + "title": "Добавить триггер", + "subtitle": "Привяжите триггер к этому приложению — создайте новый или выберите существующий, чтобы делить его.", + "tabInline": "Создать новый", + "tabPick": "Выбрать существующий", + "submitInline": "Создать и привязать", + "submitPick": "Привязать", + "submitting": "Привязка…", + "cancel": "Отмена", + "error": "Не удалось привязать", + "pickPlaceholder": "Выберите триггер…", + "pickEmpty": "Триггеров ещё нет — переключитесь на «Создать новый», чтобы добавить.", + "pickLabel": "Существующий триггер", + "pickKind": "Фильтр по виду", + "pickKindAll": "Все виды" + }, + "override": { + "toggle": "Переопределить", + "title": "Переопределения привязки", + "subtitle": "Переопределите поля конфига триггера только для этого приложения. Верхнеуровневые ключи отсюда побеждают; остальное наследуется из триггера.", + "badgeOne": "ПЕРЕОПРЕДЕЛЕНО: 1 ПОЛЕ", + "badgeMany": "ПЕРЕОПРЕДЕЛЕНО ПОЛЕЙ: {count}", + "badgeTitle": "Эта привязка переопределяет одно или несколько полей конфига триггера.", + "baseLabel": "Конфиг триггера", + "baseLoading": "Загрузка конфига триггера…", + "baseHint": "Конфиг родительского триггера в режиме чтения. Редактируйте его на странице триггера, если изменения нужны для всех привязок.", + "editLabel": "Переопределение (JSON-объект)", + "editHint": "Слияние по верхнему уровню: переопределяются только указанные здесь ключи. Оставьте {} — будет наследоваться без изменений.", + "previewLabel": "Итоговый конфиг", + "previewHint": "Предпросмотр того, что увидит эта привязка при срабатывании триггера (конфиг триггера + наложенное переопределение).", + "invalidJson": "Переопределение должно быть JSON-объектом.", + "tooLarge": "Размер переопределения — {size} Б, превышает серверный лимит {limit} Б.", + "errInvalidJson": "Нельзя сохранить: переопределение не является валидным JSON-объектом.", + "errTooLarge": "Нельзя сохранить: переопределение превышает серверный лимит 8 КиБ.", + "saveButton": "Сохранить переопределение", + "saving": "Сохранение…", + "resetButton": "Сбросить к наследованию", + "closeButton": "Закрыть" + } + } + } } } diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index c8a0f2b..a81cd8b 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -44,7 +44,8 @@ { href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' }, { href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies', countKey: 'proxies' }, { href: '/events', labelKey: 'nav.events', icon: 'events', countKey: 'eventsErrors', alert: true }, - { href: '/event-triggers', labelKey: 'nav.eventTriggers', icon: 'events', labelOverride: 'Triggers' }, + { href: '/triggers', labelKey: 'nav.triggers', icon: 'deploy' }, + { href: '/event-triggers', labelKey: 'nav.eventTriggers', icon: 'events', labelOverride: 'Event Triggers' }, { href: '/log-scan-rules', labelKey: 'nav.logScanRules', icon: 'events', labelOverride: 'Log Rules' }, { href: '/settings', labelKey: 'nav.settings', icon: 'settings' } ]; diff --git a/web/src/routes/apps/[id]/+page.svelte b/web/src/routes/apps/[id]/+page.svelte index f4c3510..18ed002 100644 --- a/web/src/routes/apps/[id]/+page.svelte +++ b/web/src/routes/apps/[id]/+page.svelte @@ -3,6 +3,7 @@ import { goto } from '$app/navigation'; import { page } from '$app/stores'; import type { Container, PluginWorkloadInput, Workload } from '$lib/types'; + import type { RedeployTrigger, WorkloadTriggerBinding } from '$lib/api'; import * as api from '$lib/api'; import { IconRefresh, @@ -12,14 +13,26 @@ IconCopy, IconCheck, IconChevronDown, - IconServer + IconServer, + IconExternalLink, + IconPlus, + IconX } from '$lib/components/icons'; import ForgeHero from '$lib/components/ForgeHero.svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import ContainerLogs from '$lib/components/ContainerLogs.svelte'; + import ToggleSwitch from '$lib/components/ToggleSwitch.svelte'; + import TriggerKindForm, { + createTriggerKindFormState, + isTriggerFormValid, + buildTriggerInput + } from '$lib/components/TriggerKindForm.svelte'; import { t } from '$lib/i18n'; - const id = $derived($page.params.id); + // Route params come back as `string | undefined`; the route file + // guarantees `id` exists, but the empty-string fallback satisfies + // the type checker — server validation rejects empty ids anyway. + const id = $derived($page.params.id ?? ''); let workload = $state(null); let containers = $state([]); @@ -29,15 +42,15 @@ let deployRef = $state(''); let lastDeployMsg = $state(''); - // Edit-mode state. Mirrors the workload's source/trigger configs as - // pretty-printed JSON so the user edits the same shape /apps/new - // produced. + // Edit-mode state. Mirrors the workload's source config as pretty- + // printed JSON so the user edits the same shape /apps/new produced. + // Trigger configuration moved out into the standalone Triggers + // panel — workloads no longer carry an embedded trigger config. let editing = $state(false); let saving = $state(false); let editName = $state(''); let editParentID = $state(''); let editSourceConfig = $state(''); - let editTriggerConfig = $state(''); let editPublicFaces = $state(''); let confirmDelete = $state(false); let deleting = $state(false); @@ -88,11 +101,222 @@ (workload?.source_kind ?? '') === 'static' && !editAdvancedJson ); - // View-mode collapse state per config block. + // View-mode collapse state per config block. Trigger config is no + // longer carried on the workload, so the trigger panel is gone — + // only source + faces JSON viewers remain here. let openSource = $state(true); - let openTrigger = $state(true); let openFaces = $state(true); + // ── Trigger bindings ────────────────────────────────────── + // Workloads no longer embed a trigger; instead they hold a list of + // bindings to standalone Trigger records. The bindings panel + // renders this list with per-row enable/unbind actions and an + // "Add trigger" modal that supports inline-create or pick-existing. + let bindings = $state([]); + let bindingsLoading = $state(false); + let bindingsError = $state(''); + let confirmUnbindId = $state(null); + + // ── Per-binding override editor ────────────────────────── + // Each binding can override fields of its parent trigger's + // config via `binding_config` (top-level merge, binding wins). + // State is keyed by binding id. The base trigger config is + // lazy-fetched the first time a row's override panel is opened; + // the editable JSON text mirrors the binding's current + // `binding_config` and is round-tripped to the backend on save. + const BINDING_CONFIG_MAX_BYTES = 8 * 1024; + let openOverrideId = $state(null); + let overrideTexts = $state>({}); + let overrideBaseConfigs = $state>({}); + let overrideBaseLoading = $state>({}); + let overrideSaving = $state>({}); + let overrideErrors = $state>({}); + + function formatJsonValue(v: unknown): string { + try { + return JSON.stringify(v ?? {}, null, 2); + } catch { + return '{}'; + } + } + + function parseOverrideObject(text: string): { ok: true; value: Record } | { ok: false } { + const t = text.trim(); + if (!t) return { ok: true, value: {} }; + try { + const parsed = JSON.parse(t); + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { ok: false }; + } + return { ok: true, value: parsed as Record }; + } catch { + return { ok: false }; + } + } + + function overrideKeyCount(v: unknown): number { + if (!v || typeof v !== 'object' || Array.isArray(v)) return 0; + return Object.keys(v as Record).length; + } + + function byteLength(s: string): number { + try { + return new TextEncoder().encode(s).length; + } catch { + return s.length; + } + } + + function overrideJsonValid(text: string): boolean { + const r = parseOverrideObject(text); + return r.ok; + } + + function overrideKeyCountFromText(text: string): number { + const r = parseOverrideObject(text); + return r.ok ? Object.keys(r.value).length : 0; + } + + function overrideTextSize(text: string): number { + const t = text.trim(); + if (!t) return 0; + try { + const parsed = JSON.parse(t); + return byteLength(JSON.stringify(parsed)); + } catch { + return byteLength(t); + } + } + + function mergedPreview(baseText: string, overrideText: string): string { + let base: Record = {}; + try { + const parsed = JSON.parse(baseText || '{}'); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + base = parsed as Record; + } + } catch { + // fall through with empty base + } + const ov = parseOverrideObject(overrideText); + const merged = ov.ok ? { ...base, ...ov.value } : base; + return formatJsonValue(merged); + } + + async function toggleOverridePanel(b: WorkloadTriggerBinding): Promise { + if (openOverrideId === b.id) { + openOverrideId = null; + return; + } + openOverrideId = b.id; + // Seed editable text with current binding_config (or "{}"). + if (!(b.id in overrideTexts)) { + overrideTexts = { + ...overrideTexts, + [b.id]: formatJsonValue(b.binding_config ?? {}) + }; + } + // Lazy-fetch the trigger's base config the first time we open. + if (!(b.trigger_id in overrideBaseConfigs) && !overrideBaseLoading[b.trigger_id]) { + overrideBaseLoading = { ...overrideBaseLoading, [b.trigger_id]: true }; + try { + const tr = await api.getTrigger(b.trigger_id); + overrideBaseConfigs = { + ...overrideBaseConfigs, + [b.trigger_id]: formatJsonValue(tr.config ?? {}) + }; + } catch (e) { + overrideErrors = { + ...overrideErrors, + [b.id]: e instanceof Error ? e.message : 'Failed to load trigger' + }; + } finally { + overrideBaseLoading = { ...overrideBaseLoading, [b.trigger_id]: false }; + } + } + } + + async function saveOverride(b: WorkloadTriggerBinding): Promise { + const text = overrideTexts[b.id] ?? ''; + const parsed = parseOverrideObject(text); + if (!parsed.ok) { + overrideErrors = { + ...overrideErrors, + [b.id]: $t('apps.detail.bindings.override.errInvalidJson') + }; + return; + } + if (overrideTextSize(text) > BINDING_CONFIG_MAX_BYTES) { + overrideErrors = { + ...overrideErrors, + [b.id]: $t('apps.detail.bindings.override.errTooLarge') + }; + return; + } + overrideErrors = { ...overrideErrors, [b.id]: '' }; + overrideSaving = { ...overrideSaving, [b.id]: true }; + try { + const updated = await api.updateBinding(b.id, { binding_config: parsed.value }); + bindings = bindings.map((x) => + x.id === b.id ? { ...x, binding_config: updated.binding_config } : x + ); + // Re-pretty so the editor matches the canonical normalized form. + overrideTexts = { + ...overrideTexts, + [b.id]: formatJsonValue(updated.binding_config ?? {}) + }; + } catch (e) { + overrideErrors = { + ...overrideErrors, + [b.id]: e instanceof Error ? e.message : 'Save failed' + }; + } finally { + overrideSaving = { ...overrideSaving, [b.id]: false }; + } + } + + async function resetOverride(b: WorkloadTriggerBinding): Promise { + overrideErrors = { ...overrideErrors, [b.id]: '' }; + overrideSaving = { ...overrideSaving, [b.id]: true }; + try { + const updated = await api.updateBinding(b.id, { binding_config: {} }); + bindings = bindings.map((x) => + x.id === b.id ? { ...x, binding_config: updated.binding_config } : x + ); + overrideTexts = { + ...overrideTexts, + [b.id]: formatJsonValue(updated.binding_config ?? {}) + }; + } catch (e) { + overrideErrors = { + ...overrideErrors, + [b.id]: e instanceof Error ? e.message : 'Reset failed' + }; + } finally { + overrideSaving = { ...overrideSaving, [b.id]: false }; + } + } + + // Add-binding modal state. The modal has two tabs (Inline / Pick) + // and lazy-loads the existing-trigger list the first time the + // "Pick" tab is opened. + let addModalOpen = $state(false); + let addModalTab = $state<'inline' | 'pick'>('inline'); + let addModalSubmitting = $state(false); + let addModalError = $state(''); + let inlineTriggerForm = $state(createTriggerKindFormState({ kind: 'registry' })); + let pickTriggerId = $state(''); + let pickKindFilter = $state(''); + let availableTriggers = $state([]); + let availableTriggersLoaded = $state(false); + + // Filtered list for the picker, derived from the kind filter. + const availableTriggersFiltered = $derived( + pickKindFilter + ? availableTriggers.filter((t) => t.kind === pickKindFilter) + : availableTriggers + ); + // Tiny copy-button affordance — shows a check for 1.2s after success. let copied = $state>({}); @@ -140,7 +364,7 @@ loading = true; error = ''; try { - const [w, c, env, vols, ch, lr] = await Promise.all([ + const [w, c, env, vols, ch, lr, bs] = await Promise.all([ api.getWorkload(id), api.listWorkloadContainers(id), api.listWorkloadEnv(id).catch(() => [] as api.WorkloadEnv[]), @@ -152,6 +376,10 @@ api.getEffectiveLogScanRules(id).catch((e) => { logRulesError = e instanceof Error ? e.message : 'rules load failed'; return [] as api.LogScanRule[]; + }), + api.listBindingsForWorkload(id).catch((e) => { + bindingsError = e instanceof Error ? e.message : 'bindings load failed'; + return [] as WorkloadTriggerBinding[]; }) ]); workload = w; @@ -160,6 +388,20 @@ volumeRows = vols; chain = ch; logRules = lr; + bindings = bs; + + // If we routed here from /apps/new with a deferred bind + // failure, surface it once and clear the flag. + try { + const key = `tinyforge.bindError.${id}`; + const stored = sessionStorage.getItem(key); + if (stored) { + bindingsError = stored; + sessionStorage.removeItem(key); + } + } catch { + // session storage may be disabled — ignore. + } } catch (e) { error = e instanceof Error ? e.message : 'Failed to load app'; } finally { @@ -355,7 +597,6 @@ editName = workload.name; editParentID = workload.parent_workload_id || ''; editSourceConfig = prettyJson(workload.source_config); - editTriggerConfig = prettyJson(workload.trigger_config); editPublicFaces = prettyJson(workload.public_faces || '[]'); editAdvancedJson = false; seedEditComposeFromJSON(editSourceConfig); @@ -530,7 +771,6 @@ error = ''; try { let parsedSource: unknown; - let parsedTrigger: unknown; let parsedFaces: unknown[]; if (useEditComposeForm) { parsedSource = { @@ -548,11 +788,6 @@ throw new Error('source_config is not valid JSON'); } } - try { - parsedTrigger = JSON.parse(editTriggerConfig); - } catch { - throw new Error('trigger_config is not valid JSON'); - } try { const f = JSON.parse(editPublicFaces); if (!Array.isArray(f)) throw new Error('public_faces must be an array'); @@ -563,13 +798,17 @@ ); } + // Trigger fields are no longer carried on the workload row. + // We pass empty placeholders so the legacy backend path is + // effectively a no-op; bindings are managed via the + // dedicated Triggers panel below. const body: PluginWorkloadInput = { name: editName.trim(), parent_workload_id: editParentID.trim(), source_kind: workload.source_kind, source_config: parsedSource, - trigger_kind: workload.trigger_kind, - trigger_config: parsedTrigger, + trigger_kind: '', + trigger_config: {}, public_faces: parsedFaces as PluginWorkloadInput['public_faces'] }; await api.updatePluginWorkload(id, body); @@ -582,6 +821,117 @@ } } + // ── Binding helpers ───────────────────────────────────────── + async function reloadBindings(): Promise { + bindingsLoading = true; + try { + bindings = await api.listBindingsForWorkload(id); + bindingsError = ''; + } catch (e) { + bindingsError = e instanceof Error ? e.message : $t('apps.detail.bindings.loadError'); + } finally { + bindingsLoading = false; + } + } + + async function toggleBinding(b: WorkloadTriggerBinding, next: boolean): Promise { + try { + const updated = await api.updateBinding(b.id, { enabled: next }); + bindings = bindings.map((x) => + x.id === b.id ? { ...x, enabled: updated.enabled } : x + ); + bindingsError = ''; + } catch (e) { + bindingsError = e instanceof Error ? e.message : 'Update failed'; + } + } + + async function doUnbind(): Promise { + if (!confirmUnbindId) return; + const bid = confirmUnbindId; + try { + await api.deleteBinding(bid); + bindings = bindings.filter((b) => b.id !== bid); + bindingsError = ''; + } catch (e) { + bindingsError = e instanceof Error ? e.message : 'Unbind failed'; + } finally { + confirmUnbindId = null; + } + } + + function openAddTriggerModal(): void { + // Reset every interaction: fresh form, no pre-picked trigger, + // no stale error. Default to inline because that's the common + // case for one-off setups. + addModalOpen = true; + addModalTab = 'inline'; + addModalError = ''; + addModalSubmitting = false; + inlineTriggerForm = createTriggerKindFormState({ kind: 'registry' }); + pickTriggerId = ''; + pickKindFilter = ''; + } + + function closeAddTriggerModal(): void { + if (addModalSubmitting) return; + addModalOpen = false; + } + + async function ensureAvailableTriggers(): Promise { + if (availableTriggersLoaded) return; + try { + availableTriggers = await api.listTriggers(); + availableTriggersLoaded = true; + } catch (e) { + addModalError = e instanceof Error ? e.message : 'Failed to load triggers'; + } + } + + async function switchAddTab(tab: 'inline' | 'pick'): Promise { + addModalTab = tab; + addModalError = ''; + if (tab === 'pick') { + await ensureAvailableTriggers(); + } + } + + const addModalCanSubmit = $derived.by(() => { + if (addModalSubmitting) return false; + if (addModalTab === 'inline') return isTriggerFormValid(inlineTriggerForm); + return !!pickTriggerId; + }); + + async function submitAddTrigger(): Promise { + if (!addModalCanSubmit) return; + addModalSubmitting = true; + addModalError = ''; + try { + if (addModalTab === 'inline') { + const inline = buildTriggerInput(inlineTriggerForm); + await api.bindTriggerToWorkload(id, { inline }); + } else { + await api.bindTriggerToWorkload(id, { trigger_id: pickTriggerId }); + } + addModalOpen = false; + await reloadBindings(); + } catch (e) { + addModalError = e instanceof Error ? e.message : $t('apps.detail.bindings.modal.error'); + } finally { + addModalSubmitting = false; + } + } + + const unbindTarget = $derived( + confirmUnbindId ? bindings.find((b) => b.id === confirmUnbindId) ?? null : null + ); + + function triggerKindLabel(k: string): string { + const key = `redeployTriggers.kind.${k}`; + const v = $t(key); + return v === key ? k : v; + } + async function doDelete() { deleting = true; error = ''; @@ -630,7 +980,6 @@ } const sourceValid = $derived(jsonOk(editSourceConfig)); - const triggerValid = $derived(jsonOk(editTriggerConfig)); const facesValid = $derived(jsonOk(editPublicFaces)); onMount(load); @@ -667,14 +1016,20 @@ {#snippet detailLede()} - + - {workload.source_kind} + {workload!.source_kind} + + + {bindings.length === 0 + ? $t('apps.detail.chainTriggersZero') + : bindings.length === 1 + ? $t('apps.detail.chainTriggersOne') + : $t('apps.detail.chainTriggersMany', { count: String(bindings.length) })} - {workload.trigger_kind} · - created {workload.created_at} + created {workload!.created_at} {/snippet} @@ -705,8 +1060,8 @@

Edit configuration.

- Source {workload.source_kind} · Trigger - {workload.trigger_kind} + Source {workload.source_kind} · triggers managed in the + Triggers panel below
@@ -1045,7 +1400,10 @@ - - - {#if openTrigger} -
-
{prettyJson(workload.trigger_config)}
-
- {/if} - - {#if workload.public_faces && workload.public_faces !== '[]'}
@@ -1793,6 +2374,157 @@ }} /> + (confirmUnbindId = null)} +/> + + +{#if addModalOpen} + + +{/if} + diff --git a/web/src/routes/apps/new/+page.svelte b/web/src/routes/apps/new/+page.svelte index 63a0bdd..3979bae 100644 --- a/web/src/routes/apps/new/+page.svelte +++ b/web/src/routes/apps/new/+page.svelte @@ -2,19 +2,43 @@ import { onMount } from 'svelte'; import { goto } from '$app/navigation'; import type { HookKinds, PluginWorkloadInput } from '$lib/types'; + import type { RedeployTrigger } from '$lib/api'; import * as api from '$lib/api'; import ForgeHero from '$lib/components/ForgeHero.svelte'; + import TriggerKindForm, { + createTriggerKindFormState, + isTriggerFormValid, + buildTriggerInput + } from '$lib/components/TriggerKindForm.svelte'; + import ToggleSwitch from '$lib/components/ToggleSwitch.svelte'; + import { t } from '$lib/i18n'; + // `triggers` is no longer hardcoded into the workload row — the + // kind list is kept on `kinds` only for reference but the wizard + // now composes a standalone Trigger record (or picks one) and + // binds it after the workload is created. let kinds = $state({ sources: [], triggers: [] }); let name = $state(''); let sourceKind = $state('image'); - let triggerKind = $state('manual'); let sourceConfig = $state('{}'); - let triggerConfig = $state('{}'); let publicSubdomain = $state(''); let publicDomain = $state(''); let publicPort = $state(0); + // Trigger UX modes — three branches: + // inline → create a new trigger inline; bind after workload create. + // pick → select an existing trigger; bind after workload create. + // skip → create the workload without any binding. + type TriggerMode = 'inline' | 'pick' | 'skip'; + let triggerMode = $state('inline'); + let triggerForm = $state(createTriggerKindFormState({ kind: 'registry' })); + + // Existing-trigger picker. `existingTriggers` is loaded lazily on + // mount; if the request fails the operator can still create one + // inline or skip altogether — we don't block the wizard. + let existingTriggers = $state([]); + let pickedTriggerId = $state(''); + // Kind-aware compose editor — the raw-JSON textarea forces users to // hand-escape YAML inside a JSON string, which is unusable. When the // source plugin is "compose" we surface a dedicated YAML textarea and @@ -79,7 +103,7 @@ schemaCache.set(kind, text); return text; } catch { - return sourceConfigSample(kind) || triggerConfigSample(kind) || '{}'; + return sourceConfigSample(kind) || '{}'; } } @@ -274,11 +298,7 @@ if (kinds.sources.length > 0 && !kinds.sources.includes(sourceKind)) { sourceKind = kinds.sources[0]; } - if (kinds.triggers.length > 0 && !kinds.triggers.includes(triggerKind)) { - triggerKind = kinds.triggers[0]; - } sourceConfig = await fetchSampleJSON(sourceKind); - triggerConfig = await fetchSampleJSON(triggerKind); if (sourceKind === 'compose') { seedComposeFromJSON(sourceConfig); } @@ -296,6 +316,14 @@ } catch { registries = []; } + // Best-effort fetch of existing triggers — feeds the + // "Pick existing" mode. Failure leaves the picker empty + // and the operator can still create one inline. + try { + existingTriggers = await api.listTriggers(); + } catch { + existingTriggers = []; + } } catch (e) { error = e instanceof Error ? e.message : 'Failed to load plugin kinds'; } finally { @@ -350,26 +378,6 @@ } } - function triggerConfigSample(kind: string): string { - switch (kind) { - case 'registry': - return JSON.stringify( - { image: 'registry.example.com/owner/app', tag_pattern: 'v*' }, - null, - 2 - ); - case 'git': - return JSON.stringify( - { repo: 'owner/repo', mode: 'push', branch: 'main', tag_pattern: '' }, - null, - 2 - ); - case 'manual': - default: - return '{}'; - } - } - async function onSourceChange() { sourceConfig = await fetchSampleJSON(sourceKind); // Switching INTO compose / image seeds the form fields from @@ -389,9 +397,6 @@ advancedJson = false; } } - async function onTriggerChange() { - triggerConfig = await fetchSampleJSON(triggerKind); - } // Toggle between the kind-aware form and the raw JSON editor. // Direction matters: going to Advanced JSON commits the form fields @@ -423,12 +428,17 @@ } } const sourceValid = $derived(jsonOk(sourceConfig)); - const triggerValid = $derived(jsonOk(triggerConfig)); const sourceLines = $derived(sourceConfig.split('\n').length); const sourceBytes = $derived(new Blob([sourceConfig]).size); - const triggerLines = $derived(triggerConfig.split('\n').length); - const triggerBytes = $derived(new Blob([triggerConfig]).size); + + // Trigger-step validity. Inline mode requires a complete kind+name+config; + // pick mode requires a chosen trigger; skip mode is always valid. + const triggerStepValid = $derived.by(() => { + if (triggerMode === 'skip') return true; + if (triggerMode === 'pick') return !!pickedTriggerId; + return isTriggerFormValid(triggerForm); + }); async function submit(e: Event) { e.preventDefault(); @@ -436,7 +446,6 @@ submitting = true; try { let parsedSource: unknown; - let parsedTrigger: unknown; if (useComposeForm) { // Form fields are typed primitives — no parse step // needed. compose_yaml passes through verbatim; the @@ -459,18 +468,17 @@ throw new Error('source_config is not valid JSON'); } } - try { - parsedTrigger = JSON.parse(triggerConfig); - } catch { - throw new Error('trigger_config is not valid JSON'); - } + // Triggers no longer ride on the workload row; the backend + // still accepts the legacy fields but the new code path + // passes a manual placeholder + empty config and binds a + // real Trigger record after creation. const body: PluginWorkloadInput = { name: name.trim(), source_kind: sourceKind, source_config: parsedSource, - trigger_kind: triggerKind, - trigger_config: parsedTrigger + trigger_kind: '', + trigger_config: {} }; if (publicSubdomain || publicDomain || publicPort > 0) { body.public_faces = [ @@ -486,6 +494,35 @@ } const created = await api.createPluginWorkload(body); + + // Bind a trigger to the freshly-created workload. Keep going + // to the detail page even on bind failure — the operator can + // retry from the workload's Triggers panel without losing + // their work. + if (triggerMode === 'inline' || triggerMode === 'pick') { + try { + if (triggerMode === 'inline') { + const inline = buildTriggerInput(triggerForm); + await api.bindTriggerToWorkload(created.id, { inline }); + } else if (pickedTriggerId) { + await api.bindTriggerToWorkload(created.id, { + trigger_id: pickedTriggerId + }); + } + } catch (be) { + const msg = be instanceof Error ? be.message : 'unknown'; + // Surface the bind failure to the user, then still + // route to the detail page where they can retry. + try { + sessionStorage.setItem( + `tinyforge.bindError.${created.id}`, + $t('apps.new.triggers.bindError', { error: msg }) + ); + } catch { + // session storage may be disabled — ignore. + } + } + } goto(`/apps/${created.id}`); } catch (e) { error = e instanceof Error ? e.message : 'Create failed'; @@ -502,8 +539,7 @@
{#snippet newLede()} Create a plugin-native workload. Source = how it deploys (image, compose, static). - Trigger = when it redeploys (registry push, git push, manual). Both axes are - independently extensible. + Pick or create a trigger below — when one fires, the source plugin redeploys. {/snippet}
02 - Plugins - SOURCE × TRIGGER -
-
- - + Source plugin + REQUIRED
+

- Both pickers are populated from the running daemon — only plugins compiled in show up. + Populated from the running daemon — only plugins compiled in show up. Triggers + (registry / git / manual) are configured below as standalone records.

@@ -921,10 +943,10 @@ -