feat(triggers): first-class triggers + bindings with fan-out webhook
Build / build (push) Successful in 10m39s
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:
+121
-69
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user