package api import ( "encoding/json" "errors" "fmt" "log/slog" "net/http" "strings" "time" "github.com/go-chi/chi/v5" "github.com/alexei/tinyforge/internal/auth" "github.com/alexei/tinyforge/internal/store" "github.com/alexei/tinyforge/internal/workload/plugin" ) // pluginWorkloadRequest is the JSON body accepted by create + update. // SourceConfig / TriggerConfig are raw JSON blobs validated by the // matching plugin's Validate() before persistence. type pluginWorkloadRequest struct { Name string `json:"name"` GroupID string `json:"group_id"` ParentWorkloadID string `json:"parent_workload_id"` SourceKind string `json:"source_kind"` SourceConfig json.RawMessage `json:"source_config"` TriggerKind string `json:"trigger_kind"` TriggerConfig json.RawMessage `json:"trigger_config"` PublicFaces []plugin.PublicFace `json:"public_faces"` NotificationURL string `json:"notification_url"` WebhookRequireSignature bool `json:"webhook_require_signature"` } // Per-blob caps so two opaque JSON fields can't blow past the route-level // body limit individually. The route already caps the whole body, but a // 1 MiB SourceConfig is unreasonable for any source we plan to support. const ( maxSourceConfigBytes = 64 << 10 // 64 KiB maxTriggerConfigBytes = 16 << 10 // 16 KiB // Hard upper bound on public faces — multi-face is now supported (route // IDs are stored per-fqdn in container.extra_json so teardown is clean) // but a workload with hundreds of public faces is almost certainly a // bug in the caller, not legitimate config. maxPublicFaces = 16 ) // createPluginWorkload handles POST /api/workloads. // // Validates source/trigger kinds against the registered plugins, runs each // plugin's own Validate() on its config blob, then persists the row. The // row is created with the new plugin-shape fields populated; the legacy // kind/ref_id columns stay empty for plugin-native workloads. func (s *Server) createPluginWorkload(w http.ResponseWriter, r *http.Request) { var req pluginWorkloadRequest if !decodeJSONStrict(w, r, &req) { return } if strings.TrimSpace(req.Name) == "" { respondError(w, http.StatusBadRequest, "name is required") return } if err := validatePluginKinds(req); err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } pw := plugin.Workload{ Name: req.Name, GroupID: req.GroupID, ParentWorkloadID: req.ParentWorkloadID, SourceKind: req.SourceKind, SourceConfig: req.SourceConfig, TriggerKind: req.TriggerKind, TriggerConfig: req.TriggerConfig, PublicFaces: req.PublicFaces, NotificationURL: req.NotificationURL, WebhookRequireSignature: req.WebhookRequireSignature, } sw, err := fromPluginWorkload(pw) if err != nil { respondError(w, http.StatusBadRequest, "encode workload: "+err.Error()) return } // Plugin-native rows are flagged with kind="plugin"; ref_id is // self-referenced to the row's own ID inside CreateWorkload so the // UNIQUE(kind, ref_id) index can hold many sibling plugin workloads. sw.Kind = "plugin" created, err := s.store.CreateWorkload(sw) if err != nil { slog.Error("create plugin workload", "error", err) respondError(w, http.StatusInternalServerError, "create workload") return } respondJSON(w, http.StatusCreated, toPluginWorkload(created)) } // updatePluginWorkload handles PUT /api/workloads/{id}/plugin. Only the // fields that belong to the plugin model are mutable here; legacy // project/stack/site fields are edited through their own endpoints during // the cutover. func (s *Server) updatePluginWorkload(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") existing, err := s.store.GetWorkloadByID(id) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "workload") return } respondError(w, http.StatusInternalServerError, "get workload") return } var req pluginWorkloadRequest if !decodeJSONStrict(w, r, &req) { return } if err := validatePluginKinds(req); err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } if req.Name != "" { existing.Name = req.Name } existing.AppID = req.GroupID existing.ParentWorkloadID = req.ParentWorkloadID existing.SourceKind = req.SourceKind if len(req.SourceConfig) > 0 { existing.SourceConfig = string(req.SourceConfig) } existing.TriggerKind = req.TriggerKind if len(req.TriggerConfig) > 0 { existing.TriggerConfig = string(req.TriggerConfig) } if req.PublicFaces != nil { b, _ := json.Marshal(req.PublicFaces) existing.PublicFaces = string(b) } existing.NotificationURL = req.NotificationURL existing.WebhookRequireSignature = req.WebhookRequireSignature if err := s.store.UpdateWorkload(existing); err != nil { slog.Error("update plugin workload", "error", err) respondError(w, http.StatusInternalServerError, "update workload") return } respondJSON(w, http.StatusOK, toPluginWorkload(existing)) } // deployPluginWorkload handles POST /api/workloads/{id}/deploy. // // Builds a manual DeploymentIntent and dispatches it through the matching // Source plugin — independent of whatever TriggerKind the workload has // configured. The body is optional; supplying `reference` overrides what // the Source uses (e.g. force a specific image tag). func (s *Server) deployPluginWorkload(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") row, err := s.store.GetWorkloadByID(id) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "workload") return } respondError(w, http.StatusInternalServerError, "get workload") return } if row.SourceKind == "" { respondError(w, http.StatusBadRequest, "workload has no source_kind; cannot dispatch") return } var body struct { Reference string `json:"reference"` Note string `json:"note"` } if r.ContentLength > 0 { if !decodeJSONStrict(w, r, &body) { return } } actor := "manual" if claims, ok := auth.ClaimsFromContext(r.Context()); ok && claims.Username != "" { actor = claims.Username } intent := plugin.DeploymentIntent{ Reason: "manual", Reference: body.Reference, Metadata: map[string]string{"note": body.Note}, TriggeredAt: time.Now().UTC(), TriggeredBy: actor, } if err := s.deployer.DispatchPlugin(r.Context(), toPluginWorkload(row), intent); err != nil { // Full error stays in the server log; the client gets a generic // message because the wrapped error can carry registry-auth bytes // or compose-stdout secrets. slog.Warn("manual dispatch failed", "workload", id, "actor", actor, "error", err) respondError(w, http.StatusInternalServerError, "dispatch failed; see server logs") return } respondJSON(w, http.StatusAccepted, map[string]any{ "workload_id": id, "reference": intent.Reference, "triggered_by": actor, }) } // deletePluginWorkload handles DELETE /api/workloads/{id}. // // Performs Source.Teardown first so containers / proxy routes / DNS are // cleaned up before the workload row is dropped. A teardown failure is // logged but does not block the row delete — the row must not outlive // the things it owns even when the cleanup is partial. func (s *Server) deletePluginWorkload(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") row, err := s.store.GetWorkloadByID(id) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "workload") return } respondError(w, http.StatusInternalServerError, "get workload") return } if row.SourceKind != "" { if err := s.deployer.DispatchTeardown(r.Context(), toPluginWorkload(row)); err != nil { slog.Warn("delete workload: teardown error", "workload", id, "kind", row.SourceKind, "error", err) } } if err := s.store.DeleteWorkload(id); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "workload") return } respondError(w, http.StatusInternalServerError, "delete workload") return } respondJSON(w, http.StatusOK, map[string]string{"deleted": id}) } // validatePluginKinds verifies the requested source_kind and trigger_kind // resolve to registered plugins, then asks each plugin to validate its // own config blob. Empty kinds are allowed (legacy rows or partial setup). // Per-blob byte caps and the v1 single-face limit are enforced here so a // hand-crafted DB write can't bypass them later. func validatePluginKinds(req pluginWorkloadRequest) error { if len(req.SourceConfig) > maxSourceConfigBytes { return fmt.Errorf("source_config exceeds %d bytes", maxSourceConfigBytes) } if len(req.TriggerConfig) > maxTriggerConfigBytes { return fmt.Errorf("trigger_config exceeds %d bytes", maxTriggerConfigBytes) } if len(req.PublicFaces) > maxPublicFaces { return fmt.Errorf("at most %d public faces per workload", maxPublicFaces) } if req.SourceKind != "" { src, err := plugin.GetSource(req.SourceKind) if err != nil { return err } if err := src.Validate(req.SourceConfig); err != nil { return err } } if req.TriggerKind != "" { trg, err := plugin.GetTrigger(req.TriggerKind) if err != nil { return err } if err := trg.Validate(req.TriggerConfig); err != nil { return err } } return nil }