refactor(triggers): review followups — fire-now, dedupe trigger pages, hardening
Build / build (push) Failing after 34s
Build / build (push) Failing after 34s
Follow-ups on commit 39e1e36 addressing review feedback from
go-reviewer / security-reviewer / typescript-reviewer.
Backend:
- New POST /api/triggers/{id}/fire (AdminOnly, schedule-only): operator
"Fire now" button — dispatches immediately without waiting for the
next natural interval. Persists last_fired_at BEFORE dispatch, same
ordering as the scheduler. Per-trigger in-flight guard (429 if a
fire is already running) to defend against rapid double-clicks /
runaway scripts. Refuses request when AdminOnly claims are absent
rather than logging an unattributable deploy.
- SetTriggerLastFired now validates timestamp parses as RFC3339 before
writing. Rejects empty string explicitly — empty-clears semantics
were dead (no caller) and would silently re-fire on next tick if
ever accidentally written. A future reset-cadence flow must add a
dedicated ClearTriggerLastFired so the call site is grep-able and
separately auditable.
- Scheduler logs WARN on catch-up fires (now - lastFired > 2× interval)
so the "surprise burst at restart" pattern shows up in audit logs.
- BindingResult reason strings extracted to package consts
(webhook.Reason*) so the scheduler and api fire-now classifications
stay in sync without string-matching drift.
- SECURITY NOTE on FanOutForTrigger documents that the
WebhookRequireSignature gate is ingress-only by design.
Frontend:
- Refactored /triggers/new (770 LOC → 155 LOC) and /triggers/[id]
(~350 LOC dropped) to use the shared TriggerKindForm. Eliminates the
triplicated per-kind state + buildConfig + canSubmit + template that
caused the d-unit regex drift in the prior commit.
- New seedTriggerKindFormState helper on TriggerKindForm primes the
form from a server-returned trigger config with defensive type
guards; resets per-kind slots first so re-seeding across kinds
doesn't inherit stale state.
- /triggers/[id] gains a Schedule status panel with Last Fired + Fire
Now button (gated on binding_count > 0). Confirmation dialog,
result flash, timer cleanup on unmount + new-fire (no stale-closure
race). EN+RU i18n parity.
This commit is contained in:
@@ -34,6 +34,18 @@ type BindingResult struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// Reason strings used in BindingResult.Reason. Exported so callers
|
||||
// classifying fan-out outcomes (e.g. the API fire-now summary log)
|
||||
// don't need to keep string literals in sync with this package.
|
||||
const (
|
||||
ReasonBindingDisabled = "binding disabled"
|
||||
ReasonWorkloadMissing = "workload missing"
|
||||
ReasonNoMatch = "no match"
|
||||
ReasonConfigError = "config merge error"
|
||||
ReasonMatchError = "match error"
|
||||
ReasonDispatchFailed = "dispatch failed"
|
||||
)
|
||||
|
||||
// handleTriggerWebhook processes an inbound webhook for a first-class
|
||||
// Trigger record. The secret resolves to one Trigger; the Trigger then
|
||||
// fans out to every enabled workload binding. Each binding gets its
|
||||
@@ -160,9 +172,9 @@ func (h *Handler) handleTriggerWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Deployed:
|
||||
deployed++
|
||||
case r.Reason == "binding disabled":
|
||||
case r.Reason == ReasonBindingDisabled:
|
||||
skipped++
|
||||
case r.Reason == "no match":
|
||||
case r.Reason == ReasonNoMatch:
|
||||
noMatch++
|
||||
default:
|
||||
errored++
|
||||
@@ -198,6 +210,14 @@ func (h *Handler) handleTriggerWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
// triggers without a real HTTP request — same dispatch path, same
|
||||
// per-binding isolation, same outcome shape.
|
||||
//
|
||||
// SECURITY NOTE: trg.WebhookSigningSecret + WebhookRequireSignature
|
||||
// gate INBOUND HTTP only (handleTriggerWebhook). This method skips
|
||||
// that check by design because the caller is first-party in-process
|
||||
// code — no untrusted bytes flow in here. If you add a new caller
|
||||
// outside the scheduler / inbound webhook, audit the call site for
|
||||
// authorization first; this is not a generic "fire any trigger"
|
||||
// entry point.
|
||||
//
|
||||
// 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
|
||||
@@ -248,14 +268,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: ReasonBindingDisabled}
|
||||
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: ReasonWorkloadMissing}
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
@@ -289,16 +309,16 @@ func (h *Handler) fireBinding(
|
||||
if err != nil {
|
||||
slog.Warn("webhook: merge effective trigger config failed",
|
||||
"trigger", trg.Name, "workload", row.Name, "error", err)
|
||||
return false, "config merge error"
|
||||
return false, ReasonConfigError
|
||||
}
|
||||
intent, err := trigPlugin.Match(ctx, h.plugins.PluginDeps(), pwl, evt)
|
||||
if err != nil {
|
||||
slog.Warn("webhook: trigger match error",
|
||||
"trigger", trg.Name, "workload", row.Name, "error", err)
|
||||
return false, "match error"
|
||||
return false, ReasonMatchError
|
||||
}
|
||||
if intent == nil {
|
||||
return false, "no match"
|
||||
return false, ReasonNoMatch
|
||||
}
|
||||
if intent.TriggeredAt.IsZero() {
|
||||
intent.TriggeredAt = time.Now().UTC()
|
||||
@@ -309,7 +329,7 @@ func (h *Handler) fireBinding(
|
||||
if err := h.plugins.DispatchPlugin(ctx, pwl, *intent); err != nil {
|
||||
slog.Warn("webhook: dispatch failed",
|
||||
"trigger", trg.Name, "workload", row.Name, "error", err)
|
||||
return false, "dispatch failed"
|
||||
return false, ReasonDispatchFailed
|
||||
}
|
||||
slog.Info("webhook: triggered deploy via trigger fan-out",
|
||||
"trigger", trg.Name, "workload", row.Name, "reason", intent.Reason)
|
||||
|
||||
Reference in New Issue
Block a user