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:
@@ -25,9 +25,10 @@ import (
|
||||
// already serializes pulls).
|
||||
const maxTriggerFanOutConcurrency = 4
|
||||
|
||||
// bindingResult is the per-binding entry in the trigger fan-out
|
||||
// response body.
|
||||
type bindingResult struct {
|
||||
// BindingResult is the per-binding entry in the trigger fan-out
|
||||
// response body. Exported so non-HTTP callers (the scheduler) can
|
||||
// inspect outcomes after calling FanOutForTrigger.
|
||||
type BindingResult struct {
|
||||
Workload string `json:"workload"`
|
||||
Deployed bool `json:"deployed"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
@@ -191,6 +192,35 @@ func (h *Handler) handleTriggerWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// FanOutForTrigger looks up the trigger plugin + bindings for trg and
|
||||
// dispatches evt through the same bounded worker pool the inbound HTTP
|
||||
// webhook uses. The scheduler calls this on each tick to fire schedule
|
||||
// triggers without a real HTTP request — same dispatch path, same
|
||||
// per-binding isolation, same outcome shape.
|
||||
//
|
||||
// Returns nil + error only when the trigger plugin is missing or the
|
||||
// bindings query fails — both fatal upstream conditions the caller
|
||||
// should log. A per-binding error becomes a row in the result slice
|
||||
// with Deployed=false; that case returns nil error.
|
||||
func (h *Handler) FanOutForTrigger(
|
||||
ctx context.Context,
|
||||
trg store.Trigger,
|
||||
evt plugin.InboundEvent,
|
||||
) ([]BindingResult, error) {
|
||||
if h.plugins == nil {
|
||||
return nil, fmt.Errorf("plugin dispatcher not wired")
|
||||
}
|
||||
trigPlugin, err := plugin.GetTrigger(trg.Kind)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("trigger plugin %q: %w", trg.Kind, err)
|
||||
}
|
||||
bindings, err := h.store.ListBindingsForTrigger(trg.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list bindings: %w", err)
|
||||
}
|
||||
return h.fanOutBindings(ctx, trg, trigPlugin, bindings, evt), nil
|
||||
}
|
||||
|
||||
// fanOutBindings dispatches every binding through fireBinding with at
|
||||
// most maxTriggerFanOutConcurrency goroutines in flight. Order of the
|
||||
// returned slice matches the input bindings slice so callers can rely
|
||||
@@ -205,8 +235,8 @@ func (h *Handler) fanOutBindings(
|
||||
trigPlugin plugin.Trigger,
|
||||
bindings []store.WorkloadTriggerBinding,
|
||||
evt plugin.InboundEvent,
|
||||
) []bindingResult {
|
||||
results := make([]bindingResult, len(bindings))
|
||||
) []BindingResult {
|
||||
results := make([]BindingResult, len(bindings))
|
||||
concurrency := maxTriggerFanOutConcurrency
|
||||
if len(bindings) < concurrency {
|
||||
concurrency = len(bindings)
|
||||
@@ -218,14 +248,14 @@ func (h *Handler) fanOutBindings(
|
||||
var wg sync.WaitGroup
|
||||
for i, b := range bindings {
|
||||
if !b.Enabled {
|
||||
results[i] = bindingResult{Workload: b.WorkloadID, Deployed: false, Reason: "binding disabled"}
|
||||
results[i] = BindingResult{Workload: b.WorkloadID, Deployed: false, Reason: "binding disabled"}
|
||||
continue
|
||||
}
|
||||
row, lookupErr := h.store.GetWorkloadByID(b.WorkloadID)
|
||||
if lookupErr != nil {
|
||||
slog.Warn("webhook: bound workload missing",
|
||||
"trigger", trg.Name, "workload", b.WorkloadID, "error", lookupErr)
|
||||
results[i] = bindingResult{Workload: b.WorkloadID, Deployed: false, Reason: "workload missing"}
|
||||
results[i] = BindingResult{Workload: b.WorkloadID, Deployed: false, Reason: "workload missing"}
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
@@ -234,7 +264,7 @@ func (h *Handler) fanOutBindings(
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
fired, reason := h.fireBinding(ctx, trg, trigPlugin, wl, binding, evt)
|
||||
results[idx] = bindingResult{Workload: wl.Name, Deployed: fired, Reason: reason}
|
||||
results[idx] = BindingResult{Workload: wl.Name, Deployed: fired, Reason: reason}
|
||||
}(i, b, row)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
Reference in New Issue
Block a user