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:
@@ -430,6 +430,12 @@ func (s *Server) Router() chi.Router {
|
||||
// running image tag onto this workload's default_tag.
|
||||
r.Get("/chain", s.getWorkloadChain)
|
||||
r.With(auth.AdminOnly).Post("/promote-from/{sourceID}", s.promoteFromWorkload)
|
||||
|
||||
// Trigger bindings on this workload — the symmetric view
|
||||
// of /triggers/{id}/bindings keyed on the workload side
|
||||
// so the workload detail page is one round-trip.
|
||||
r.Get("/triggers", s.listBindingsForWorkload)
|
||||
r.With(auth.AdminOnly).Post("/triggers", s.bindTriggerToWorkload)
|
||||
})
|
||||
|
||||
// Global container index, joined to workload + app names.
|
||||
@@ -446,6 +452,26 @@ func (s *Server) Router() chi.Router {
|
||||
r.Delete("/apps/{id}", s.deleteApp)
|
||||
})
|
||||
|
||||
// First-class Triggers (redeploy signal sources). One trigger
|
||||
// (registry / git / webhook / manual / schedule / log_scan)
|
||||
// fans out to many workloads via workload_trigger_bindings.
|
||||
// Reads are open to authenticated users; mutations + secret
|
||||
// rotation are admin-gated.
|
||||
r.Get("/triggers", s.listTriggers)
|
||||
r.Get("/triggers/{id}", s.getTrigger)
|
||||
r.Get("/triggers/{id}/bindings", s.listBindingsForTrigger)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(auth.AdminOnly)
|
||||
r.Post("/triggers", s.createTrigger)
|
||||
r.Put("/triggers/{id}", s.updateTrigger)
|
||||
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}/bindings", s.bindWorkloadToTrigger)
|
||||
r.Put("/bindings/{bid}", s.updateBinding)
|
||||
r.Delete("/bindings/{bid}", s.deleteBinding)
|
||||
})
|
||||
|
||||
// Event triggers: filter+action rules over the event_log
|
||||
// stream. Read endpoints are available to any authenticated
|
||||
// user; mutations + test-dispatch are admin-gated since they
|
||||
|
||||
@@ -0,0 +1,628 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
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,
|
||||
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,
|
||||
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)
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
Reference in New Issue
Block a user