feat(triggers): first-class triggers + bindings with fan-out webhook
Build / build (push) Successful in 10m39s

Promote triggers from embedded workload fields to standalone records
joined to workloads via workload_trigger_bindings. One trigger (webhook,
registry watcher, git push, manual) now fans out to many workloads with
per-binding config overrides (top-level JSON merge, binding wins).

Backend
- new triggers + workload_trigger_bindings tables with ON DELETE CASCADE
- boot-time backfill of embedded trigger config inside per-workload tx
- store.ErrUnique sentinel translates SQLite UNIQUE at store boundary
- /api/triggers CRUD + /api/triggers/{id}/{webhook,bindings}
- /api/bindings/{id} update/delete; /api/workloads/{id}/triggers list+bind
- bindTriggerToWorkload accepts trigger_id or inline {kind,name,config}
- inline-create uses CreateTriggerWithBindingTx (no orphan triggers)
- validateBindingConfig enforces 8 KiB cap + plugin Validate on merged
- ListTriggersWithBindingCount + ListBindings*WithNames remove N+1
- POST /api/webhook/triggers/{secret} resolves trigger then fans out
- bounded worker pool (4) per request; per-binding error isolation
- outcome accounting: deployed / skipped / no-match / errored
- legacy /api/webhook/workloads/{secret} route removed (clean break;
  backfill keeps secrets resolvable at the new /triggers/{secret} path)
- reconciler gate dropped from (Source && Trigger) to Source only
- MergeJSONConfig returns freshly allocated slices (no fan-out aliasing)
- WithEffectiveTrigger lets existing Trigger.Match contract stay unchanged

Frontend
- /triggers list, new wizard, [id] detail (bindings, webhook rotate)
- workload create wizard: NEW / PICK / SKIP trigger modes
- workload detail: bindings panel + Add-trigger modal (inline / pick)
- per-binding override editor with merged-preview + 8 KiB guard
- "OVERRIDES n FIELDS" row badge when binding_config is non-empty
- shared TriggerKindForm component (registry / git / manual + JSON)
- 3 raw <input type=checkbox> replaced with <ToggleSwitch>
- full EN + RU i18n: redeployTriggers.*, apps.detail.bindings.*,
  apps.new.triggers.*, nav.triggers; event-triggers nav disambiguated

Doc
- WORKLOAD_REFACTOR_TODO: trigger-split marked DONE; next focus is
  the static-source inline port + hard legacy cutover (Priority 1)
This commit is contained in:
2026-05-16 02:24:31 +03:00
parent 30133bc1eb
commit 2aff22f565
21 changed files with 7445 additions and 460 deletions
+121 -69
View File
@@ -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 `<ToggleSwitch>` + 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 `<input type="checkbox">`
instances in `apps/new` + `apps/[id]` (render-markdown, env-encrypted)
replaced with `<ToggleSwitch>` 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