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
+270
View File
@@ -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
}