feat(triggers): first-class triggers + bindings with fan-out webhook
Build / build (push) Successful in 10m39s
Build / build (push) Successful in 10m39s
Promote triggers from embedded workload fields to standalone records
joined to workloads via workload_trigger_bindings. One trigger (webhook,
registry watcher, git push, manual) now fans out to many workloads with
per-binding config overrides (top-level JSON merge, binding wins).
Backend
- new triggers + workload_trigger_bindings tables with ON DELETE CASCADE
- boot-time backfill of embedded trigger config inside per-workload tx
- store.ErrUnique sentinel translates SQLite UNIQUE at store boundary
- /api/triggers CRUD + /api/triggers/{id}/{webhook,bindings}
- /api/bindings/{id} update/delete; /api/workloads/{id}/triggers list+bind
- bindTriggerToWorkload accepts trigger_id or inline {kind,name,config}
- inline-create uses CreateTriggerWithBindingTx (no orphan triggers)
- validateBindingConfig enforces 8 KiB cap + plugin Validate on merged
- ListTriggersWithBindingCount + ListBindings*WithNames remove N+1
- POST /api/webhook/triggers/{secret} resolves trigger then fans out
- bounded worker pool (4) per request; per-binding error isolation
- outcome accounting: deployed / skipped / no-match / errored
- legacy /api/webhook/workloads/{secret} route removed (clean break;
backfill keeps secrets resolvable at the new /triggers/{secret} path)
- reconciler gate dropped from (Source && Trigger) to Source only
- MergeJSONConfig returns freshly allocated slices (no fan-out aliasing)
- WithEffectiveTrigger lets existing Trigger.Match contract stay unchanged
Frontend
- /triggers list, new wizard, [id] detail (bindings, webhook rotate)
- workload create wizard: NEW / PICK / SKIP trigger modes
- workload detail: bindings panel + Add-trigger modal (inline / pick)
- per-binding override editor with merged-preview + 8 KiB guard
- "OVERRIDES n FIELDS" row badge when binding_config is non-empty
- shared TriggerKindForm component (registry / git / manual + JSON)
- 3 raw <input type=checkbox> replaced with <ToggleSwitch>
- full EN + RU i18n: redeployTriggers.*, apps.detail.bindings.*,
apps.new.triggers.*, nav.triggers; event-triggers nav disambiguated
Doc
- WORKLOAD_REFACTOR_TODO: trigger-split marked DONE; next focus is
the static-source inline port + hard legacy cutover (Priority 1)
This commit is contained in:
+7
-167
@@ -13,7 +13,6 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
@@ -297,11 +296,16 @@ func (h *Handler) Drain() {
|
||||
//
|
||||
// POST /{secret} — per-project deploy trigger (legacy)
|
||||
// POST /sites/{secret} — per-site sync trigger (legacy)
|
||||
// POST /workloads/{secret} — plugin-native workload trigger
|
||||
// POST /triggers/{secret} — first-class trigger fan-out to all bound workloads
|
||||
//
|
||||
// The legacy POST /workloads/{secret} route was dropped in the
|
||||
// trigger-split refactor. Existing inbound webhook secrets were lifted
|
||||
// into trigger rows by the boot backfill, so the same secret value
|
||||
// works at /triggers/{secret} after the upgrade.
|
||||
func (h *Handler) Route() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Post("/sites/{secret}", h.handleSiteWebhook)
|
||||
r.Post("/workloads/{secret}", h.handlePluginWorkloadWebhook)
|
||||
r.Post("/triggers/{secret}", h.handleTriggerWebhook)
|
||||
r.Post("/{secret}", h.handleWebhook)
|
||||
return r
|
||||
}
|
||||
@@ -675,170 +679,6 @@ func (h *Handler) handleSiteWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// handlePluginWorkloadWebhook processes an inbound webhook for a
|
||||
// plugin-native workload.
|
||||
//
|
||||
// URL: POST /api/webhook/workloads/{secret}
|
||||
//
|
||||
// The secret resolves to exactly one workload row whose Source +
|
||||
// Trigger kinds determine how the payload is interpreted. The body
|
||||
// shape is the same as the legacy project/site webhooks (Image for
|
||||
// registry pushes, Ref for git pushes) — Gitea / GitHub / generic
|
||||
// registry CIs can target this URL without payload changes. The
|
||||
// workload's configured Trigger plugin then decides whether the event
|
||||
// fires a deploy.
|
||||
func (h *Handler) handlePluginWorkloadWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
delivery := store.WebhookDelivery{
|
||||
TargetType: "workload",
|
||||
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
|
||||
}
|
||||
|
||||
wl, err := h.store.GetWorkloadByWebhookSecret(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: workload lookup failed", "error", err)
|
||||
delivery.StatusCode = http.StatusNotFound
|
||||
delivery.Outcome = outcomeError
|
||||
delivery.Detail = "lookup failed"
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if wl.SourceKind == "" || wl.TriggerKind == "" {
|
||||
// Legacy workload row whose secret happens to also be valid on the
|
||||
// legacy path. Tell the caller they hit the wrong route rather
|
||||
// than silently 404-ing — avoids head-scratching.
|
||||
delivery.StatusCode = http.StatusBadRequest
|
||||
delivery.Outcome = outcomeBadRequest
|
||||
delivery.Detail = "workload is legacy; use the project or site route"
|
||||
respondWebhookError(w, http.StatusBadRequest, "workload is not plugin-native")
|
||||
return
|
||||
}
|
||||
delivery.TargetID = wl.ID
|
||||
delivery.TargetName = wl.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(wl.WebhookSigningSecret, body, header)
|
||||
delivery.SignatureState = signatureStateFor(wl.WebhookSigningSecret, header, verified, attempted)
|
||||
if wl.WebhookRequireSignature && !verified {
|
||||
slog.Warn("webhook: workload signature required but invalid/missing", "workload", wl.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: workload bad signature", "workload", wl.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
|
||||
}
|
||||
|
||||
trig, err := plugin.GetTrigger(wl.TriggerKind)
|
||||
if err != nil {
|
||||
slog.Warn("webhook: trigger plugin not registered",
|
||||
"workload", wl.Name, "trigger", wl.TriggerKind, "error", err)
|
||||
delivery.StatusCode = http.StatusInternalServerError
|
||||
delivery.Outcome = outcomeError
|
||||
delivery.Detail = "trigger plugin missing"
|
||||
respondWebhookError(w, http.StatusInternalServerError, "trigger plugin missing")
|
||||
return
|
||||
}
|
||||
|
||||
pwl := toPluginWorkload(wl)
|
||||
intent, err := trig.Match(ctx, h.plugins.PluginDeps(), pwl, evt)
|
||||
if err != nil {
|
||||
slog.Warn("webhook: trigger match error",
|
||||
"workload", wl.Name, "trigger", wl.TriggerKind, "error", err)
|
||||
delivery.StatusCode = http.StatusInternalServerError
|
||||
delivery.Outcome = outcomeError
|
||||
delivery.Detail = "trigger match error"
|
||||
respondWebhookError(w, http.StatusInternalServerError, "trigger match error")
|
||||
return
|
||||
}
|
||||
if intent == nil {
|
||||
delivery.Detail = "trigger declined (no match)"
|
||||
respondWebhookJSON(w, http.StatusOK, map[string]any{
|
||||
"success": true, "deploy": false, "workload": wl.Name,
|
||||
"reason": "trigger declined",
|
||||
})
|
||||
return
|
||||
}
|
||||
if intent.TriggeredAt.IsZero() {
|
||||
intent.TriggeredAt = time.Now().UTC()
|
||||
}
|
||||
if intent.TriggeredBy == "" {
|
||||
intent.TriggeredBy = "webhook"
|
||||
}
|
||||
|
||||
if err := h.plugins.DispatchPlugin(ctx, pwl, *intent); err != nil {
|
||||
slog.Warn("webhook: plugin dispatch failed",
|
||||
"workload", wl.Name, "error", err)
|
||||
delivery.StatusCode = http.StatusInternalServerError
|
||||
delivery.Outcome = outcomeError
|
||||
delivery.Detail = "dispatch failed; see server logs"
|
||||
respondWebhookError(w, http.StatusInternalServerError, "dispatch failed; see server logs")
|
||||
return
|
||||
}
|
||||
delivery.Outcome = outcomeDeploy
|
||||
delivery.Detail = fmt.Sprintf("reason=%s ref=%s", intent.Reason, intent.Reference)
|
||||
slog.Info("webhook: triggered plugin deploy",
|
||||
"workload", wl.Name, "trigger", wl.TriggerKind, "reason", intent.Reason)
|
||||
respondWebhookJSON(w, http.StatusOK, map[string]any{
|
||||
"success": true, "deploy": true,
|
||||
"workload": wl.Name, "reference": intent.Reference,
|
||||
})
|
||||
}
|
||||
|
||||
// buildInboundEvent normalizes the incoming webhook body into the
|
||||
// plugin.InboundEvent shape. The dispatch order is:
|
||||
//
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
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.
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user