2aff22f565
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)
271 lines
8.2 KiB
Go
271 lines
8.2 KiB
Go
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
|
|
}
|