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.
319 lines
11 KiB
Go
319 lines
11 KiB
Go
package webhook
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/alexei/tinyforge/internal/store"
|
|
"github.com/alexei/tinyforge/internal/workload/plugin"
|
|
)
|
|
|
|
// maxTriggerFanOutConcurrency caps how many bindings dispatch in
|
|
// parallel for a single trigger webhook. Sequential fan-out would hold
|
|
// the request goroutine for the sum of every binding's deploy time —
|
|
// minutes for an N-binding trigger. Bounding to 4 keeps wall-clock
|
|
// roughly N/4 * deploy_time without saturating the docker daemon (which
|
|
// already serializes pulls).
|
|
const maxTriggerFanOutConcurrency = 4
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// 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
|
|
// effective config (trigger.config + binding.binding_config merged) and
|
|
// runs through the trigger plugin's Match independently — one binding
|
|
// firing does not affect another.
|
|
//
|
|
// URL: POST /api/webhook/triggers/{secret}
|
|
//
|
|
// Response shape: aggregate counts so a CI can tell at a glance whether
|
|
// any deploys fired (status 200 + deploys=N) without parsing per-binding
|
|
// detail. Errors per-binding are logged at warn level but do not fail
|
|
// the whole request — one broken workload should not block the others.
|
|
func (h *Handler) handleTriggerWebhook(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
delivery := store.WebhookDelivery{
|
|
TargetType: "trigger",
|
|
SourceIP: clientIP(r),
|
|
SignatureState: sigStateUnconfigured,
|
|
StatusCode: http.StatusOK,
|
|
Outcome: outcomeSkip,
|
|
}
|
|
defer func() { h.recordDelivery(delivery) }()
|
|
|
|
if h.plugins == nil {
|
|
delivery.StatusCode = http.StatusServiceUnavailable
|
|
delivery.Outcome = outcomeError
|
|
delivery.Detail = "plugin dispatcher not wired"
|
|
respondWebhookError(w, http.StatusServiceUnavailable, "plugin dispatcher not wired")
|
|
return
|
|
}
|
|
|
|
secret := chi.URLParam(r, "secret")
|
|
if secret == "" {
|
|
delivery.StatusCode = http.StatusNotFound
|
|
delivery.Outcome = outcomeNotFound
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
trg, err := h.store.GetTriggerByWebhookSecret(secret)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
delivery.StatusCode = http.StatusNotFound
|
|
delivery.Outcome = outcomeNotFound
|
|
delivery.Detail = "unknown webhook secret"
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
slog.Error("webhook: trigger lookup failed", "error", err)
|
|
delivery.StatusCode = http.StatusNotFound
|
|
delivery.Outcome = outcomeError
|
|
delivery.Detail = "lookup failed"
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
delivery.TargetID = trg.ID
|
|
delivery.TargetName = trg.Name
|
|
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodyBytes))
|
|
if err != nil {
|
|
delivery.StatusCode = http.StatusBadRequest
|
|
delivery.Outcome = outcomeBadRequest
|
|
delivery.Detail = "failed to read request body"
|
|
respondWebhookError(w, http.StatusBadRequest, "failed to read request body")
|
|
return
|
|
}
|
|
delivery.BodySize = len(body)
|
|
|
|
header := r.Header.Get(signatureHeader)
|
|
verified, attempted := verifyHMAC(trg.WebhookSigningSecret, body, header)
|
|
delivery.SignatureState = signatureStateFor(trg.WebhookSigningSecret, header, verified, attempted)
|
|
if trg.WebhookRequireSignature && !verified {
|
|
slog.Warn("webhook: trigger signature required but invalid/missing", "trigger", trg.Name)
|
|
delivery.StatusCode = http.StatusUnauthorized
|
|
delivery.Outcome = outcomeRejected
|
|
delivery.Detail = "invalid or missing signature"
|
|
respondWebhookError(w, http.StatusUnauthorized, "invalid or missing signature")
|
|
return
|
|
}
|
|
if attempted && !verified {
|
|
slog.Warn("webhook: trigger bad signature", "trigger", trg.Name)
|
|
delivery.StatusCode = http.StatusUnauthorized
|
|
delivery.Outcome = outcomeRejected
|
|
delivery.Detail = "invalid signature"
|
|
respondWebhookError(w, http.StatusUnauthorized, "invalid signature")
|
|
return
|
|
}
|
|
|
|
evt, err := buildInboundEvent(body, r.Header)
|
|
if err != nil {
|
|
delivery.StatusCode = http.StatusBadRequest
|
|
delivery.Outcome = outcomeBadRequest
|
|
delivery.Detail = err.Error()
|
|
respondWebhookError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
trigPlugin, err := plugin.GetTrigger(trg.Kind)
|
|
if err != nil {
|
|
slog.Warn("webhook: trigger plugin not registered",
|
|
"trigger", trg.Name, "kind", trg.Kind, "error", err)
|
|
delivery.StatusCode = http.StatusInternalServerError
|
|
delivery.Outcome = outcomeError
|
|
delivery.Detail = "trigger plugin missing"
|
|
respondWebhookError(w, http.StatusInternalServerError, "trigger plugin missing")
|
|
return
|
|
}
|
|
|
|
bindings, err := h.store.ListBindingsForTrigger(trg.ID)
|
|
if err != nil {
|
|
slog.Error("webhook: list bindings failed", "trigger", trg.Name, "error", err)
|
|
delivery.StatusCode = http.StatusInternalServerError
|
|
delivery.Outcome = outcomeError
|
|
delivery.Detail = "list bindings failed"
|
|
respondWebhookError(w, http.StatusInternalServerError, "list bindings failed")
|
|
return
|
|
}
|
|
|
|
results := h.fanOutBindings(ctx, trg, trigPlugin, bindings, evt)
|
|
var deployed, skipped, noMatch, errored int
|
|
for _, r := range results {
|
|
switch {
|
|
case r.Deployed:
|
|
deployed++
|
|
case r.Reason == "binding disabled":
|
|
skipped++
|
|
case r.Reason == "no match":
|
|
noMatch++
|
|
default:
|
|
errored++
|
|
}
|
|
}
|
|
|
|
switch {
|
|
case deployed > 0:
|
|
delivery.Outcome = outcomeDeploy
|
|
delivery.Detail = fmt.Sprintf("deployed=%d of %d (errored=%d, skipped=%d)",
|
|
deployed, len(results), errored, skipped)
|
|
case errored > 0:
|
|
delivery.Outcome = outcomeError
|
|
delivery.Detail = fmt.Sprintf("errored=%d of %d", errored, len(results))
|
|
case skipped == len(results):
|
|
delivery.Detail = "all bindings disabled"
|
|
case noMatch == len(results)-skipped:
|
|
delivery.Detail = "no binding matched"
|
|
default:
|
|
delivery.Detail = fmt.Sprintf("matched=0 skipped=%d errored=%d", skipped, errored)
|
|
}
|
|
respondWebhookJSON(w, http.StatusOK, map[string]any{
|
|
"success": true,
|
|
"trigger": trg.Name,
|
|
"deployed": deployed,
|
|
"bindings": results,
|
|
})
|
|
}
|
|
|
|
// 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
|
|
// on positional correlation.
|
|
//
|
|
// Disabled bindings short-circuit on the orchestrator goroutine — they
|
|
// don't take a worker slot, leaving the pool free for real dispatches.
|
|
// Workload-missing rows are recorded as errors and also skip the pool.
|
|
func (h *Handler) fanOutBindings(
|
|
ctx context.Context,
|
|
trg store.Trigger,
|
|
trigPlugin plugin.Trigger,
|
|
bindings []store.WorkloadTriggerBinding,
|
|
evt plugin.InboundEvent,
|
|
) []BindingResult {
|
|
results := make([]BindingResult, len(bindings))
|
|
concurrency := maxTriggerFanOutConcurrency
|
|
if len(bindings) < concurrency {
|
|
concurrency = len(bindings)
|
|
}
|
|
if concurrency < 1 {
|
|
concurrency = 1
|
|
}
|
|
sem := make(chan struct{}, concurrency)
|
|
var wg sync.WaitGroup
|
|
for i, b := range bindings {
|
|
if !b.Enabled {
|
|
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"}
|
|
continue
|
|
}
|
|
wg.Add(1)
|
|
sem <- struct{}{}
|
|
go func(idx int, binding store.WorkloadTriggerBinding, wl store.Workload) {
|
|
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}
|
|
}(i, b, row)
|
|
}
|
|
wg.Wait()
|
|
return results
|
|
}
|
|
|
|
// fireBinding runs Match for one binding and dispatches if intent.
|
|
// Returns (fired, human-readable reason). Errors are logged but the
|
|
// reason is kept generic on the wire so a malformed binding does not
|
|
// leak internals.
|
|
func (h *Handler) fireBinding(
|
|
ctx context.Context,
|
|
trg store.Trigger,
|
|
trigPlugin plugin.Trigger,
|
|
row store.Workload,
|
|
b store.WorkloadTriggerBinding,
|
|
evt plugin.InboundEvent,
|
|
) (bool, string) {
|
|
pwl := toPluginWorkload(row)
|
|
pwl, err := plugin.WithEffectiveTrigger(pwl, trg.Kind,
|
|
json.RawMessage(trg.Config), json.RawMessage(b.BindingConfig))
|
|
if err != nil {
|
|
slog.Warn("webhook: merge effective trigger config failed",
|
|
"trigger", trg.Name, "workload", row.Name, "error", err)
|
|
return false, "config merge error"
|
|
}
|
|
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"
|
|
}
|
|
if intent == nil {
|
|
return false, "no match"
|
|
}
|
|
if intent.TriggeredAt.IsZero() {
|
|
intent.TriggeredAt = time.Now().UTC()
|
|
}
|
|
if intent.TriggeredBy == "" {
|
|
intent.TriggeredBy = "trigger-webhook"
|
|
}
|
|
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"
|
|
}
|
|
slog.Info("webhook: triggered deploy via trigger fan-out",
|
|
"trigger", trg.Name, "workload", row.Name, "reason", intent.Reason)
|
|
return true, intent.Reason
|
|
}
|
|
|