feat(apps): stepped creation wizard, branch previews, and app-creation fixes
This session (frontend focus):
- Rebuild /apps/new as a 4-step wizard (Basics → Configure → Trigger → Review):
WizardRail, SourceKindPicker card grid, AppManifest review, per-step validation,
ConfirmDialog-based unsaved-changes guard.
- Extract lib/workload/sourceForms.ts (single source of truth for source_config)
+ {Image,Compose,Static,Dockerfile}SourceForm + StaticDiscoveryWizard; fold the
/apps/[id] edit form onto the same components (removes the duplication). Add
vitest + sourceForms unit tests.
- Branch preview environments UI: /chain is_preview/preview_branch + a Preview
environments panel on /apps/[id] (per-branch URLs, ConfirmDialog teardown, armed
state); RegistryImagePicker on the registry trigger and the image source.
- Fixes: image-inspect 404 -> admin-gated POST /api/discovery/image/inspect;
conflict-panel blur flicker; friendly localized discovery errors; CPU/Memory
label hints; dashboard + /apps "Total workloads" count only source_kind workloads
(drop stale trigger_kind gate); NPM cert/access-list name cache; EntityPicker
empty-list guard.
- Update CLAUDE.md frontend conventions + add a Build & Test section.
Also captures pre-existing in-progress platform work (not from this session):
workload notifications, Prometheus metrics export, store lockfile, health probes,
backup hardening, and related store/webhook/scheduler changes.
This commit is contained in:
@@ -16,13 +16,12 @@ import (
|
||||
)
|
||||
|
||||
// rateLimitedLogin wraps the login handler with per-IP rate limiting.
|
||||
// Uses clientIP() so X-Forwarded-For is honored only when the request
|
||||
// arrives from a configured trusted-proxy CIDR — preventing remote
|
||||
// attackers from spoofing the header to bypass the per-IP login limiter.
|
||||
func (s *Server) rateLimitedLogin(rl *rateLimiter) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := r.RemoteAddr
|
||||
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
||||
ip = fwd
|
||||
}
|
||||
if !rl.allow(ip) {
|
||||
if !rl.allow(clientIP(r)) {
|
||||
respondError(w, http.StatusTooManyRequests, "too many login attempts, try again later")
|
||||
return
|
||||
}
|
||||
|
||||
+73
-32
@@ -1,7 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -118,7 +117,22 @@ func (s *Server) deleteBackup(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// restoreBackup handles POST /api/backups/{id}/restore.
|
||||
// This replaces the current database with the backup and triggers a graceful shutdown.
|
||||
//
|
||||
// Restore happens in three documented stages so a failure at any stage
|
||||
// leaves the live DB intact:
|
||||
//
|
||||
// 1. PRE-FLIGHT (sync, before the HTTP response): PrepareRestore opens
|
||||
// the candidate read-only and runs `PRAGMA integrity_check`. If it
|
||||
// fails the live DB is untouched and we return 400 with the reason.
|
||||
//
|
||||
// 2. SAFETY NET: a pre-restore backup of the LIVE DB is created so the
|
||||
// operator can roll back even if the candidate is later discovered
|
||||
// to be missing data.
|
||||
//
|
||||
// 3. SWAP (async, after the response is flushed): close the live DB,
|
||||
// atomic-rename the candidate over the live path, wipe WAL/SHM,
|
||||
// trigger graceful shutdown. supervisord / systemd / docker
|
||||
// restart=on-failure brings the process back with the new DB.
|
||||
func (s *Server) restoreBackup(w http.ResponseWriter, r *http.Request) {
|
||||
if s.backupEngine == nil {
|
||||
respondError(w, http.StatusServiceUnavailable, "backup engine not initialized")
|
||||
@@ -126,13 +140,44 @@ func (s *Server) restoreBackup(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
id := chi.URLParam(r, "id")
|
||||
restorePath, err := s.backupEngine.RestorePath(id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "backup not found: "+err.Error())
|
||||
|
||||
// CSRF / accidental-fire guard: the restore endpoint is the most
|
||||
// destructive surface in the API (replaces the whole DB). Even
|
||||
// though it sits behind AdminOnly + Bearer JWT, a blind cross-site
|
||||
// POST or a misclicked button in any open admin tab can fire it.
|
||||
// Require the operator's client to echo X-Confirm-Restore: <id>
|
||||
// — matching the path param — so a CSRF post-form / image-src
|
||||
// trick can't trigger restore (browsers don't let cross-origin
|
||||
// requests set custom headers without a preflight).
|
||||
if confirm := r.Header.Get("X-Confirm-Restore"); confirm != id {
|
||||
respondError(w, http.StatusBadRequest,
|
||||
"missing or mismatched X-Confirm-Restore header (must equal backup id)")
|
||||
return
|
||||
}
|
||||
|
||||
// Create a safety backup before restore so the user can undo if needed.
|
||||
// Single-flight guard: a rapid double-click would otherwise spawn
|
||||
// two goroutines racing s.store.Close() and the candidate-over-
|
||||
// live rename. CAS to true here; if someone else won, return 409.
|
||||
if !s.restoreInFlight.CompareAndSwap(false, true) {
|
||||
respondError(w, http.StatusConflict, "a restore is already in progress")
|
||||
return
|
||||
}
|
||||
// Do NOT release the flag — the restore path triggers shutdown.
|
||||
// A failed restore is also terminal (the DB may be closed); a
|
||||
// fresh process boot is the recovery path.
|
||||
// PRE-FLIGHT: refuse before touching anything if the candidate is
|
||||
// not a valid SQLite database or fails integrity_check. This is the
|
||||
// guard the prior code lacked — a corrupt backup would silently
|
||||
// overwrite a healthy live DB.
|
||||
restorePath, err := s.backupEngine.PrepareRestore(id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// SAFETY NET: pre-restore snapshot of the live DB. A failure here
|
||||
// is logged but does not abort — the integrity-checked candidate
|
||||
// is still safer than refusing to restore.
|
||||
if _, err := s.backupEngine.CreateBackup("pre-restore"); err != nil {
|
||||
slog.Warn("failed to create pre-restore backup", "error", err)
|
||||
}
|
||||
@@ -153,41 +198,37 @@ func (s *Server) restoreBackup(w http.ResponseWriter, r *http.Request) {
|
||||
go func() {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Close the current database to release locks.
|
||||
// Once we begin closing the live DB the process can no longer serve
|
||||
// requests against a sane store, so EVERY exit path from here must
|
||||
// trigger shutdown. Returning early would leave the server limping
|
||||
// on a closed/half-swapped database with no path to recovery except
|
||||
// an external kill. shutdownFunc → graceful shutdown → main returns
|
||||
// → deferred releaseLock()/db.Close() run, and the supervisor reopens
|
||||
// whatever DB is on disk on the next boot.
|
||||
triggerShutdown := func() {
|
||||
if s.shutdownFunc != nil {
|
||||
s.shutdownFunc()
|
||||
}
|
||||
}
|
||||
|
||||
// Close the current database to release locks. AtomicReplaceDB
|
||||
// expects the live file to be unmapped before swap (especially
|
||||
// important on Windows where open files cannot be renamed over).
|
||||
if err := s.store.Close(); err != nil {
|
||||
slog.Error("restore: failed to close database", "error", err)
|
||||
slog.Error("restore: failed to close database, restarting", "error", err)
|
||||
triggerShutdown()
|
||||
return
|
||||
}
|
||||
|
||||
// Copy the backup file over the main database using streaming (no full read into memory).
|
||||
src, err := os.Open(restorePath)
|
||||
if err != nil {
|
||||
slog.Error("restore: failed to open backup file", "error", err)
|
||||
if err := s.backupEngine.AtomicReplaceDB(restorePath, s.dbPath); err != nil {
|
||||
slog.Error("restore: atomic replace failed, restarting", "error", err)
|
||||
triggerShutdown()
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
dst, err := os.Create(s.dbPath)
|
||||
if err != nil {
|
||||
slog.Error("restore: failed to create database file", "error", err)
|
||||
return
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
slog.Error("restore: failed to copy backup to database", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Remove WAL and SHM files to ensure clean state.
|
||||
os.Remove(s.dbPath + "-wal")
|
||||
os.Remove(s.dbPath + "-shm")
|
||||
|
||||
slog.Info("restore: database replaced, triggering shutdown")
|
||||
|
||||
// Signal the server to shut down gracefully so it can be restarted.
|
||||
if s.shutdownFunc != nil {
|
||||
s.shutdownFunc()
|
||||
}
|
||||
triggerShutdown()
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/docker"
|
||||
"github.com/alexei/tinyforge/internal/staticsite"
|
||||
)
|
||||
|
||||
@@ -350,6 +351,54 @@ func (s *Server) listImageConflicts(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, conflicts)
|
||||
}
|
||||
|
||||
// inspectImageRequest is the body for POST /api/discovery/image/inspect.
|
||||
type inspectImageRequest struct {
|
||||
Image string `json:"image"`
|
||||
}
|
||||
|
||||
// inspectImageResponse mirrors the frontend InspectResult shape the
|
||||
// new-app wizard pre-fills from: the first exposed port (parsed to int,
|
||||
// 0 when none) and the image's HEALTHCHECK command string.
|
||||
type inspectImageResponse struct {
|
||||
Port int `json:"port"`
|
||||
Healthcheck string `json:"healthcheck"`
|
||||
}
|
||||
|
||||
// inspectImageMetadata inspects a LOCAL image and returns its first
|
||||
// exposed port + healthcheck so the wizard can pre-fill those fields.
|
||||
// POST /api/discovery/image/inspect.
|
||||
//
|
||||
// This inspects local images only — it does not pull. When the image is
|
||||
// not present locally the docker call fails; we return a generic,
|
||||
// non-leaky 400 rather than the git-specific upstreamError so a raw
|
||||
// docker daemon string (which may echo the ref) never reaches the client.
|
||||
func (s *Server) inspectImageMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
var req inspectImageRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
image := strings.TrimSpace(req.Image)
|
||||
if image == "" {
|
||||
respondError(w, http.StatusBadRequest, "image is required")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), discoveryTimeout)
|
||||
defer cancel()
|
||||
|
||||
info, err := s.docker.InspectImage(ctx, image)
|
||||
if err != nil {
|
||||
slog.Warn("inspect image metadata failed", "error", err)
|
||||
respondError(w, http.StatusBadRequest, "could not inspect image — make sure it is pulled locally and the reference is correct")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, inspectImageResponse{
|
||||
Port: docker.ExtractPort(info.ExposedPorts),
|
||||
Healthcheck: info.Healthcheck,
|
||||
})
|
||||
}
|
||||
|
||||
// stripImageTag returns the image reference with the trailing :tag
|
||||
// removed, taking care to leave a registry port (e.g. registry:5000/foo)
|
||||
// intact. Digest references (image@sha256:...) are returned unchanged.
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/metrics"
|
||||
)
|
||||
|
||||
// livez always returns 200 if the process is up. Used by container
|
||||
// orchestrators / load balancers / Docker HEALTHCHECK as the "is the
|
||||
// binary alive" probe. Intentionally does NOT touch the DB or Docker —
|
||||
// a slow DB must not cause restart loops.
|
||||
func (s *Server) livez(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
}
|
||||
|
||||
// readyz returns 200 only when the process can actually serve traffic:
|
||||
// SQLite is reachable, the encryption key is loaded, the deployer is
|
||||
// not draining. The response body is intentionally minimal — the
|
||||
// specific failing probe name is recorded in slog (operator-visible)
|
||||
// rather than returned to unauthenticated callers. This avoids handing
|
||||
// reconnaissance to an attacker who can hit /readyz during an outage
|
||||
// ("DB down" vs "encryption key missing" leaks operational state).
|
||||
func (s *Server) readyz(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// DB ping: cheap and exact — exercises the connection pool, file
|
||||
// lock, and busy-timeout. A failing ping means SQLite WAL is wedged
|
||||
// or the data dir is gone.
|
||||
if err := s.store.DB().PingContext(ctx); err != nil {
|
||||
slog.Warn("readyz: db ping failed", "error", err)
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte("not ready\n"))
|
||||
return
|
||||
}
|
||||
|
||||
// Encryption key sanity: if it's zero we cannot decrypt any stored
|
||||
// secret, so the deployer paths will all explode at first use.
|
||||
if s.encKey == ([32]byte{}) {
|
||||
slog.Warn("readyz: encryption key not loaded")
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte("not ready\n"))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte("ready\n"))
|
||||
}
|
||||
|
||||
// metricsExport writes the process-wide metrics registry in Prometheus
|
||||
// text format. Admin-only by router placement; surface is intentionally
|
||||
// thin (no histograms / quantiles, only counters) to keep the binary
|
||||
// dependency-free.
|
||||
func (s *Server) metricsExport(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||
_ = metrics.DefaultRegistry.WritePrometheus(w)
|
||||
}
|
||||
+318
-7
@@ -1,14 +1,119 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/metrics"
|
||||
)
|
||||
|
||||
// requestIDKey is the context key under which the generated/forwarded
|
||||
// X-Request-ID is stored. Exported indirectly via RequestIDFromContext
|
||||
// so handlers and services downstream of the API layer can thread it
|
||||
// into their own slog calls without re-extracting from headers.
|
||||
type requestIDKeyType struct{}
|
||||
|
||||
var requestIDKey = requestIDKeyType{}
|
||||
|
||||
// RequestIDFromContext returns the correlation ID for the request, or
|
||||
// "" when called outside the API request path.
|
||||
func RequestIDFromContext(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(requestIDKey).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// requestID middleware ensures every request has a stable correlation
|
||||
// ID. Honors a caller-supplied X-Request-ID when the request comes from
|
||||
// a trusted proxy AND the value matches a safe character set; otherwise
|
||||
// generates a fresh 128-bit ID. The ID is echoed back as X-Request-ID
|
||||
// and stitched into every subsequent slog call via the context value
|
||||
// the `logging` middleware reads.
|
||||
//
|
||||
// Format clamp: a compromised reverse proxy (or one that mis-parses an
|
||||
// untrusted header) could forward an ID containing newlines, semicolons,
|
||||
// or other separator characters. Those would corrupt structured log
|
||||
// parsers that assume one record per line / key-value. Restricting to
|
||||
// `[A-Za-z0-9._-]{1,64}` covers UUIDs, hex IDs, and trace-context IDs
|
||||
// without any sharp edges.
|
||||
func requestID(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
rid := r.Header.Get("X-Request-ID")
|
||||
if rid == "" || !isTrustedPeer(r) || !isValidRequestID(rid) {
|
||||
rid = newRequestID()
|
||||
}
|
||||
w.Header().Set("X-Request-ID", rid)
|
||||
ctx := context.WithValue(r.Context(), requestIDKey, rid)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// isValidRequestID enforces `[A-Za-z0-9._-]{1,64}` without compiling a
|
||||
// regex on the request path. Single linear scan, no allocations.
|
||||
func isValidRequestID(s string) bool {
|
||||
if len(s) == 0 || len(s) > 64 {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c >= 'A' && c <= 'Z':
|
||||
case c >= 'a' && c <= 'z':
|
||||
case c >= '0' && c <= '9':
|
||||
case c == '.' || c == '_' || c == '-':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isTrustedPeer is a thin wrapper around the TRUSTED_PROXY_CIDRS allow-
|
||||
// list — we honor a forwarded request-id only from upstreams we already
|
||||
// trust for X-Forwarded-For. Otherwise an internet client could spam
|
||||
// log files with attacker-chosen IDs.
|
||||
func isTrustedPeer(r *http.Request) bool {
|
||||
peer := r.RemoteAddr
|
||||
if host, _, err := net.SplitHostPort(peer); err == nil {
|
||||
peer = host
|
||||
}
|
||||
if len(trustedProxyCIDRs) == 0 {
|
||||
return false
|
||||
}
|
||||
ip := net.ParseIP(peer)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
for _, n := range trustedProxyCIDRs {
|
||||
if n.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func newRequestID() string {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
// Fall back to time-based suffix if crypto/rand is unavailable
|
||||
// — extremely unlikely outside of broken environments, but the
|
||||
// ID is for tracing not security, so a deterministic fallback
|
||||
// is preferable to a panic.
|
||||
return "ts-" + time.Now().UTC().Format("20060102T150405.000000000")
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// logging is an HTTP middleware that logs every request with method, path,
|
||||
// status code, and duration. Webhook URLs are redacted before being logged
|
||||
// because the secret is the only authenticator — leaking it to log
|
||||
@@ -20,15 +125,58 @@ func logging(next http.Handler) http.Handler {
|
||||
|
||||
next.ServeHTTP(wrapped, r)
|
||||
|
||||
slog.Info("http request",
|
||||
fields := []any{
|
||||
"method", r.Method,
|
||||
"path", redactPath(r.URL.Path),
|
||||
"status", wrapped.status,
|
||||
"duration", time.Since(start).String(),
|
||||
)
|
||||
}
|
||||
if rq := redactQuery(r.URL.RawQuery); rq != "" {
|
||||
fields = append(fields, "query", rq)
|
||||
}
|
||||
if rid := RequestIDFromContext(r.Context()); rid != "" {
|
||||
fields = append(fields, "request_id", rid)
|
||||
}
|
||||
slog.Info("http request", fields...)
|
||||
|
||||
// Lightweight per-request counter. Bucket by status class so
|
||||
// the cardinality stays at 5 × #methods regardless of how many
|
||||
// distinct response codes we emit.
|
||||
metrics.HTTPRequestsTotal.Inc(bucketMethod(r.Method), statusClass(wrapped.status))
|
||||
})
|
||||
}
|
||||
|
||||
// bucketMethod normalises HTTP method names against the standard set
|
||||
// so a malicious client cannot spam arbitrary method tokens (RFC 7230
|
||||
// allows any token) and inflate the metrics map. Anything off the
|
||||
// allow-list collapses to "other".
|
||||
func bucketMethod(m string) string {
|
||||
switch m {
|
||||
case "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "CONNECT", "TRACE":
|
||||
return m
|
||||
}
|
||||
return "other"
|
||||
}
|
||||
|
||||
// statusClass buckets a status code into "1xx".."5xx" / "other". Keeps
|
||||
// metrics cardinality bounded so a chatty endpoint can't explode the
|
||||
// metrics map with one series per distinct response code.
|
||||
func statusClass(code int) string {
|
||||
switch {
|
||||
case code >= 100 && code < 200:
|
||||
return "1xx"
|
||||
case code >= 200 && code < 300:
|
||||
return "2xx"
|
||||
case code >= 300 && code < 400:
|
||||
return "3xx"
|
||||
case code >= 400 && code < 500:
|
||||
return "4xx"
|
||||
case code >= 500 && code < 600:
|
||||
return "5xx"
|
||||
}
|
||||
return "other"
|
||||
}
|
||||
|
||||
// redactPath strips secrets from URL paths that carry them in segments.
|
||||
// Only the canonical /api/webhook/triggers/{secret} surface remains after
|
||||
// the hard cutover.
|
||||
@@ -40,6 +188,45 @@ func redactPath(path string) string {
|
||||
return path
|
||||
}
|
||||
|
||||
// redactQueryKeys is the case-insensitive set of query-parameter names whose
|
||||
// values are masked before a URL lands in the request log. `token` is used by
|
||||
// SSE/EventSource when a custom header can't be set; the rest are
|
||||
// defence-in-depth against sensitive values ever appearing in a query string.
|
||||
var redactQueryKeys = map[string]struct{}{
|
||||
"token": {},
|
||||
"secret": {},
|
||||
"password": {},
|
||||
"passwd": {},
|
||||
"api_key": {},
|
||||
"apikey": {},
|
||||
"access_token": {},
|
||||
"client_secret": {},
|
||||
"sig": {},
|
||||
"signature": {},
|
||||
}
|
||||
|
||||
// redactQuery masks the values of sensitive query parameters (see
|
||||
// redactQueryKeys) in a URL's raw query before it lands in the request log.
|
||||
// Key matching is case-insensitive. Returns the input unchanged when there is
|
||||
// nothing to redact so a malformed URL surfaces naturally.
|
||||
func redactQuery(rawQuery string) string {
|
||||
if rawQuery == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(rawQuery, "&")
|
||||
for i, p := range parts {
|
||||
eq := strings.IndexByte(p, '=')
|
||||
if eq < 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(p[:eq])
|
||||
if _, ok := redactQueryKeys[key]; ok {
|
||||
parts[i] = p[:eq+1] + "***"
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "&")
|
||||
}
|
||||
|
||||
// recovery is an HTTP middleware that catches panics and returns a 500 response.
|
||||
func recovery(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -54,16 +241,49 @@ func recovery(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
// securityHeaders sets standard security headers on all responses.
|
||||
//
|
||||
// Strict-Transport-Security is emitted only when the request arrived
|
||||
// over HTTPS (direct TLS or forwarded). Emitting HSTS over plain HTTP
|
||||
// is harmless to compliant browsers but flags as an issue in scanners
|
||||
// and confuses some reverse proxies.
|
||||
//
|
||||
// The CSP keeps `'unsafe-inline'` for now because SvelteKit injects
|
||||
// inline boot scripts and styles; removing it requires a nonce-based
|
||||
// strategy threaded through the SvelteKit handle hook. Tracked as a
|
||||
// follow-up; documented in the security report.
|
||||
func securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'")
|
||||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
|
||||
w.Header().Set("Content-Security-Policy",
|
||||
"default-src 'self'; "+
|
||||
"script-src 'self' 'unsafe-inline'; "+
|
||||
"style-src 'self' 'unsafe-inline'; "+
|
||||
"img-src 'self' data:; "+
|
||||
"connect-src 'self'; "+
|
||||
"font-src 'self'; "+
|
||||
"frame-ancestors 'none'; "+
|
||||
"base-uri 'self'; "+
|
||||
"form-action 'self'")
|
||||
if isHTTPS(r) {
|
||||
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func isHTTPS(r *http.Request) bool {
|
||||
if r.TLS != nil {
|
||||
return true
|
||||
}
|
||||
if r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// cors is an HTTP middleware that handles CORS for same-origin requests.
|
||||
// The frontend is served from the same origin, so cross-origin requests are not expected.
|
||||
func cors(next http.Handler) http.Handler {
|
||||
@@ -164,10 +384,7 @@ func jsonContentType(next http.Handler) http.Handler {
|
||||
func rateLimitMiddleware(rl *rateLimiter) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := r.RemoteAddr
|
||||
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
||||
ip = fwd
|
||||
}
|
||||
ip := clientIP(r)
|
||||
if !rl.allow(ip) {
|
||||
respondError(w, http.StatusTooManyRequests, "rate limit exceeded")
|
||||
return
|
||||
@@ -177,6 +394,100 @@ func rateLimitMiddleware(rl *rateLimiter) func(http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// trustedProxyCIDRs is the parsed allow-list of upstream proxy networks
|
||||
// whose X-Forwarded-For header we honor. Set TRUSTED_PROXY_CIDRS to a
|
||||
// comma-separated list of CIDRs (e.g. "127.0.0.1/32,10.0.0.0/8") to
|
||||
// enable. When unset (the default) X-Forwarded-For is ignored entirely
|
||||
// and rate limiting + audit logging use r.RemoteAddr — preventing a
|
||||
// remote attacker from spoofing the header to bypass per-IP limiters.
|
||||
var trustedProxyCIDRs = parseTrustedProxyCIDRs(os.Getenv("TRUSTED_PROXY_CIDRS"))
|
||||
|
||||
func parseTrustedProxyCIDRs(raw string) []*net.IPNet {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
var nets []*net.IPNet
|
||||
for _, p := range strings.Split(raw, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
// Allow bare IPs as /32 (IPv4) or /128 (IPv6).
|
||||
if !strings.Contains(p, "/") {
|
||||
if ip := net.ParseIP(p); ip != nil {
|
||||
if ip.To4() != nil {
|
||||
p += "/32"
|
||||
} else {
|
||||
p += "/128"
|
||||
}
|
||||
}
|
||||
}
|
||||
_, n, err := net.ParseCIDR(p)
|
||||
if err != nil {
|
||||
slog.Warn("ignoring invalid TRUSTED_PROXY_CIDRS entry", "value", p, "error", err)
|
||||
continue
|
||||
}
|
||||
nets = append(nets, n)
|
||||
}
|
||||
return nets
|
||||
}
|
||||
|
||||
// clientIP returns the per-request "client" address used for rate-limit
|
||||
// keying and audit attribution. X-Forwarded-For is honored ONLY when the
|
||||
// direct peer (r.RemoteAddr) belongs to a configured trusted-proxy CIDR;
|
||||
// otherwise the header is ignored to prevent header-spoofing bypasses.
|
||||
func clientIP(r *http.Request) string {
|
||||
peer := r.RemoteAddr
|
||||
if host, _, err := net.SplitHostPort(peer); err == nil {
|
||||
peer = host
|
||||
}
|
||||
if len(trustedProxyCIDRs) == 0 {
|
||||
return peer
|
||||
}
|
||||
peerIP := net.ParseIP(peer)
|
||||
if peerIP == nil || !isTrustedProxy(peerIP) {
|
||||
return peer
|
||||
}
|
||||
fwd := r.Header.Get("X-Forwarded-For")
|
||||
if fwd == "" {
|
||||
return peer
|
||||
}
|
||||
// Walk X-Forwarded-For from the RIGHTMOST entry (the address closest to
|
||||
// us, appended by our trusted peer) leftward, skipping entries that are
|
||||
// themselves trusted proxies, and return the first untrusted address.
|
||||
// The LEFTMOST entry is fully client-controlled — trusting it (as a
|
||||
// naive `fwd[:firstComma]` does) lets an attacker spoof their rate-limit
|
||||
// and audit identity by prepending a forged value, defeating the per-IP
|
||||
// login limiter.
|
||||
parts := strings.Split(fwd, ",")
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
candidate := strings.TrimSpace(parts[i])
|
||||
ip := net.ParseIP(candidate)
|
||||
if ip == nil {
|
||||
continue
|
||||
}
|
||||
if isTrustedProxy(ip) {
|
||||
continue
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
// Every forwarded entry was a trusted proxy (or unparseable) — fall back
|
||||
// to the direct peer.
|
||||
return peer
|
||||
}
|
||||
|
||||
// isTrustedProxy reports whether ip falls within a configured
|
||||
// trusted-proxy CIDR.
|
||||
func isTrustedProxy(ip net.IP) bool {
|
||||
for _, n := range trustedProxyCIDRs {
|
||||
if n.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// statusRecorder wraps http.ResponseWriter to capture the status code.
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
|
||||
+74
-12
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
@@ -61,6 +62,13 @@ type Server struct {
|
||||
shutdownFunc func() // called after restore to trigger graceful shutdown
|
||||
onBackupSettingsChanged func(enabled bool, intervalHours int) // called when backup settings change
|
||||
onProxyProviderChanged func(provider proxy.Provider) // called when proxy provider changes
|
||||
|
||||
// restoreInFlight is a process-wide guard against double-firing
|
||||
// the restore endpoint. A rapid double-click would otherwise
|
||||
// schedule two goroutines racing s.store.Close() and the
|
||||
// candidate-over-live rename. CAS to true at the entry point;
|
||||
// reject the second caller with 409 Conflict.
|
||||
restoreInFlight atomic.Bool
|
||||
}
|
||||
|
||||
// NewServer creates a new API Server with all required dependencies.
|
||||
@@ -157,13 +165,32 @@ func (s *Server) SetDNSProviderChangedCallback(fn DNSProviderChangedFunc) {
|
||||
|
||||
// initOIDCProvider creates an OIDC provider from settings. Errors are logged, not fatal.
|
||||
func (s *Server) initOIDCProvider(ctx context.Context, as store.AuthSettings) {
|
||||
// Decrypt the OIDC client secret if it's encrypted.
|
||||
// Decrypt the OIDC client secret. The prior code did a try-decrypt
|
||||
// and silently treated failures as plaintext — under a rotated key
|
||||
// that sent ciphertext upstream to the OP. Now:
|
||||
// - If the value carries the tf1: envelope → fail loud on
|
||||
// decrypt failure (rotated key / corrupted ciphertext).
|
||||
// - If the value is unprefixed (legacy ciphertext from v0 or true
|
||||
// plaintext from an old migration) → try decrypt; on failure
|
||||
// accept as plaintext (the only safe legacy interpretation).
|
||||
clientSecret := as.OIDCClientSecret
|
||||
if clientSecret != "" {
|
||||
if decrypted, err := crypto.Decrypt(s.encKey, clientSecret); err == nil {
|
||||
switch {
|
||||
case crypto.HasEnvelope(clientSecret):
|
||||
decrypted, err := crypto.Decrypt(s.encKey, clientSecret)
|
||||
if err != nil {
|
||||
slog.Error("OIDC client secret could not be decrypted — refusing to initialize provider",
|
||||
"error", err,
|
||||
"hint", "rotate ENCRYPTION_KEY back, OR re-save OIDC settings to re-encrypt with the current key")
|
||||
return
|
||||
}
|
||||
clientSecret = decrypted
|
||||
default:
|
||||
// Legacy v0 value: try decrypt; on failure assume plaintext.
|
||||
if decrypted, err := crypto.Decrypt(s.encKey, clientSecret); err == nil {
|
||||
clientSecret = decrypted
|
||||
}
|
||||
}
|
||||
// If decrypt fails, assume it's already plaintext (migration scenario).
|
||||
}
|
||||
provider, err := auth.NewOIDCProvider(ctx, auth.OIDCConfig{
|
||||
IssuerURL: as.OIDCIssuerURL,
|
||||
@@ -183,12 +210,29 @@ func (s *Server) initOIDCProvider(ctx context.Context, as store.AuthSettings) {
|
||||
func (s *Server) Router() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Global middleware.
|
||||
// Global middleware. requestID runs first so every downstream log
|
||||
// line (and the access log emitted by `logging`) carries the same
|
||||
// correlation id, plus the response carries it back on the
|
||||
// X-Request-ID header for the operator to grep across services.
|
||||
r.Use(requestID)
|
||||
r.Use(recovery)
|
||||
r.Use(securityHeaders)
|
||||
r.Use(logging)
|
||||
r.Use(cors)
|
||||
|
||||
// Unauthenticated health probes — mounted at the root so container
|
||||
// orchestrators / load balancers can hit them without knowing about
|
||||
// the /api prefix. /livez intentionally does no work and stays
|
||||
// unbounded; /readyz pings the DB and is rate-limited to keep an
|
||||
// unauthenticated flood from serialising behind SQLite's single
|
||||
// writer connection (busy-timeout = 5s) and log-amplifying every
|
||||
// request via the structured access log. The 10-per-minute budget
|
||||
// is the existing rateLimiter default — generous for k8s readiness
|
||||
// probes (typically every 5-10s), restrictive for an attacker.
|
||||
r.Get("/livez", s.livez)
|
||||
readyLimiter := newRateLimiter()
|
||||
r.With(rateLimitMiddleware(readyLimiter)).Get("/readyz", s.readyz)
|
||||
|
||||
loginLimiter := newRateLimiter()
|
||||
webhookLimiter := newRateLimiter()
|
||||
|
||||
@@ -232,6 +276,7 @@ func (s *Server) Router() chi.Router {
|
||||
r.Post("/discovery/git/branches", s.listGitBranches)
|
||||
r.Post("/discovery/git/tree", s.listGitTree)
|
||||
r.Get("/discovery/image/conflicts", s.listImageConflicts)
|
||||
r.Post("/discovery/image/inspect", s.inspectImageMetadata)
|
||||
})
|
||||
|
||||
// Read-only endpoints (any authenticated user).
|
||||
@@ -245,16 +290,18 @@ func (s *Server) Router() chi.Router {
|
||||
r.Get("/events/log/stats", s.getEventLogStats)
|
||||
r.Get("/registries", s.listRegistries)
|
||||
r.Route("/registries/{id}", func(r chi.Router) {
|
||||
// All registry probes are admin-gated. The /tags and
|
||||
// /images endpoints used to be open to any authenticated
|
||||
// user, but they make outbound requests using the
|
||||
// admin-encrypted registry token — a viewer could
|
||||
// effectively drive arbitrary requests against a private
|
||||
// registry under admin credentials.
|
||||
r.Use(auth.AdminOnly)
|
||||
r.Get("/tags/*", s.listRegistryTags)
|
||||
r.Get("/images", s.listRegistryImages)
|
||||
|
||||
// Admin-only registry mutations.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(auth.AdminOnly)
|
||||
r.Put("/", s.updateRegistry)
|
||||
r.Delete("/", s.deleteRegistry)
|
||||
r.Post("/test", s.testRegistry)
|
||||
})
|
||||
r.Put("/", s.updateRegistry)
|
||||
r.Delete("/", s.deleteRegistry)
|
||||
r.Post("/test", s.testRegistry)
|
||||
})
|
||||
r.Get("/settings", s.getSettings)
|
||||
r.Get("/settings/npm-certificates", s.listNpmCertificates)
|
||||
@@ -312,6 +359,15 @@ func (s *Server) Router() chi.Router {
|
||||
// of /triggers/{id}/bindings keyed on the workload side.
|
||||
r.Get("/triggers", s.listBindingsForWorkload)
|
||||
r.With(auth.AdminOnly).Post("/triggers", s.bindTriggerToWorkload)
|
||||
|
||||
// Per-workload notification routes — multi-destination
|
||||
// fan-out (Slack channel + Discord webhook + ...). When
|
||||
// zero rows are configured the dispatcher falls back to
|
||||
// the legacy single-URL columns on the workload row.
|
||||
r.Get("/notifications", s.listWorkloadNotifications)
|
||||
r.With(auth.AdminOnly).Post("/notifications", s.createWorkloadNotification)
|
||||
r.With(auth.AdminOnly).Put("/notifications/{nid}", s.updateWorkloadNotification)
|
||||
r.With(auth.AdminOnly).Delete("/notifications/{nid}", s.deleteWorkloadNotification)
|
||||
})
|
||||
|
||||
// Global container index, joined to workload + app names.
|
||||
@@ -379,6 +435,12 @@ func (s *Server) Router() chi.Router {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(auth.AdminOnly)
|
||||
|
||||
// Prometheus-format metrics export. Admin-only so the
|
||||
// counter cardinality cannot be enumerated by a low-trust
|
||||
// viewer to map internal endpoints / sources / outcomes.
|
||||
// Scrape with bearer auth from your Prometheus job.
|
||||
r.Get("/metrics", s.metricsExport)
|
||||
|
||||
// Config export (reveals registry/global details).
|
||||
r.Get("/config/export", s.exportConfig)
|
||||
|
||||
|
||||
+19
-2
@@ -32,9 +32,26 @@ func (s *Server) streamEvents(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
flusher.Flush()
|
||||
|
||||
// Subscribe to instance status, deploy status, and persistent event log events.
|
||||
// Build logs are high-volume: a single verbose `docker build` can emit
|
||||
// thousands of lines. Streaming them to EVERY connection would flood each
|
||||
// subscriber's bounded bus buffer and evict status/log events for ALL
|
||||
// clients. So build logs are delivered ONLY to connections that opt in
|
||||
// with ?workload_id=<id>, and only for that workload. Connections without
|
||||
// the param (e.g. the global dashboard) never receive build-log frames.
|
||||
buildLogWorkloadID := r.URL.Query().Get("workload_id")
|
||||
sub := s.eventBus.Subscribe(func(evt events.Event) bool {
|
||||
return evt.Type == events.EventInstanceStatus || evt.Type == events.EventDeployStatus || evt.Type == events.EventLog
|
||||
switch evt.Type {
|
||||
case events.EventInstanceStatus, events.EventDeployStatus, events.EventLog:
|
||||
return true
|
||||
case events.EventBuildLog:
|
||||
if buildLogWorkloadID == "" {
|
||||
return false
|
||||
}
|
||||
p, ok := evt.Payload.(events.BuildLogPayload)
|
||||
return ok && p.WorkloadID == buildLogWorkloadID
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
defer s.eventBus.Unsubscribe(sub)
|
||||
|
||||
|
||||
@@ -89,12 +89,16 @@ func toTriggerViewWithCount(row store.TriggerWithBindingCount) triggerView {
|
||||
// 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.
|
||||
//
|
||||
// WebhookRequireSignature is a *bool so we can distinguish "field omitted
|
||||
// by client" (nil → apply secure default of true when webhook is enabled)
|
||||
// from an explicit opt-out (false → respected).
|
||||
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"`
|
||||
WebhookRequireSignature *bool `json:"webhook_require_signature,omitempty"`
|
||||
}
|
||||
|
||||
// Same per-blob caps used on the workload pluginWorkloadRequest path —
|
||||
@@ -134,12 +138,26 @@ func (s *Server) getTrigger(w http.ResponseWriter, r *http.Request) {
|
||||
// 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.
|
||||
//
|
||||
// SECURITY: a new trigger with webhook enabled defaults to require_signature
|
||||
// = true. Operators can opt out at create time for receivers that do not
|
||||
// support HMAC, but the safer default avoids the "freshly-created trigger
|
||||
// accepts unsigned posts to its URL" footgun.
|
||||
func buildTriggerFromRequest(req triggerRequest) store.Trigger {
|
||||
// Secure default: if webhook is enabled and the operator did NOT
|
||||
// explicitly set require_signature, force it on. Explicit false is
|
||||
// preserved (legacy receivers without HMAC support still work).
|
||||
requireSig := false
|
||||
if req.WebhookRequireSignature != nil {
|
||||
requireSig = *req.WebhookRequireSignature
|
||||
} else if req.WebhookEnabled {
|
||||
requireSig = true
|
||||
}
|
||||
t := store.Trigger{
|
||||
Kind: req.Kind,
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
Config: string(req.Config),
|
||||
WebhookRequireSignature: req.WebhookRequireSignature,
|
||||
WebhookRequireSignature: requireSig,
|
||||
}
|
||||
if req.WebhookEnabled {
|
||||
t.WebhookSecret = generateWebhookSecret()
|
||||
@@ -199,7 +217,13 @@ func (s *Server) updateTrigger(w http.ResponseWriter, r *http.Request) {
|
||||
if len(req.Config) > 0 {
|
||||
existing.Config = string(req.Config)
|
||||
}
|
||||
existing.WebhookRequireSignature = req.WebhookRequireSignature
|
||||
if req.WebhookRequireSignature != nil {
|
||||
existing.WebhookRequireSignature = *req.WebhookRequireSignature
|
||||
} else if req.WebhookEnabled && !existing.WebhookRequireSignature {
|
||||
// Re-enabling webhook without specifying the signature flag —
|
||||
// take the secure default.
|
||||
existing.WebhookRequireSignature = true
|
||||
}
|
||||
wasEnabled := existing.WebhookSecret != ""
|
||||
if req.WebhookEnabled && !wasEnabled {
|
||||
// false→true transition: rotate both secrets so re-enabling
|
||||
|
||||
@@ -13,18 +13,29 @@ import (
|
||||
"github.com/alexei/tinyforge/internal/auth"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
"github.com/alexei/tinyforge/internal/workload/preview"
|
||||
)
|
||||
|
||||
// chainNode is the lightweight shape returned by /chain — we deliberately
|
||||
// don't return full plugin.Workload values for ancestor/descendant rows
|
||||
// because the secret fields don't belong in a chain-traversal response.
|
||||
//
|
||||
// IsPreview / PreviewBranch surface branch-preview children to the UI so it
|
||||
// can render them in a dedicated "Preview environments" panel rather than as
|
||||
// undistinguished stage children. They are computed against the chain's
|
||||
// `self` workload via preview.IsPreviewChild — the canonical "this child is a
|
||||
// branch preview" test that reverses the MaterializeForBranch naming formula.
|
||||
// Both are zero-valued (false / "") for the parent and self nodes and for
|
||||
// operator-created stage children.
|
||||
type chainNode struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SourceKind string `json:"source_kind"`
|
||||
TriggerKind string `json:"trigger_kind"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SourceKind string `json:"source_kind"`
|
||||
TriggerKind string `json:"trigger_kind"`
|
||||
IsPreview bool `json:"is_preview"`
|
||||
PreviewBranch string `json:"preview_branch,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func chainNodeOf(w store.Workload) chainNode {
|
||||
@@ -38,6 +49,32 @@ func chainNodeOf(w store.Workload) chainNode {
|
||||
}
|
||||
}
|
||||
|
||||
// previewBranchOf extracts the branch a preview child was materialized for
|
||||
// from its source_config (the `branch` key MaterializeForBranch wrote).
|
||||
// Returns "" on a missing/malformed config — the caller only calls this for
|
||||
// rows preview.IsPreviewChild already confirmed, so a blank result just means
|
||||
// the JSON couldn't be decoded.
|
||||
func previewBranchOf(w store.Workload) string {
|
||||
var cfg struct {
|
||||
Branch string `json:"branch"`
|
||||
}
|
||||
if w.SourceConfig != "" {
|
||||
_ = json.Unmarshal([]byte(w.SourceConfig), &cfg)
|
||||
}
|
||||
return cfg.Branch
|
||||
}
|
||||
|
||||
// childChainNode builds a chainNode for a child row, marking it as a branch
|
||||
// preview (and attaching its branch) when it was materialized from `self`.
|
||||
func childChainNode(self, child store.Workload) chainNode {
|
||||
node := chainNodeOf(child)
|
||||
if preview.IsPreviewChild(self, child) {
|
||||
node.IsPreview = true
|
||||
node.PreviewBranch = previewBranchOf(child)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
// getWorkloadChain handles GET /api/workloads/{id}/chain.
|
||||
//
|
||||
// Returns the workload's parent (or nil), itself, and its direct children
|
||||
@@ -76,7 +113,7 @@ func (s *Server) getWorkloadChain(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
children := make([]chainNode, 0, len(childRows))
|
||||
for _, c := range childRows {
|
||||
children = append(children, chainNodeOf(c))
|
||||
children = append(children, childChainNode(self, c))
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]any{
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
// TestChildChainNode_MarksPreviewChildren verifies the /chain DTO builder
|
||||
// distinguishes branch-preview children (materialized by the preview package)
|
||||
// from operator-created stage children that merely share the parent link.
|
||||
// The discriminator is preview.IsPreviewChild, which reverses the
|
||||
// MaterializeForBranch naming formula: name == template.Name + "/" + slug.
|
||||
func TestChildChainNode_MarksPreviewChildren(t *testing.T) {
|
||||
template := store.Workload{
|
||||
ID: "tmpl-1",
|
||||
Name: "myapp",
|
||||
SourceKind: "dockerfile",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
child store.Workload
|
||||
wantPrev bool
|
||||
wantBranch string
|
||||
}{
|
||||
{
|
||||
name: "preview child is marked with its branch",
|
||||
child: store.Workload{
|
||||
ID: "child-prev",
|
||||
Name: "myapp/feat-login",
|
||||
SourceKind: "dockerfile",
|
||||
SourceConfig: `{"branch":"feat/login","port":3000}`,
|
||||
ParentWorkloadID: "tmpl-1",
|
||||
},
|
||||
wantPrev: true,
|
||||
wantBranch: "feat/login",
|
||||
},
|
||||
{
|
||||
name: "operator-named stage child sharing the parent is not a preview",
|
||||
child: store.Workload{
|
||||
ID: "child-stage",
|
||||
Name: "myapp-staging",
|
||||
SourceKind: "dockerfile",
|
||||
SourceConfig: `{"branch":"main"}`,
|
||||
ParentWorkloadID: "tmpl-1",
|
||||
},
|
||||
wantPrev: false,
|
||||
wantBranch: "",
|
||||
},
|
||||
{
|
||||
name: "child of a different parent is not a preview of self",
|
||||
child: store.Workload{
|
||||
ID: "child-other",
|
||||
Name: "myapp/feat-login",
|
||||
SourceKind: "dockerfile",
|
||||
SourceConfig: `{"branch":"feat/login"}`,
|
||||
ParentWorkloadID: "some-other-template",
|
||||
},
|
||||
wantPrev: false,
|
||||
wantBranch: "",
|
||||
},
|
||||
{
|
||||
name: "child with no branch in source_config is not a preview",
|
||||
child: store.Workload{
|
||||
ID: "child-nobranch",
|
||||
Name: "myapp/feat-login",
|
||||
SourceKind: "dockerfile",
|
||||
SourceConfig: `{}`,
|
||||
ParentWorkloadID: "tmpl-1",
|
||||
},
|
||||
wantPrev: false,
|
||||
wantBranch: "",
|
||||
},
|
||||
{
|
||||
// Same parent + a valid branch, but the name carries an extra
|
||||
// suffix so it fails ONLY the slug-equality check (expected
|
||||
// "myapp/feat-login", got "myapp/feat-login-staging"). The
|
||||
// branch alone must not be enough to mark a preview.
|
||||
name: "valid branch but name fails the slug match is not a preview",
|
||||
child: store.Workload{
|
||||
ID: "child-slugmiss",
|
||||
Name: "myapp/feat-login-staging",
|
||||
SourceKind: "dockerfile",
|
||||
SourceConfig: `{"branch":"feat/login","port":3000}`,
|
||||
ParentWorkloadID: "tmpl-1",
|
||||
},
|
||||
wantPrev: false,
|
||||
wantBranch: "",
|
||||
},
|
||||
{
|
||||
// Uppercase + slash branch: slugifyBranch lowercases and maps
|
||||
// "/" -> "-", so "Feature/Login" -> "feature-login" and the name
|
||||
// "myapp/feature-login" matches. PreviewBranch must echo the RAW
|
||||
// branch from source_config ("Feature/Login"), not the slug.
|
||||
name: "uppercase slash branch matches and keeps raw branch",
|
||||
child: store.Workload{
|
||||
ID: "child-upper",
|
||||
Name: "myapp/feature-login",
|
||||
SourceKind: "dockerfile",
|
||||
SourceConfig: `{"branch":"Feature/Login","port":8080}`,
|
||||
ParentWorkloadID: "tmpl-1",
|
||||
},
|
||||
wantPrev: true,
|
||||
wantBranch: "Feature/Login",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
node := childChainNode(template, tc.child)
|
||||
if node.IsPreview != tc.wantPrev {
|
||||
t.Errorf("IsPreview = %v, want %v", node.IsPreview, tc.wantPrev)
|
||||
}
|
||||
if node.PreviewBranch != tc.wantBranch {
|
||||
t.Errorf("PreviewBranch = %q, want %q", node.PreviewBranch, tc.wantBranch)
|
||||
}
|
||||
// Base fields must always round-trip regardless of preview status.
|
||||
if node.ID != tc.child.ID || node.Name != tc.child.Name {
|
||||
t.Errorf("base fields mangled: got id=%q name=%q", node.ID, node.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreviewBranchOf_ToleratesMalformedConfig confirms the branch extractor
|
||||
// returns "" rather than panicking on a missing or invalid source_config.
|
||||
func TestPreviewBranchOf_ToleratesMalformedConfig(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg string
|
||||
want string
|
||||
}{
|
||||
{"valid branch", `{"branch":"release/v1"}`, "release/v1"},
|
||||
{"empty config", ``, ""},
|
||||
{"empty object", `{}`, ""},
|
||||
{"malformed json", `{not-json`, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := previewBranchOf(store.Workload{SourceConfig: c.cfg})
|
||||
if got != c.want {
|
||||
t.Errorf("previewBranchOf(%q) = %q, want %q", c.cfg, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/crypto"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
// workloadNotificationRow is the JSON shape returned to clients. The
|
||||
// `secret_set` boolean replaces the actual ciphertext: once stored a
|
||||
// secret is write-only, mirroring how workload_env hides encrypted
|
||||
// values. Rotating means submitting a new value.
|
||||
type workloadNotificationRow struct {
|
||||
ID string `json:"id"`
|
||||
WorkloadID string `json:"workload_id"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
SecretSet bool `json:"secret_set"`
|
||||
EventTypes string `json:"event_types"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func toWorkloadNotificationRow(n store.WorkloadNotification) workloadNotificationRow {
|
||||
return workloadNotificationRow{
|
||||
ID: n.ID,
|
||||
WorkloadID: n.WorkloadID,
|
||||
Name: n.Name,
|
||||
URL: n.URL,
|
||||
SecretSet: n.Secret != "",
|
||||
EventTypes: n.EventTypes,
|
||||
Enabled: n.Enabled,
|
||||
SortOrder: n.SortOrder,
|
||||
CreatedAt: n.CreatedAt,
|
||||
UpdatedAt: n.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) listWorkloadNotifications(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if _, err := s.store.GetWorkloadByID(id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "workload")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "get workload")
|
||||
return
|
||||
}
|
||||
rows, err := s.store.ListWorkloadNotifications(id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "list workload notifications")
|
||||
return
|
||||
}
|
||||
out := make([]workloadNotificationRow, 0, len(rows))
|
||||
for _, n := range rows {
|
||||
out = append(out, toWorkloadNotificationRow(n))
|
||||
}
|
||||
respondJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// workloadNotificationRequest is the POST/PUT body. Secret is the raw
|
||||
// plaintext webhook signing key; the server encrypts it at rest with
|
||||
// the global encryption key before INSERT. An empty Secret on UPDATE
|
||||
// leaves the stored secret untouched so the operator can edit the URL
|
||||
// or event filter without re-entering the secret each time.
|
||||
type workloadNotificationRequest struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Secret string `json:"secret"`
|
||||
EventTypes string `json:"event_types"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
func (s *Server) createWorkloadNotification(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if _, err := s.store.GetWorkloadByID(id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "workload")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "get workload")
|
||||
return
|
||||
}
|
||||
var req workloadNotificationRequest
|
||||
if !decodeJSONStrict(w, r, &req) {
|
||||
return
|
||||
}
|
||||
req.URL = strings.TrimSpace(req.URL)
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.URL == "" {
|
||||
respondError(w, http.StatusBadRequest, "url is required")
|
||||
return
|
||||
}
|
||||
encSecret := ""
|
||||
if req.Secret != "" {
|
||||
v, err := crypto.Encrypt(s.encKey, req.Secret)
|
||||
if err != nil {
|
||||
slog.Error("workload notifications: encrypt secret", "workload", id, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "encrypt secret")
|
||||
return
|
||||
}
|
||||
encSecret = v
|
||||
}
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
created, err := s.store.CreateWorkloadNotification(store.WorkloadNotification{
|
||||
WorkloadID: id,
|
||||
Name: req.Name,
|
||||
URL: req.URL,
|
||||
Secret: encSecret,
|
||||
EventTypes: req.EventTypes,
|
||||
Enabled: enabled,
|
||||
SortOrder: req.SortOrder,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("workload notifications: create", "workload", id, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "create workload notification")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusCreated, toWorkloadNotificationRow(created))
|
||||
}
|
||||
|
||||
func (s *Server) updateWorkloadNotification(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
nid := chi.URLParam(r, "nid")
|
||||
if _, err := s.store.GetWorkloadByID(id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "workload")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "get workload")
|
||||
return
|
||||
}
|
||||
existing, err := s.store.GetWorkloadNotification(nid)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "workload_notification")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "get workload_notification")
|
||||
return
|
||||
}
|
||||
if existing.WorkloadID != id {
|
||||
// Route mismatch — the row exists but under a different workload.
|
||||
// Return 404 rather than 403 so we don't leak the existence of
|
||||
// foreign rows to an unauthorised caller.
|
||||
respondNotFound(w, "workload_notification")
|
||||
return
|
||||
}
|
||||
|
||||
var req workloadNotificationRequest
|
||||
if !decodeJSONStrict(w, r, &req) {
|
||||
return
|
||||
}
|
||||
req.URL = strings.TrimSpace(req.URL)
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.URL == "" {
|
||||
respondError(w, http.StatusBadRequest, "url is required")
|
||||
return
|
||||
}
|
||||
|
||||
existing.Name = req.Name
|
||||
existing.URL = req.URL
|
||||
existing.EventTypes = req.EventTypes
|
||||
existing.SortOrder = req.SortOrder
|
||||
if req.Enabled != nil {
|
||||
existing.Enabled = *req.Enabled
|
||||
}
|
||||
// Empty Secret on UPDATE preserves the stored ciphertext — explicit
|
||||
// rotation requires sending the new plaintext. This avoids forcing
|
||||
// the operator to re-enter their secret on every URL edit.
|
||||
if req.Secret != "" {
|
||||
v, err := crypto.Encrypt(s.encKey, req.Secret)
|
||||
if err != nil {
|
||||
slog.Error("workload notifications: encrypt secret", "workload", id, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "encrypt secret")
|
||||
return
|
||||
}
|
||||
existing.Secret = v
|
||||
}
|
||||
|
||||
if err := s.store.UpdateWorkloadNotification(existing); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "workload_notification")
|
||||
return
|
||||
}
|
||||
slog.Error("workload notifications: update", "workload", id, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "update workload notification")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, toWorkloadNotificationRow(existing))
|
||||
}
|
||||
|
||||
func (s *Server) deleteWorkloadNotification(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
nid := chi.URLParam(r, "nid")
|
||||
existing, err := s.store.GetWorkloadNotification(nid)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "workload_notification")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "get workload_notification")
|
||||
return
|
||||
}
|
||||
if existing.WorkloadID != id {
|
||||
respondNotFound(w, "workload_notification")
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteWorkloadNotification(nid); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "workload_notification")
|
||||
return
|
||||
}
|
||||
slog.Error("workload notifications: delete", "workload", id, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "delete workload notification")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"success": true})
|
||||
}
|
||||
@@ -82,16 +82,27 @@ func (s *Server) getWorkloadRuntimeState(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
payload := runtimeStatePayload{SourceKind: workload.SourceKind}
|
||||
|
||||
if workload.SourceKind != "static" {
|
||||
// Both static and dockerfile sources persist their runtime state into
|
||||
// containers.extra_json under a deterministic row id. The shapes
|
||||
// match (status / last_commit_sha / last_sync_at / last_error) so the
|
||||
// handler can decode them identically. The suffix differs per source
|
||||
// kind: static uses ":site", dockerfile uses ":dockerfile".
|
||||
var rowSuffix string
|
||||
switch workload.SourceKind {
|
||||
case "static":
|
||||
rowSuffix = ":site"
|
||||
case "dockerfile":
|
||||
rowSuffix = ":dockerfile"
|
||||
default:
|
||||
respondJSON(w, http.StatusOK, payload)
|
||||
return
|
||||
}
|
||||
|
||||
// The static plugin owns one container row per workload at the
|
||||
// deterministic ID <workloadID>:site. A missing row means the
|
||||
// workload has never been deployed — return HasState=false so the
|
||||
// UI can prompt the operator to deploy.
|
||||
row, err := s.store.GetContainerByID(id + ":site")
|
||||
// The owning plugin maintains one container row per workload at the
|
||||
// deterministic ID. A missing row means the workload has never been
|
||||
// deployed — return HasState=false so the UI can prompt the operator
|
||||
// to deploy.
|
||||
row, err := s.store.GetContainerByID(id + rowSuffix)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondJSON(w, http.StatusOK, payload)
|
||||
|
||||
@@ -130,6 +130,13 @@ func TestGetWorkloadRuntimeState_MalformedExtraJSON_ReturnsContainerFieldsOnly(t
|
||||
SourceKind: "static",
|
||||
SourceConfig: `{"provider":"gitea"}`,
|
||||
})
|
||||
// Seed a row with a valid extra_json first, then corrupt it via raw
|
||||
// SQL. Prior to the write-side validateExtraJSON guard this test
|
||||
// could pass a malformed string straight to UpsertContainer; the
|
||||
// guard now rejects that at the boundary, which is the correct
|
||||
// behaviour. The reader resilience this test verifies remains
|
||||
// relevant for pre-existing bad rows from upgrades or external
|
||||
// manipulation, so we still produce one via direct SQL.
|
||||
if err := e.store.UpsertContainer(store.Container{
|
||||
ID: wl.ID + ":site",
|
||||
WorkloadID: wl.ID,
|
||||
@@ -137,10 +144,16 @@ func TestGetWorkloadRuntimeState_MalformedExtraJSON_ReturnsContainerFieldsOnly(t
|
||||
Host: "local",
|
||||
ContainerID: "abc",
|
||||
State: "running",
|
||||
ExtraJSON: `{this is not json`,
|
||||
ExtraJSON: `{}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if _, err := e.store.DB().Exec(
|
||||
`UPDATE containers SET extra_json = ? WHERE id = ?`,
|
||||
`{this is not json`, wl.ID+":site",
|
||||
); err != nil {
|
||||
t.Fatalf("corrupt extra_json: %v", err)
|
||||
}
|
||||
resp := e.do(t, http.MethodGet, "/api/workloads/"+wl.ID+"/runtime-state", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (decode is non-fatal)", resp.StatusCode)
|
||||
@@ -155,6 +168,57 @@ func TestGetWorkloadRuntimeState_MalformedExtraJSON_ReturnsContainerFieldsOnly(t
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetWorkloadRuntimeState_DockerfileSourceDeployed_DecodesExtraJSON(t *testing.T) {
|
||||
e := newAPITestEnv(t)
|
||||
wl, err := e.store.CreateWorkload(store.Workload{
|
||||
Kind: string(store.WorkloadKindProject),
|
||||
Name: "build-app",
|
||||
SourceKind: "dockerfile",
|
||||
SourceConfig: `{"provider":"gitea","port":3000}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed workload: %v", err)
|
||||
}
|
||||
extra, _ := json.Marshal(map[string]any{
|
||||
"status": "deployed",
|
||||
"last_commit_sha": "deadbeef",
|
||||
"last_sync_at": "2026-05-23T10:00:00Z",
|
||||
"last_error": "",
|
||||
})
|
||||
if err := e.store.UpsertContainer(store.Container{
|
||||
ID: wl.ID + ":dockerfile",
|
||||
WorkloadID: wl.ID,
|
||||
WorkloadKind: string(store.WorkloadKindBuild),
|
||||
Host: "local",
|
||||
ContainerID: "ffeeddcc",
|
||||
State: "running",
|
||||
ExtraJSON: string(extra),
|
||||
}); err != nil {
|
||||
t.Fatalf("seed container: %v", err)
|
||||
}
|
||||
|
||||
resp := e.do(t, http.MethodGet, "/api/workloads/"+wl.ID+"/runtime-state", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var got runtimeStatePayload
|
||||
if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" {
|
||||
t.Fatalf("envelope error: %q", errMsg)
|
||||
}
|
||||
if !got.HasState {
|
||||
t.Fatalf("HasState = false, want true")
|
||||
}
|
||||
if got.SourceKind != "dockerfile" {
|
||||
t.Errorf("SourceKind = %q, want dockerfile", got.SourceKind)
|
||||
}
|
||||
if got.ContainerID != "ffeeddcc" || got.State != "running" {
|
||||
t.Errorf("container fields = (%q,%q), want (ffeeddcc, running)", got.ContainerID, got.State)
|
||||
}
|
||||
if got.Status != "deployed" || got.LastCommitSHA != "deadbeef" {
|
||||
t.Errorf("runtime fields = %+v, want deployed/deadbeef", got)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GET /api/workloads/{id}/storage
|
||||
// =============================================================================
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/alexei/tinyforge/internal/auth"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
"github.com/alexei/tinyforge/internal/workload/preview"
|
||||
)
|
||||
|
||||
// pluginWorkloadRequest is the JSON body accepted by create + update.
|
||||
@@ -227,6 +228,28 @@ func (s *Server) deletePluginWorkload(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Cascade-teardown any branch previews materialized from this workload
|
||||
// so deleting a template does not orphan their containers, proxy routes,
|
||||
// and rows. Operator-managed stage-chain children (which share the same
|
||||
// parent link) are deliberately left alone — only previews are auto-owned
|
||||
// by the template (see preview.IsPreviewChild).
|
||||
if previews, err := preview.ListPreviewChildren(s.store, row); err != nil {
|
||||
slog.Warn("delete workload: list preview children", "workload", id, "error", err)
|
||||
} else {
|
||||
for _, child := range previews {
|
||||
if child.SourceKind != "" {
|
||||
if err := s.deployer.DispatchTeardown(r.Context(), toPluginWorkload(child)); err != nil {
|
||||
slog.Warn("delete workload: preview child teardown error",
|
||||
"workload", id, "child", child.ID, "error", err)
|
||||
}
|
||||
}
|
||||
if err := s.store.DeleteWorkload(child.ID); err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||
slog.Warn("delete workload: preview child delete error",
|
||||
"workload", id, "child", child.ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if row.SourceKind != "" {
|
||||
if err := s.deployer.DispatchTeardown(r.Context(), toPluginWorkload(row)); err != nil {
|
||||
slog.Warn("delete workload: teardown error",
|
||||
|
||||
Reference in New Issue
Block a user