feat(triggers): add schedule trigger kind + internal scheduler
Build / build (push) Successful in 10m42s
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.
This commit is contained in:
@@ -366,8 +366,13 @@ type Trigger struct {
|
||||
WebhookSecret string `json:"-"` // URL-identifier secret; never serialized
|
||||
WebhookSigningSecret string `json:"-"` // HMAC key; never serialized
|
||||
WebhookRequireSignature bool `json:"webhook_require_signature"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
// LastFiredAt is the RFC3339 wall-clock the scheduler last dispatched
|
||||
// this trigger. Empty for never-fired or non-schedule triggers. The
|
||||
// scheduler reads + writes this column to decide next-fire windows
|
||||
// and to surface "last fired" on the trigger detail page.
|
||||
LastFiredAt string `json:"last_fired_at,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// WorkloadTriggerBinding joins a Workload to a Trigger. BindingConfig is
|
||||
|
||||
@@ -164,6 +164,11 @@ func (s *Store) runMigrations() error {
|
||||
`ALTER TABLE workloads ADD COLUMN trigger_config TEXT NOT NULL DEFAULT '{}'`,
|
||||
`ALTER TABLE workloads ADD COLUMN public_faces TEXT NOT NULL DEFAULT '[]'`,
|
||||
`ALTER TABLE workloads ADD COLUMN parent_workload_id TEXT NOT NULL DEFAULT ''`,
|
||||
// Schedule trigger needs a column to remember when it last fired so
|
||||
// the scheduler can compute next-fire windows across restarts.
|
||||
// Empty string = never fired. Pre-trigger-split DBs land the column
|
||||
// here so the scheduler can read/write it on first boot.
|
||||
`ALTER TABLE triggers ADD COLUMN last_fired_at TEXT NOT NULL DEFAULT ''`,
|
||||
// Hard cutover: drop every legacy table. Idempotent — DROP TABLE
|
||||
// IF EXISTS is a no-op once the table is gone. Operators upgrading
|
||||
// from a pre-cutover build will lose any project / stack / static
|
||||
@@ -275,6 +280,7 @@ func (s *Store) runMigrations() error {
|
||||
webhook_secret TEXT NOT NULL DEFAULT '',
|
||||
webhook_signing_secret TEXT NOT NULL DEFAULT '',
|
||||
webhook_require_signature INTEGER NOT NULL DEFAULT 0,
|
||||
last_fired_at TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`,
|
||||
|
||||
@@ -10,14 +10,14 @@ import (
|
||||
|
||||
const triggerColumns = `id, kind, name, config,
|
||||
webhook_secret, webhook_signing_secret, webhook_require_signature,
|
||||
created_at, updated_at`
|
||||
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.CreatedAt, &t.UpdatedAt); err != nil {
|
||||
&t.LastFiredAt, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
||||
return Trigger{}, err
|
||||
}
|
||||
t.WebhookRequireSignature = requireSig != 0
|
||||
@@ -38,10 +38,10 @@ func (s *Store) CreateTrigger(t Trigger) (Trigger, error) {
|
||||
t.UpdatedAt = t.CreatedAt
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO triggers (`+triggerColumns+`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
t.ID, t.Kind, t.Name, t.Config,
|
||||
t.WebhookSecret, t.WebhookSigningSecret, BoolToInt(t.WebhookRequireSignature),
|
||||
t.CreatedAt, t.UpdatedAt,
|
||||
t.LastFiredAt, t.CreatedAt, t.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return Trigger{}, fmt.Errorf("insert trigger: %w", translateSQLError(err))
|
||||
@@ -139,7 +139,7 @@ func (s *Store) ListTriggersWithBindingCount(kind string) ([]TriggerWithBindingC
|
||||
const base = `
|
||||
SELECT t.id, t.kind, t.name, t.config,
|
||||
t.webhook_secret, t.webhook_signing_secret, t.webhook_require_signature,
|
||||
t.created_at, t.updated_at,
|
||||
t.last_fired_at, t.created_at, t.updated_at,
|
||||
COALESCE(b.cnt, 0)
|
||||
FROM triggers t
|
||||
LEFT JOIN (
|
||||
@@ -166,7 +166,7 @@ func (s *Store) ListTriggersWithBindingCount(kind string) ([]TriggerWithBindingC
|
||||
var count int
|
||||
if err := rows.Scan(&t.ID, &t.Kind, &t.Name, &t.Config,
|
||||
&t.WebhookSecret, &t.WebhookSigningSecret, &requireSig,
|
||||
&t.CreatedAt, &t.UpdatedAt, &count); err != nil {
|
||||
&t.LastFiredAt, &t.CreatedAt, &t.UpdatedAt, &count); err != nil {
|
||||
return nil, fmt.Errorf("scan trigger+count: %w", err)
|
||||
}
|
||||
t.WebhookRequireSignature = requireSig != 0
|
||||
@@ -236,10 +236,10 @@ func (s *Store) CreateTriggerWithBindingTx(t Trigger, b WorkloadTriggerBinding)
|
||||
t.UpdatedAt = t.CreatedAt
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO triggers (`+triggerColumns+`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
t.ID, t.Kind, t.Name, t.Config,
|
||||
t.WebhookSecret, t.WebhookSigningSecret, BoolToInt(t.WebhookRequireSignature),
|
||||
t.CreatedAt, t.UpdatedAt,
|
||||
t.LastFiredAt, t.CreatedAt, t.UpdatedAt,
|
||||
); err != nil {
|
||||
return Trigger{}, WorkloadTriggerBinding{}, fmt.Errorf("insert trigger: %w", translateSQLError(err))
|
||||
}
|
||||
@@ -301,3 +301,24 @@ func (s *Store) EnsureTriggerWebhookSecret(id string) (string, error) {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user