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