Files
tiny-forge/internal/api/triggers.go
T
alexei.dolgolyov 39e1e36510
Build / build (push) Successful in 10m42s
feat(triggers): add schedule trigger kind + internal scheduler
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.
2026-05-16 11:24:05 +03:00

637 lines
20 KiB
Go

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"`
// 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)
}
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})
}