5e78f13e06
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.
771 lines
25 KiB
Go
771 lines
25 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"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.
|
|
type triggerView struct {
|
|
ID string `json:"id"`
|
|
Kind string `json:"kind"`
|
|
Name string `json:"name"`
|
|
Config json.RawMessage `json:"config"`
|
|
WebhookEnabled bool `json:"webhook_enabled"`
|
|
WebhookRequireSignature bool `json:"webhook_require_signature"`
|
|
BindingCount int `json:"binding_count"`
|
|
// LastFiredAt is the RFC3339 wall-clock the scheduler last
|
|
// dispatched this trigger. Always present in the response shape;
|
|
// empty for triggers that have never fired or are not scheduler-
|
|
// driven. The detail page renders it as "last fired" on schedule
|
|
// triggers; other kinds ignore it.
|
|
LastFiredAt string `json:"last_fired_at"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
func (s *Server) toTriggerView(t store.Trigger) triggerView {
|
|
count, err := s.store.CountBindingsForTrigger(t.ID)
|
|
if err != nil {
|
|
slog.Warn("triggerView: count bindings", "trigger", t.ID, "error", err)
|
|
}
|
|
return triggerView{
|
|
ID: t.ID,
|
|
Kind: t.Kind,
|
|
Name: t.Name,
|
|
Config: json.RawMessage(t.Config),
|
|
WebhookEnabled: t.WebhookSecret != "",
|
|
WebhookRequireSignature: t.WebhookRequireSignature,
|
|
BindingCount: count,
|
|
LastFiredAt: t.LastFiredAt,
|
|
CreatedAt: t.CreatedAt,
|
|
UpdatedAt: t.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
// toTriggerViewWithCount is the join-aware variant used by listTriggers
|
|
// to avoid one COUNT(*) per row. Kept distinct from toTriggerView so
|
|
// single-row paths (get/create/update) keep the simple call shape.
|
|
func toTriggerViewWithCount(row store.TriggerWithBindingCount) triggerView {
|
|
return triggerView{
|
|
ID: row.ID,
|
|
Kind: row.Kind,
|
|
Name: row.Name,
|
|
Config: json.RawMessage(row.Config),
|
|
WebhookEnabled: row.WebhookSecret != "",
|
|
WebhookRequireSignature: row.WebhookRequireSignature,
|
|
BindingCount: row.BindingCount,
|
|
LastFiredAt: row.LastFiredAt,
|
|
CreatedAt: row.CreatedAt,
|
|
UpdatedAt: row.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
// triggerRequest is the create/update body. Config is opaque per kind.
|
|
// Auto-generates a webhook secret on create when WebhookEnabled is true;
|
|
// the secret is exposed only via the /webhook subresource.
|
|
type triggerRequest struct {
|
|
Kind string `json:"kind"`
|
|
Name string `json:"name"`
|
|
Config json.RawMessage `json:"config"`
|
|
WebhookEnabled bool `json:"webhook_enabled"`
|
|
WebhookRequireSignature bool `json:"webhook_require_signature"`
|
|
}
|
|
|
|
// Same per-blob caps used on the workload pluginWorkloadRequest path —
|
|
// triggers and workload trigger configs share the same plugin Validate()
|
|
// call, so the byte budget should match.
|
|
const maxTriggerStandaloneConfigBytes = 16 << 10
|
|
|
|
func (s *Server) listTriggers(w http.ResponseWriter, r *http.Request) {
|
|
kind := r.URL.Query().Get("kind")
|
|
rows, err := s.store.ListTriggersWithBindingCount(kind)
|
|
if err != nil {
|
|
slog.Error("list triggers", "error", err)
|
|
respondError(w, http.StatusInternalServerError, "list triggers")
|
|
return
|
|
}
|
|
out := make([]triggerView, 0, len(rows))
|
|
for _, t := range rows {
|
|
out = append(out, toTriggerViewWithCount(t))
|
|
}
|
|
respondJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func (s *Server) getTrigger(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
t, err := s.store.GetTriggerByID(id)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "trigger")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "get trigger")
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusOK, s.toTriggerView(t))
|
|
}
|
|
|
|
// buildTriggerFromRequest assembles a store.Trigger ready for insert.
|
|
// Centralized so the standalone create endpoint and the inline-bind
|
|
// endpoint cannot drift on secret-generation defaults.
|
|
func buildTriggerFromRequest(req triggerRequest) store.Trigger {
|
|
t := store.Trigger{
|
|
Kind: req.Kind,
|
|
Name: strings.TrimSpace(req.Name),
|
|
Config: string(req.Config),
|
|
WebhookRequireSignature: req.WebhookRequireSignature,
|
|
}
|
|
if req.WebhookEnabled {
|
|
t.WebhookSecret = generateWebhookSecret()
|
|
t.WebhookSigningSecret = generateWebhookSecret()
|
|
}
|
|
return t
|
|
}
|
|
|
|
func (s *Server) createTrigger(w http.ResponseWriter, r *http.Request) {
|
|
var req triggerRequest
|
|
if !decodeJSONStrict(w, r, &req) {
|
|
return
|
|
}
|
|
if err := validateTriggerRequest(req); err != nil {
|
|
respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
created, err := s.store.CreateTrigger(buildTriggerFromRequest(req))
|
|
if err != nil {
|
|
slog.Error("create trigger", "error", err)
|
|
// UNIQUE name collision is the most common user-facing failure.
|
|
if errors.Is(err, store.ErrUnique) {
|
|
respondError(w, http.StatusConflict, "a trigger with this name already exists")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "create trigger")
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusCreated, s.toTriggerView(created))
|
|
}
|
|
|
|
func (s *Server) updateTrigger(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
existing, err := s.store.GetTriggerByID(id)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "trigger")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "get trigger")
|
|
return
|
|
}
|
|
var req triggerRequest
|
|
if !decodeJSONStrict(w, r, &req) {
|
|
return
|
|
}
|
|
// Kind is immutable on update. Mirror the value from the existing
|
|
// row so validateTriggerRequest can still verify the config blob.
|
|
req.Kind = existing.Kind
|
|
if err := validateTriggerRequest(req); err != nil {
|
|
respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if req.Name != "" {
|
|
existing.Name = strings.TrimSpace(req.Name)
|
|
}
|
|
if len(req.Config) > 0 {
|
|
existing.Config = string(req.Config)
|
|
}
|
|
existing.WebhookRequireSignature = req.WebhookRequireSignature
|
|
wasEnabled := existing.WebhookSecret != ""
|
|
if req.WebhookEnabled && !wasEnabled {
|
|
// false→true transition: rotate both secrets so re-enabling
|
|
// after a disable does not silently revive an old leaked URL.
|
|
existing.WebhookSecret = generateWebhookSecret()
|
|
existing.WebhookSigningSecret = generateWebhookSecret()
|
|
}
|
|
if !req.WebhookEnabled {
|
|
existing.WebhookSecret = ""
|
|
existing.WebhookSigningSecret = ""
|
|
}
|
|
if err := s.store.UpdateTrigger(existing); err != nil {
|
|
slog.Error("update trigger", "error", err)
|
|
if errors.Is(err, store.ErrUnique) {
|
|
respondError(w, http.StatusConflict, "a trigger with this name already exists")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "update trigger")
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusOK, s.toTriggerView(existing))
|
|
}
|
|
|
|
func (s *Server) deleteTrigger(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if err := s.store.DeleteTrigger(id); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "trigger")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "delete trigger")
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusOK, map[string]string{"deleted": id})
|
|
}
|
|
|
|
// triggerWebhookView surfaces the inbound URL for a trigger. Returns
|
|
// empty path / secret when the trigger has webhook ingress disabled.
|
|
type triggerWebhookView struct {
|
|
URL string `json:"url"`
|
|
Secret string `json:"secret"`
|
|
WebhookRequireSignature bool `json:"webhook_require_signature"`
|
|
}
|
|
|
|
func (s *Server) getTriggerWebhook(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
t, err := s.store.GetTriggerByID(id)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "trigger")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "get trigger")
|
|
return
|
|
}
|
|
view := triggerWebhookView{
|
|
Secret: t.WebhookSecret,
|
|
WebhookRequireSignature: t.WebhookRequireSignature,
|
|
}
|
|
if t.WebhookSecret != "" {
|
|
view.URL = "/api/webhook/triggers/" + t.WebhookSecret
|
|
}
|
|
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()
|
|
if err := s.store.SetTriggerWebhookSecret(id, secret); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "trigger")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "rotate webhook secret")
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusOK, map[string]string{
|
|
"secret": secret,
|
|
"url": "/api/webhook/triggers/" + secret,
|
|
})
|
|
}
|
|
|
|
// maxBindingConfigBytes caps a per-binding override blob. Smaller than
|
|
// the full trigger config — bindings should be lightweight tweaks
|
|
// (tag pattern, branch filter), not whole replacement configs.
|
|
const maxBindingConfigBytes = 8 << 10
|
|
|
|
// validateBindingConfig enforces the size cap and runs the trigger
|
|
// plugin's Validate() against the merged (trigger.config + binding)
|
|
// shape so a malformed override is caught at write time instead of
|
|
// silently breaking webhook fan-out at deploy time.
|
|
func validateBindingConfig(trg store.Trigger, bindingConfig json.RawMessage) error {
|
|
if len(bindingConfig) > maxBindingConfigBytes {
|
|
return fmt.Errorf("binding_config exceeds %d bytes", maxBindingConfigBytes)
|
|
}
|
|
merged, err := plugin.MergeJSONConfig(json.RawMessage(trg.Config), bindingConfig)
|
|
if err != nil {
|
|
return fmt.Errorf("binding_config: %w", err)
|
|
}
|
|
tp, err := plugin.GetTrigger(trg.Kind)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return tp.Validate(merged)
|
|
}
|
|
|
|
// validateTriggerRequest type-checks the trigger via the registered
|
|
// plugin. Accepts an empty config only when the plugin allows it (e.g.
|
|
// the manual trigger).
|
|
func validateTriggerRequest(req triggerRequest) error {
|
|
if strings.TrimSpace(req.Kind) == "" {
|
|
return fmt.Errorf("kind is required")
|
|
}
|
|
if strings.TrimSpace(req.Name) == "" {
|
|
return fmt.Errorf("name is required")
|
|
}
|
|
if len(req.Config) > maxTriggerStandaloneConfigBytes {
|
|
return fmt.Errorf("config exceeds %d bytes", maxTriggerStandaloneConfigBytes)
|
|
}
|
|
tp, err := plugin.GetTrigger(req.Kind)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return tp.Validate(req.Config)
|
|
}
|
|
|
|
// bindingView shapes one binding for the /api/triggers/{id}/bindings
|
|
// listing. Includes the workload's name to avoid an N+1 round-trip on
|
|
// the frontend.
|
|
type bindingView struct {
|
|
ID string `json:"id"`
|
|
WorkloadID string `json:"workload_id"`
|
|
WorkloadName string `json:"workload_name"`
|
|
TriggerID string `json:"trigger_id"`
|
|
BindingConfig json.RawMessage `json:"binding_config"`
|
|
Enabled bool `json:"enabled"`
|
|
SortOrder int `json:"sort_order"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
func (s *Server) toBindingView(b store.WorkloadTriggerBinding) bindingView {
|
|
name := ""
|
|
if w, err := s.store.GetWorkloadByID(b.WorkloadID); err == nil {
|
|
name = w.Name
|
|
}
|
|
return bindingView{
|
|
ID: b.ID,
|
|
WorkloadID: b.WorkloadID,
|
|
WorkloadName: name,
|
|
TriggerID: b.TriggerID,
|
|
BindingConfig: json.RawMessage(b.BindingConfig),
|
|
Enabled: b.Enabled,
|
|
SortOrder: b.SortOrder,
|
|
CreatedAt: b.CreatedAt,
|
|
UpdatedAt: b.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
func (s *Server) listBindingsForTrigger(w http.ResponseWriter, r *http.Request) {
|
|
tid := chi.URLParam(r, "id")
|
|
if _, err := s.store.GetTriggerByID(tid); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "trigger")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "get trigger")
|
|
return
|
|
}
|
|
rows, err := s.store.ListBindingsForTriggerWithNames(tid)
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "list bindings")
|
|
return
|
|
}
|
|
out := make([]bindingView, 0, len(rows))
|
|
for _, b := range rows {
|
|
out = append(out, bindingView{
|
|
ID: b.ID,
|
|
WorkloadID: b.WorkloadID,
|
|
WorkloadName: b.WorkloadName,
|
|
TriggerID: b.TriggerID,
|
|
BindingConfig: json.RawMessage(b.BindingConfig),
|
|
Enabled: b.Enabled,
|
|
SortOrder: b.SortOrder,
|
|
CreatedAt: b.CreatedAt,
|
|
UpdatedAt: b.UpdatedAt,
|
|
})
|
|
}
|
|
respondJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// bindingRequest is shared by trigger-side bind (POST .../bindings) and
|
|
// workload-side bind (POST workloads/{id}/triggers).
|
|
type bindingRequest struct {
|
|
WorkloadID string `json:"workload_id"`
|
|
TriggerID string `json:"trigger_id"`
|
|
BindingConfig json.RawMessage `json:"binding_config"`
|
|
Enabled *bool `json:"enabled"`
|
|
SortOrder int `json:"sort_order"`
|
|
}
|
|
|
|
func (s *Server) bindWorkloadToTrigger(w http.ResponseWriter, r *http.Request) {
|
|
tid := chi.URLParam(r, "id")
|
|
var req bindingRequest
|
|
if !decodeJSONStrict(w, r, &req) {
|
|
return
|
|
}
|
|
if req.WorkloadID == "" {
|
|
respondError(w, http.StatusBadRequest, "workload_id is required")
|
|
return
|
|
}
|
|
trg, err := s.store.GetTriggerByID(tid)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "trigger")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "get trigger")
|
|
return
|
|
}
|
|
if _, err := s.store.GetWorkloadByID(req.WorkloadID); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "workload")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "get workload")
|
|
return
|
|
}
|
|
if err := validateBindingConfig(trg, req.BindingConfig); err != nil {
|
|
respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
enabled := true
|
|
if req.Enabled != nil {
|
|
enabled = *req.Enabled
|
|
}
|
|
b := store.WorkloadTriggerBinding{
|
|
WorkloadID: req.WorkloadID,
|
|
TriggerID: tid,
|
|
BindingConfig: string(req.BindingConfig),
|
|
Enabled: enabled,
|
|
SortOrder: req.SortOrder,
|
|
}
|
|
created, err := s.store.CreateBinding(b)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrUnique) {
|
|
respondError(w, http.StatusConflict, "this workload is already bound to this trigger")
|
|
return
|
|
}
|
|
slog.Error("create binding", "error", err)
|
|
respondError(w, http.StatusInternalServerError, "create binding")
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusCreated, s.toBindingView(created))
|
|
}
|
|
|
|
func (s *Server) updateBinding(w http.ResponseWriter, r *http.Request) {
|
|
bid := chi.URLParam(r, "bid")
|
|
existing, err := s.store.GetBindingByID(bid)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "binding")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "get binding")
|
|
return
|
|
}
|
|
var req bindingRequest
|
|
if !decodeJSONStrict(w, r, &req) {
|
|
return
|
|
}
|
|
if len(req.BindingConfig) > 0 {
|
|
trg, terr := s.store.GetTriggerByID(existing.TriggerID)
|
|
if terr != nil {
|
|
slog.Error("update binding: trigger lookup", "trigger", existing.TriggerID, "error", terr)
|
|
respondError(w, http.StatusInternalServerError, "trigger lookup")
|
|
return
|
|
}
|
|
if err := validateBindingConfig(trg, req.BindingConfig); err != nil {
|
|
respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
existing.BindingConfig = string(req.BindingConfig)
|
|
}
|
|
if req.Enabled != nil {
|
|
existing.Enabled = *req.Enabled
|
|
}
|
|
existing.SortOrder = req.SortOrder
|
|
if err := s.store.UpdateBinding(existing); err != nil {
|
|
respondError(w, http.StatusInternalServerError, "update binding")
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusOK, s.toBindingView(existing))
|
|
}
|
|
|
|
// listBindingsForWorkload is the workload-side mirror of
|
|
// listBindingsForTrigger. Returns every trigger bound to the workload
|
|
// in sort_order so the detail page can render them inline.
|
|
func (s *Server) listBindingsForWorkload(w http.ResponseWriter, r *http.Request) {
|
|
wid := chi.URLParam(r, "id")
|
|
if _, err := s.store.GetWorkloadByID(wid); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "workload")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "get workload")
|
|
return
|
|
}
|
|
rows, err := s.store.ListBindingsForWorkloadWithNames(wid)
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "list bindings")
|
|
return
|
|
}
|
|
type item struct {
|
|
bindingView
|
|
TriggerKind string `json:"trigger_kind"`
|
|
TriggerName string `json:"trigger_name"`
|
|
}
|
|
out := make([]item, 0, len(rows))
|
|
for _, b := range rows {
|
|
out = append(out, item{
|
|
bindingView: bindingView{
|
|
ID: b.ID,
|
|
WorkloadID: b.WorkloadID,
|
|
TriggerID: b.TriggerID,
|
|
BindingConfig: json.RawMessage(b.BindingConfig),
|
|
Enabled: b.Enabled,
|
|
SortOrder: b.SortOrder,
|
|
CreatedAt: b.CreatedAt,
|
|
UpdatedAt: b.UpdatedAt,
|
|
},
|
|
TriggerKind: b.TriggerKind,
|
|
TriggerName: b.TriggerName,
|
|
})
|
|
}
|
|
respondJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// workloadBindRequest covers the two UX flows: bind an existing trigger
|
|
// (TriggerID present) or inline-create one in the same call (TriggerID
|
|
// empty + Inline populated). The inline form keeps the 1:1 case feeling
|
|
// unchanged from the embedded-trigger era.
|
|
type workloadBindRequest struct {
|
|
TriggerID string `json:"trigger_id"`
|
|
BindingConfig json.RawMessage `json:"binding_config"`
|
|
Enabled *bool `json:"enabled"`
|
|
SortOrder int `json:"sort_order"`
|
|
Inline *triggerRequest `json:"inline"`
|
|
}
|
|
|
|
func (s *Server) bindTriggerToWorkload(w http.ResponseWriter, r *http.Request) {
|
|
wid := chi.URLParam(r, "id")
|
|
if _, err := s.store.GetWorkloadByID(wid); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "workload")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "get workload")
|
|
return
|
|
}
|
|
var req workloadBindRequest
|
|
if !decodeJSONStrict(w, r, &req) {
|
|
return
|
|
}
|
|
if req.TriggerID == "" && req.Inline == nil {
|
|
respondError(w, http.StatusBadRequest, "either trigger_id or inline trigger is required")
|
|
return
|
|
}
|
|
|
|
enabled := true
|
|
if req.Enabled != nil {
|
|
enabled = *req.Enabled
|
|
}
|
|
|
|
// Inline path: create trigger + binding atomically so a binding
|
|
// failure cannot leak a half-built trigger row.
|
|
if req.TriggerID == "" {
|
|
if err := validateTriggerRequest(*req.Inline); err != nil {
|
|
respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
_, b, err := s.store.CreateTriggerWithBindingTx(
|
|
buildTriggerFromRequest(*req.Inline),
|
|
store.WorkloadTriggerBinding{
|
|
WorkloadID: wid,
|
|
BindingConfig: string(req.BindingConfig),
|
|
Enabled: enabled,
|
|
SortOrder: req.SortOrder,
|
|
},
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrUnique) {
|
|
respondError(w, http.StatusConflict, "a trigger with this name already exists")
|
|
return
|
|
}
|
|
slog.Error("inline trigger+binding tx", "error", err)
|
|
respondError(w, http.StatusInternalServerError, "create inline trigger+binding")
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusCreated, s.toBindingView(b))
|
|
return
|
|
}
|
|
|
|
// Existing-trigger path: just bind.
|
|
trg, err := s.store.GetTriggerByID(req.TriggerID)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "trigger")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "get trigger")
|
|
return
|
|
}
|
|
if err := validateBindingConfig(trg, req.BindingConfig); err != nil {
|
|
respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
b, err := s.store.CreateBinding(store.WorkloadTriggerBinding{
|
|
WorkloadID: wid,
|
|
TriggerID: req.TriggerID,
|
|
BindingConfig: string(req.BindingConfig),
|
|
Enabled: enabled,
|
|
SortOrder: req.SortOrder,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrUnique) {
|
|
respondError(w, http.StatusConflict, "this workload is already bound to this trigger")
|
|
return
|
|
}
|
|
slog.Error("create binding from workload side", "error", err)
|
|
respondError(w, http.StatusInternalServerError, "create binding")
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusCreated, s.toBindingView(b))
|
|
}
|
|
|
|
func (s *Server) deleteBinding(w http.ResponseWriter, r *http.Request) {
|
|
bid := chi.URLParam(r, "bid")
|
|
if err := s.store.DeleteBinding(bid); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "binding")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "delete binding")
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusOK, map[string]string{"deleted": bid})
|
|
}
|