diff --git a/cmd/server/main.go b/cmd/server/main.go index db1d35b..9be2c34 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -197,7 +197,7 @@ func main() { switch { case r.Deployed: deployed++ - case r.Reason == "binding disabled", r.Reason == "no match": + case r.Reason == webhook.ReasonBindingDisabled, r.Reason == webhook.ReasonNoMatch: // not a failure — silent default: errored++ diff --git a/internal/api/router.go b/internal/api/router.go index 25a8c83..bf8dc2e 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -318,6 +318,7 @@ func (s *Server) Router() chi.Router { r.Delete("/triggers/{id}", s.deleteTrigger) r.Get("/triggers/{id}/webhook", s.getTriggerWebhook) r.Post("/triggers/{id}/webhook/regenerate", s.regenerateTriggerWebhook) + r.Post("/triggers/{id}/fire", s.fireTriggerNow) r.Post("/triggers/{id}/bindings", s.bindWorkloadToTrigger) r.Put("/bindings/{bid}", s.updateBinding) r.Delete("/bindings/{bid}", s.deleteBinding) diff --git a/internal/api/triggers.go b/internal/api/triggers.go index 1e2dc06..88cb195 100644 --- a/internal/api/triggers.go +++ b/internal/api/triggers.go @@ -7,13 +7,27 @@ import ( "log/slog" "net/http" "strings" + "sync" + "time" "github.com/go-chi/chi/v5" + "github.com/alexei/tinyforge/internal/auth" "github.com/alexei/tinyforge/internal/store" + "github.com/alexei/tinyforge/internal/webhook" "github.com/alexei/tinyforge/internal/workload/plugin" ) +// fireInFlight tracks trigger IDs that have a fire-now request actively +// running so a runaway script or rapid double-click doesn't queue +// duplicate deploys. Keyed by trigger ID; entries are added under the +// mutex and removed by the handler's defer. Sufficient for an admin +// gate — a real rate limiter belongs at the middleware layer, not here. +var ( + fireInFlightMu sync.Mutex + fireInFlight = map[string]struct{}{} +) + // triggerView is the response shape for /api/triggers. Webhook secrets // are never serialized — read them via the dedicated /webhook subresource // where the canonical URL is composed. @@ -251,6 +265,126 @@ func (s *Server) getTriggerWebhook(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, view) } +// fireTriggerNow dispatches a trigger immediately without waiting for +// its next natural fire window. Used by the /triggers/[id] "Fire now" +// button so an operator can re-test a fixed broken deploy without +// waiting one full schedule interval. +// +// Scope: schedule triggers only. Other kinds (registry / git / manual) +// already have their own dispatch paths — registry/git fire on real +// inbound events, manual fires from the workload Deploy button. Adding +// "fire-now" for those would duplicate those flows without adding new +// capability. +// +// Side effect: updates last_fired_at to "now" (same persist-before- +// dispatch ordering the scheduler uses) so the natural next-fire +// window shifts forward by exactly the interval. This is the +// principle-of-least-surprise behavior — an operator who fires now +// is intentionally resetting the cadence. +func (s *Server) fireTriggerNow(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + // Per-trigger in-flight guard. AdminOnly + UI throttle is the only + // gate against rapid double-clicks; without this guard a runaway + // script could queue parallel fans-out of the same schedule, each + // holding up to maxTriggerFanOutConcurrency deployer slots. + // Returning 429 lets the client distinguish "already running" from + // a real validation error. + fireInFlightMu.Lock() + if _, busy := fireInFlight[id]; busy { + fireInFlightMu.Unlock() + respondError(w, http.StatusTooManyRequests, + "a fire is already in progress for this trigger") + return + } + fireInFlight[id] = struct{}{} + fireInFlightMu.Unlock() + defer func() { + fireInFlightMu.Lock() + delete(fireInFlight, id) + fireInFlightMu.Unlock() + }() + + trg, err := s.store.GetTriggerByID(id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "trigger") + return + } + respondError(w, http.StatusInternalServerError, "failed to load trigger") + return + } + if trg.Kind != "schedule" { + respondError(w, http.StatusBadRequest, + "fire-now is only supported for schedule triggers") + return + } + + // AdminOnly middleware guarantees claims; treat their absence as a + // boot-time wiring bug rather than fall back to an unattributable + // "manual" string that collides with the `manual` trigger kind in + // audit logs. + claims, ok := auth.ClaimsFromContext(r.Context()) + if !ok || claims.Username == "" { + slog.Error("fire-now: missing claims under AdminOnly", "trigger", trg.Name) + respondError(w, http.StatusInternalServerError, "missing auth context") + return + } + actor := claims.Username + + now := time.Now().UTC() + if err := s.store.SetTriggerLastFired(trg.ID, now.Format(time.RFC3339)); err != nil { + respondError(w, http.StatusInternalServerError, "persist last_fired_at") + return + } + + evt := plugin.InboundEvent{ + Kind: "schedule", + Schedule: &plugin.ScheduleEvent{FiredAt: now}, + } + results, err := s.webhook.FanOutForTrigger(r.Context(), trg, evt) + if err != nil { + slog.Warn("fire-now: fan-out failed", + "trigger", trg.Name, "actor", actor, "error", err) + // Don't expose the raw error — it can carry registry-auth or + // compose-stdout bytes (matches the manual-deploy handler). + respondError(w, http.StatusInternalServerError, "fire failed; see server logs") + return + } + + var deployed, errored int + for _, b := range results { + switch { + case b.Deployed: + deployed++ + case b.Reason == webhook.ReasonBindingDisabled, b.Reason == webhook.ReasonNoMatch: + // silent + default: + errored++ + } + } + // Empty fan-out (no bindings) is almost certainly an operator + // mistake — the UI button is gated on binding_count>0, but the + // counts can change between page load and click. Warn so the + // no-op shows up in audit logs. + if len(results) == 0 { + slog.Warn("fire-now: no bindings to fire", + "trigger", trg.Name, "actor", actor) + } else { + slog.Info("fire-now dispatched", + "trigger", trg.Name, "actor", actor, + "bindings", len(results), "deployed", deployed, "errored", errored) + } + + respondJSON(w, http.StatusAccepted, map[string]any{ + "trigger": trg.Name, + "fired_at": now.Format(time.RFC3339), + "bindings": len(results), + "deployed": deployed, + "errored": errored, + }) +} + func (s *Server) regenerateTriggerWebhook(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") secret := generateWebhookSecret() diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 3ff4c1f..46f4022 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -171,9 +171,31 @@ func (s *Scheduler) shouldFire(t store.Trigger, now time.Time) bool { // require a manual DB poke. return true } - return !now.Before(last.Add(interval)) + if now.Before(last.Add(interval)) { + return false + } + // Catch-up warning: a trigger whose last_fired_at is many intervals + // old (paused-then-resumed, restored from backup, or just left + // running while the dispatcher was down) WILL fire on this tick. + // Log a one-line warning so the operator can recognize the "surprise + // burst at restart" pattern in audit logs. We still fire — silent + // no-fire would be worse — but the warning explains why. + if overdue := now.Sub(last); overdue > catchUpWarnThreshold*interval { + slog.Warn("scheduler: catch-up fire (very overdue)", + "trigger", t.Name, "overdue", overdue, "interval", interval) + } + return true } +// catchUpWarnThreshold is the multiplier on `interval` past which a +// fire is logged as "catch-up." 2× means a daily schedule whose last +// fire was more than 48h ago gets a warning at next tick. Chosen so +// the warning fires on "wedged for many intervals" without alerting on +// the every-tick lag a healthy 30s-tick scheduler accumulates against +// a sub-minute interval. Bigger threshold = noisier-quiet trade-off; +// 2× is the smallest value that excludes single-tick lag. +const catchUpWarnThreshold = 2 + // fire dispatches one trigger and records the new last_fired_at. // // We persist last_fired_at BEFORE calling the dispatcher so a panic diff --git a/internal/store/triggers.go b/internal/store/triggers.go index 1518392..cd6ab64 100644 --- a/internal/store/triggers.go +++ b/internal/store/triggers.go @@ -4,6 +4,7 @@ import ( "database/sql" "errors" "fmt" + "time" "github.com/google/uuid" ) @@ -307,7 +308,17 @@ func (s *Store) EnsureTriggerWebhookSecret(id string) (string, error) { // 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. +// +// ts must parse as RFC3339 — a defense-in-depth check so a careless +// caller cannot corrupt the column with a garbage string the scheduler +// would refuse to parse on every tick. To clear the column (effectively +// "fire on next tick"), use a separate API rather than passing empty +// here; the narrow contract keeps the call site grep-able and forces +// any reset-cadence flow to be explicitly designed and authorized. func (s *Store) SetTriggerLastFired(id, ts string) error { + if _, err := time.Parse(time.RFC3339, ts); err != nil { + return fmt.Errorf("invalid last_fired_at %q (want RFC3339): %w", ts, err) + } result, err := s.db.Exec( `UPDATE triggers SET last_fired_at = ? WHERE id = ?`, ts, id, diff --git a/internal/webhook/trigger_handler.go b/internal/webhook/trigger_handler.go index 1c61756..bd49b67 100644 --- a/internal/webhook/trigger_handler.go +++ b/internal/webhook/trigger_handler.go @@ -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) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index a87baf1..df74e4a 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -801,6 +801,20 @@ export function regenerateTriggerWebhook(id: string): Promise<{ secret: string; return post<{ secret: string; url: string }>(`/api/triggers/${id}/webhook/regenerate`); } +export interface FireNowResponse { + trigger: string; + fired_at: string; + bindings: number; + deployed: number; + errored: number; +} + +/** Fire a schedule trigger immediately without waiting for the next + * natural fire window. Backend rejects with 400 for non-schedule kinds. */ +export function fireTriggerNow(id: string): Promise { + return post(`/api/triggers/${id}/fire`); +} + export function listBindingsForTrigger(id: string, signal?: AbortSignal): Promise { return get(`/api/triggers/${id}/bindings`, signal); } diff --git a/web/src/lib/components/TriggerKindForm.svelte b/web/src/lib/components/TriggerKindForm.svelte index 22a6a4c..7a9a717 100644 --- a/web/src/lib/components/TriggerKindForm.svelte +++ b/web/src/lib/components/TriggerKindForm.svelte @@ -125,6 +125,84 @@ } } + /** Reset every per-kind slot to its default. Called by + * seedTriggerKindFormState before re-seeding so a caller that + * re-seeds across kinds (draft restore, future flows) does not + * inherit stale state from the previous kind's slots. The factory + * defaults live in createTriggerKindFormState — we restate them + * here rather than re-instantiating because the parent binds + * the state object by reference. */ + function resetKindSlots(s: TriggerKindFormState): void { + s.regImage = ''; + s.regTagPattern = '*'; + s.gitRepo = ''; + s.gitMode = 'push'; + s.gitBranch = 'main'; + s.gitTagPattern = 'v*'; + s.schInterval = '24h'; + s.schReference = ''; + } + + /** Seed an existing form state in place from a server-returned + * trigger config blob. Used by the /triggers/[id] edit page so the + * same component renders identically on create + edit. Unknown + * kinds force the advanced-JSON fallback. Typed defensively — a + * malformed config value falls back to the default rather than + * stringifying garbage into an input box. Safe to call repeatedly + * across kinds: every per-kind slot is reset before the switch. */ + export function seedTriggerKindFormState( + s: TriggerKindFormState, + kind: string, + name: string, + config: unknown, + webhookEnabled: boolean, + webhookRequireSig: boolean + ): void { + resetKindSlots(s); + s.kind = kind; + s.name = name; + s.webhookEnabled = webhookEnabled; + s.webhookRequireSig = webhookRequireSig; + const cfg = (config ?? {}) as Record; + // Prime the JSON text so toggling Advanced reveals the canonical + // shape rather than a blank box. JSON.stringify of a plain + // object only throws on cyclic refs, which a JSON-deserialized + // response cannot contain — no try/catch needed. + s.jsonText = JSON.stringify(cfg, null, 2); + // Force JSON-only mode for unknown kinds — the structured form + // has no branch for them. + const isKnown = (KNOWN_KINDS as readonly string[]).includes(kind); + if (!isKnown) { + s.useAdvancedJson = true; + return; + } + s.useAdvancedJson = false; + switch (kind) { + case 'registry': + s.regImage = typeof cfg.image === 'string' ? cfg.image : ''; + s.regTagPattern = typeof cfg.tag_pattern === 'string' ? cfg.tag_pattern : '*'; + break; + case 'git': + s.gitRepo = typeof cfg.repo === 'string' ? cfg.repo : ''; + // Backend Validate enforces mode ∈ {push, tag}. Anything + // else (undefined, "PUSH" case mismatch) collapses to + // "push" — the safe default, but worth flagging so an + // operator who hand-edited an invalid mode in the DB + // understands the silent rewrite. + s.gitMode = cfg.mode === 'tag' ? 'tag' : 'push'; + s.gitBranch = typeof cfg.branch === 'string' ? cfg.branch : 'main'; + s.gitTagPattern = typeof cfg.tag_pattern === 'string' ? cfg.tag_pattern : 'v*'; + break; + case 'manual': + // no structured fields + break; + case 'schedule': + s.schInterval = typeof cfg.interval === 'string' ? cfg.interval : '24h'; + s.schReference = typeof cfg.reference === 'string' ? cfg.reference : ''; + break; + } + } + export function buildTriggerInput(s: TriggerKindFormState): TriggerInput { let config: unknown; if (s.useAdvancedJson) { diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 88be3bf..4e35d82 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -1085,7 +1085,17 @@ "unbindMessage": "Workload \"{name}\" will stop redeploying when this trigger fires. The workload itself is not deleted.", "unbindConfirm": "Unbind", "lastFired": "Last fired", - "lastFiredNever": "Never fired" + "lastFiredNever": "Never fired", + "scheduleStatus": "Schedule status", + "scheduleStatusSub": "Operational state of the internal scheduler for this trigger. Fire-now skips ahead of the next natural window and resets the cadence to start counting from now.", + "fireNow": "Fire now", + "fireNowTitle": "Dispatch this trigger immediately and reset the next-fire window.", + "fireNowDisabledTitle": "Bind at least one workload before firing.", + "firing": "Firing…", + "fireConfirmTitle": "Fire schedule trigger?", + "fireConfirmMessage": "Trigger \"{name}\" will fire immediately and fan out to its {count} bound workload(s). The next natural fire window will be one full interval from now.", + "fireConfirm": "Fire now", + "fireResult": "Fired · deployed {deployed}/{bindings} · errored {errored}" }, "form": { "kindLabel": "Kind", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 2b373f5..c8828fb 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -1085,7 +1085,17 @@ "unbindMessage": "Нагрузка «{name}» перестанет передеплоиваться при срабатывании этого триггера. Сама нагрузка не удаляется.", "unbindConfirm": "Отвязать", "lastFired": "Последний запуск", - "lastFiredNever": "Ни разу не срабатывал" + "lastFiredNever": "Ни разу не срабатывал", + "scheduleStatus": "Состояние расписания", + "scheduleStatusSub": "Рабочее состояние внутреннего планировщика для этого триггера. «Запустить сейчас» сдвигает следующий запуск и начинает отсчёт нового интервала с этого момента.", + "fireNow": "Запустить сейчас", + "fireNowTitle": "Запустить триггер немедленно и сбросить окно следующего срабатывания.", + "fireNowDisabledTitle": "Привяжите хотя бы одну нагрузку перед запуском.", + "firing": "Запуск…", + "fireConfirmTitle": "Запустить триггер расписания?", + "fireConfirmMessage": "Триггер «{name}» сработает немедленно и развернёт {count} связанных нагрузок. Следующий естественный запуск будет через полный интервал от текущего момента.", + "fireConfirm": "Запустить", + "fireResult": "Сработал · задеплоено {deployed}/{bindings} · ошибок {errored}" }, "form": { "kindLabel": "Вид", diff --git a/web/src/routes/triggers/[id]/+page.svelte b/web/src/routes/triggers/[id]/+page.svelte index 944fe7d..1a316d9 100644 --- a/web/src/routes/triggers/[id]/+page.svelte +++ b/web/src/routes/triggers/[id]/+page.svelte @@ -1,5 +1,5 @@ @@ -208,293 +62,14 @@ {/if} - -
- - - {$t('redeployTriggers.form.kindLabel')} - {$t('redeployTriggers.form.required')} - -

{$t('redeployTriggers.form.kindHint')}

-
- {#each ALL_PICKABLE as k} - - {/each} -
-
- - -
- - -
- - -
- - - {$t('redeployTriggers.form.configLabel')} - {$t(`redeployTriggers.kindShort.${kind}`)} - - - - {#if useAdvancedJson} -

{$t('redeployTriggers.form.advancedHint')}

- - {:else if kind === 'registry'} - - - {:else if kind === 'git'} - -
- {$t('redeployTriggers.form.mode')} -
- - -
-
- {#if gitMode === 'push'} - - {:else} - - {/if} - {:else if kind === 'manual'} -
- MANUAL -

{$t('redeployTriggers.form.manualNote')}

-
- {:else if kind === 'schedule'} -
- CRN -

{$t('redeployTriggers.form.scheduleNote')}

-
-
- {$t('redeployTriggers.form.intervalPresets')} -
- {#each SCHEDULE_PRESETS as p (p.key)} - - {/each} -
-
- - - {:else} -
- ? -

{$t('redeployTriggers.form.unknownNote')}

-
- {/if} -
- - -
- - - {$t('redeployTriggers.detail.webhook')} - OPTIONAL - -
-
- {$t('redeployTriggers.form.webhookEnabled')} -

{$t('redeployTriggers.form.webhookEnabledHint')}

-
- -
- {#if webhookEnabled} -
-
- {$t('redeployTriggers.form.webhookRequireSig')} -

{$t('redeployTriggers.form.webhookRequireSigHint')}

-
- -
- {/if} -
+
{$t('redeployTriggers.form.cancel')}