39e1e36510
Build / build (push) Successful in 10m42s
Fourth trigger kind alongside registry/git/manual. Recurring time-interval fires driven by a new internal/scheduler tick loop (default 30s, clamped to 5m). Goes through the same webhook.Handler.FanOutForTrigger seam as inbound HTTP webhooks, so per-binding concurrency, outcome accounting, and config-merge semantics are identical. Schema: triggers.last_fired_at TEXT column (additive ALTER for existing DBs). Scheduler persists last_fired_at BEFORE dispatch so a panicking Match cannot wedge a tight loop; failed deploys wait one full interval before retry — correct trade-off for a periodic refresh trigger. Frontend: TriggerKindForm + /triggers/new + /triggers/[id] gain the schedule kind (4-col card grid, preset chips Hourly/Daily/Weekly, custom interval input matched to Go time.ParseDuration syntax, optional pinned reference). /triggers/[id] surfaces "last fired" on schedule rows. EN+RU i18n in parity. Review fixes from go-reviewer / security-reviewer / typescript-reviewer: - Scheduler Start/Stop wrapped in sync.Once (no goroutine leak / double- cancel panic on shutdown re-entry). - shouldFire rejects sub-MinInterval as defense-in-depth against hand-inserted rows that bypassed Validate. - fire() asserts trigger Kind=="schedule" before dispatching. - Aligned isValidInterval regex across all three frontend sites; reject the unsupported "d" unit (Go time.ParseDuration doesn't accept it). - formatLastFired falls back to lastFiredNever on malformed timestamps rather than leaking raw bytes into the UI. - main.go scheduler closure logs per-fire deployed/errored counts.
325 lines
9.7 KiB
Go
325 lines
9.7 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
const triggerColumns = `id, kind, name, config,
|
|
webhook_secret, webhook_signing_secret, webhook_require_signature,
|
|
last_fired_at, created_at, updated_at`
|
|
|
|
func scanTrigger(s rowScanner) (Trigger, error) {
|
|
var t Trigger
|
|
var requireSig int
|
|
if err := s.Scan(&t.ID, &t.Kind, &t.Name, &t.Config,
|
|
&t.WebhookSecret, &t.WebhookSigningSecret, &requireSig,
|
|
&t.LastFiredAt, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
|
return Trigger{}, err
|
|
}
|
|
t.WebhookRequireSignature = requireSig != 0
|
|
return t, nil
|
|
}
|
|
|
|
// CreateTrigger inserts a new trigger row. Kind + Name are required.
|
|
// Config is normalized to "{}" when empty; webhook secret is left empty
|
|
// unless the caller pre-populates it.
|
|
func (s *Store) CreateTrigger(t Trigger) (Trigger, error) {
|
|
if t.ID == "" {
|
|
t.ID = uuid.New().String()
|
|
}
|
|
if t.Config == "" {
|
|
t.Config = "{}"
|
|
}
|
|
t.CreatedAt = Now()
|
|
t.UpdatedAt = t.CreatedAt
|
|
_, err := s.db.Exec(
|
|
`INSERT INTO triggers (`+triggerColumns+`)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
t.ID, t.Kind, t.Name, t.Config,
|
|
t.WebhookSecret, t.WebhookSigningSecret, BoolToInt(t.WebhookRequireSignature),
|
|
t.LastFiredAt, t.CreatedAt, t.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return Trigger{}, fmt.Errorf("insert trigger: %w", translateSQLError(err))
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
// GetTriggerByID returns one trigger.
|
|
func (s *Store) GetTriggerByID(id string) (Trigger, error) {
|
|
t, err := scanTrigger(s.db.QueryRow(
|
|
`SELECT `+triggerColumns+` FROM triggers WHERE id = ?`, id,
|
|
))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Trigger{}, fmt.Errorf("trigger %s: %w", id, ErrNotFound)
|
|
}
|
|
if err != nil {
|
|
return Trigger{}, fmt.Errorf("query trigger: %w", err)
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
// GetTriggerByWebhookSecret resolves a trigger by its inbound webhook
|
|
// secret. Empty input is treated as not-found to avoid accidental matches
|
|
// against rows whose webhook is disabled.
|
|
func (s *Store) GetTriggerByWebhookSecret(secret string) (Trigger, error) {
|
|
if secret == "" {
|
|
return Trigger{}, fmt.Errorf("empty secret: %w", ErrNotFound)
|
|
}
|
|
t, err := scanTrigger(s.db.QueryRow(
|
|
`SELECT `+triggerColumns+` FROM triggers WHERE webhook_secret = ?`, secret,
|
|
))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Trigger{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return Trigger{}, fmt.Errorf("query trigger by webhook secret: %w", err)
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
// GetTriggerByName resolves a trigger by its unique human name.
|
|
func (s *Store) GetTriggerByName(name string) (Trigger, error) {
|
|
if name == "" {
|
|
return Trigger{}, fmt.Errorf("empty name: %w", ErrNotFound)
|
|
}
|
|
t, err := scanTrigger(s.db.QueryRow(
|
|
`SELECT `+triggerColumns+` FROM triggers WHERE name = ?`, name,
|
|
))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Trigger{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return Trigger{}, fmt.Errorf("query trigger by name: %w", err)
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
// ListTriggers returns all triggers, ordered by name. Optional kind
|
|
// filter — empty string returns everything.
|
|
func (s *Store) ListTriggers(kind string) ([]Trigger, error) {
|
|
var rows *sql.Rows
|
|
var err error
|
|
if kind == "" {
|
|
rows, err = s.db.Query(`SELECT ` + triggerColumns + ` FROM triggers ORDER BY name`)
|
|
} else {
|
|
rows, err = s.db.Query(`SELECT `+triggerColumns+` FROM triggers WHERE kind = ? ORDER BY name`, kind)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query triggers: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []Trigger{}
|
|
for rows.Next() {
|
|
t, err := scanTrigger(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan trigger: %w", err)
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// TriggerWithBindingCount projects a Trigger plus its current binding
|
|
// count in a single round-trip. Used by /api/triggers list rendering so
|
|
// the response avoids one COUNT(*) per trigger row.
|
|
type TriggerWithBindingCount struct {
|
|
Trigger
|
|
BindingCount int
|
|
}
|
|
|
|
// ListTriggersWithBindingCount returns every trigger ordered by name
|
|
// with the count of its bindings joined in. Optional kind filter.
|
|
func (s *Store) ListTriggersWithBindingCount(kind string) ([]TriggerWithBindingCount, error) {
|
|
const base = `
|
|
SELECT t.id, t.kind, t.name, t.config,
|
|
t.webhook_secret, t.webhook_signing_secret, t.webhook_require_signature,
|
|
t.last_fired_at, t.created_at, t.updated_at,
|
|
COALESCE(b.cnt, 0)
|
|
FROM triggers t
|
|
LEFT JOIN (
|
|
SELECT trigger_id, COUNT(*) AS cnt
|
|
FROM workload_trigger_bindings
|
|
GROUP BY trigger_id
|
|
) b ON b.trigger_id = t.id`
|
|
var rows *sql.Rows
|
|
var err error
|
|
if kind == "" {
|
|
rows, err = s.db.Query(base + ` ORDER BY t.name`)
|
|
} else {
|
|
rows, err = s.db.Query(base+` WHERE t.kind = ? ORDER BY t.name`, kind)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query triggers with binding count: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []TriggerWithBindingCount{}
|
|
for rows.Next() {
|
|
var t Trigger
|
|
var requireSig int
|
|
var count int
|
|
if err := rows.Scan(&t.ID, &t.Kind, &t.Name, &t.Config,
|
|
&t.WebhookSecret, &t.WebhookSigningSecret, &requireSig,
|
|
&t.LastFiredAt, &t.CreatedAt, &t.UpdatedAt, &count); err != nil {
|
|
return nil, fmt.Errorf("scan trigger+count: %w", err)
|
|
}
|
|
t.WebhookRequireSignature = requireSig != 0
|
|
out = append(out, TriggerWithBindingCount{Trigger: t, BindingCount: count})
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// UpdateTrigger updates the mutable fields of a trigger. Kind is
|
|
// immutable post-create — changing kinds would invalidate every
|
|
// binding's interpretation of binding_config.
|
|
func (s *Store) UpdateTrigger(t Trigger) error {
|
|
t.UpdatedAt = Now()
|
|
if t.Config == "" {
|
|
t.Config = "{}"
|
|
}
|
|
result, err := s.db.Exec(
|
|
`UPDATE triggers SET name=?, config=?,
|
|
webhook_secret=?, webhook_signing_secret=?, webhook_require_signature=?,
|
|
updated_at=?
|
|
WHERE id=?`,
|
|
t.Name, t.Config,
|
|
t.WebhookSecret, t.WebhookSigningSecret, BoolToInt(t.WebhookRequireSignature),
|
|
t.UpdatedAt, t.ID,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("update trigger: %w", translateSQLError(err))
|
|
}
|
|
n, _ := result.RowsAffected()
|
|
if n == 0 {
|
|
return fmt.Errorf("trigger %s: %w", t.ID, ErrNotFound)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteTrigger removes a trigger row. Bindings cascade away via FK.
|
|
func (s *Store) DeleteTrigger(id string) error {
|
|
result, err := s.db.Exec(`DELETE FROM triggers WHERE id = ?`, id)
|
|
if err != nil {
|
|
return fmt.Errorf("delete trigger: %w", err)
|
|
}
|
|
n, _ := result.RowsAffected()
|
|
if n == 0 {
|
|
return fmt.Errorf("trigger %s: %w", id, ErrNotFound)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CreateTriggerWithBindingTx atomically creates a trigger row and
|
|
// binds it to a workload. Used by the workload-side inline-create-and-
|
|
// bind endpoint so a binding-insert failure does not leave an orphan
|
|
// trigger row behind. Returns the persisted trigger and binding.
|
|
func (s *Store) CreateTriggerWithBindingTx(t Trigger, b WorkloadTriggerBinding) (Trigger, WorkloadTriggerBinding, error) {
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return Trigger{}, WorkloadTriggerBinding{}, fmt.Errorf("begin: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
if t.ID == "" {
|
|
t.ID = uuid.New().String()
|
|
}
|
|
if t.Config == "" {
|
|
t.Config = "{}"
|
|
}
|
|
t.CreatedAt = Now()
|
|
t.UpdatedAt = t.CreatedAt
|
|
if _, err := tx.Exec(
|
|
`INSERT INTO triggers (`+triggerColumns+`)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
t.ID, t.Kind, t.Name, t.Config,
|
|
t.WebhookSecret, t.WebhookSigningSecret, BoolToInt(t.WebhookRequireSignature),
|
|
t.LastFiredAt, t.CreatedAt, t.UpdatedAt,
|
|
); err != nil {
|
|
return Trigger{}, WorkloadTriggerBinding{}, fmt.Errorf("insert trigger: %w", translateSQLError(err))
|
|
}
|
|
|
|
if b.ID == "" {
|
|
b.ID = uuid.New().String()
|
|
}
|
|
if b.BindingConfig == "" {
|
|
b.BindingConfig = "{}"
|
|
}
|
|
b.TriggerID = t.ID
|
|
b.CreatedAt = t.CreatedAt
|
|
b.UpdatedAt = t.UpdatedAt
|
|
if _, err := tx.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,
|
|
); err != nil {
|
|
return Trigger{}, WorkloadTriggerBinding{}, fmt.Errorf("insert binding: %w", translateSQLError(err))
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return Trigger{}, WorkloadTriggerBinding{}, fmt.Errorf("commit: %w", err)
|
|
}
|
|
return t, b, nil
|
|
}
|
|
|
|
// SetTriggerWebhookSecret rotates the inbound webhook URL secret. Pass
|
|
// empty string to disable webhook ingress for this trigger.
|
|
func (s *Store) SetTriggerWebhookSecret(id, secret string) error {
|
|
result, err := s.db.Exec(
|
|
`UPDATE triggers SET webhook_secret=?, updated_at=? WHERE id=?`,
|
|
secret, Now(), id,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("update trigger webhook_secret: %w", err)
|
|
}
|
|
n, _ := result.RowsAffected()
|
|
if n == 0 {
|
|
return fmt.Errorf("trigger %s: %w", id, ErrNotFound)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// EnsureTriggerWebhookSecret returns the current secret, generating one
|
|
// lazily for triggers that have none. Mirrors the workload helper.
|
|
func (s *Store) EnsureTriggerWebhookSecret(id string) (string, error) {
|
|
t, err := s.GetTriggerByID(id)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if t.WebhookSecret != "" {
|
|
return t.WebhookSecret, nil
|
|
}
|
|
secret := generateWebhookSecret()
|
|
if err := s.SetTriggerWebhookSecret(id, secret); err != nil {
|
|
return "", err
|
|
}
|
|
return secret, nil
|
|
}
|
|
|
|
// SetTriggerLastFired records the wall-clock the scheduler last
|
|
// dispatched this trigger. Callers pass time.Now().UTC().Format(time.RFC3339)
|
|
// so the value is stable across timezones. Updating last_fired_at does
|
|
// not bump updated_at — last_fired_at is operational state, while
|
|
// updated_at tracks user-visible config edits.
|
|
func (s *Store) SetTriggerLastFired(id, ts string) error {
|
|
result, err := s.db.Exec(
|
|
`UPDATE triggers SET last_fired_at = ? WHERE id = ?`,
|
|
ts, id,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("update trigger last_fired_at: %w", err)
|
|
}
|
|
n, _ := result.RowsAffected()
|
|
if n == 0 {
|
|
return fmt.Errorf("trigger %s: %w", id, ErrNotFound)
|
|
}
|
|
return nil
|
|
}
|
|
|