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 }