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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user