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:
@@ -12,3 +12,13 @@ Start/restart with: `./scripts/dev-server.sh`
|
||||
## Frontend
|
||||
|
||||
- **Boolean inputs use `ToggleSwitch`** (`$lib/components/ToggleSwitch.svelte`) — the slide-style switch is the unified control across the WebUI. Do not introduce raw `<input type="checkbox">` elements; place a `<ToggleSwitch>` next to a label/help block instead.
|
||||
- **Confirmations & destructive actions use `ConfirmDialog`** (`$lib/components/ConfirmDialog.svelte`) — never native `window.confirm` / `alert`. For navigation guards (e.g. the unsaved-changes prompt on `/apps/new`), `cancel()` the navigation in `beforeNavigate`, open `ConfirmDialog`, and re-issue the navigation with a bypass flag on confirm. Native `beforeunload` is acceptable only for hard tab-close/reload, where the browser forbids custom UI.
|
||||
- **Source-config shape: `$lib/workload/sourceForms.ts`** is the single source of truth (seed/serialize/validity for image/compose/static/dockerfile), consumed by both `/apps/new` and `/apps/[id]`. Don't re-inline seed/serialize logic.
|
||||
- **"App" = workload with `source_kind !== ''`.** Triggers are first-class bindings (`workload_trigger_bindings`), NOT on the workload row — never gate app lists/counts on `trigger_kind` (it's empty for plugin workloads). Legacy pre-cutover `kind:project/stack/site` rows have an empty `source_kind` and must be excluded everywhere.
|
||||
- **i18n parity is mandatory** — every key in BOTH `web/src/lib/i18n/{en,ru}.json`. A missing key is NOT a build error (`$t` returns the key string), so verify parity manually.
|
||||
|
||||
## Build & Test
|
||||
|
||||
- Frontend (from `web/`): `npm run check` (svelte-check — expect 0 errors), `npm run build`, `npm run test` (vitest; pure-logic units like `sourceForms.test.ts`).
|
||||
- Backend (repo root): `go build ./...`, `go vet ./internal/...`, `go test ./internal/...`.
|
||||
- `./scripts/dev-server.sh` rebuilds the SPA + restarts the Go server on :8090; it kills the prior process, so a previous background dev-server task reporting **exit 1 is expected**, not a failure.
|
||||
|
||||
@@ -11,6 +11,15 @@ Self-hosted deployment platform with a web dashboard. Deploy Docker containers f
|
||||
- **Multi-stage projects** (dev, staging, prod) with tag pattern matching
|
||||
- **Real-time deploy logs** via SSE streaming
|
||||
|
||||
### Branch Preview Environments
|
||||
|
||||
Get an isolated, throwaway deploy for every feature branch:
|
||||
|
||||
- Add a **branch pattern** (e.g. `feat/*`) to a workload's **git trigger** (Triggers panel → git trigger → *Branch pattern*).
|
||||
- Pushing to any branch matching the pattern deploys an **isolated per-branch preview** — a child workload that inherits the source config, served at a **slug-prefixed subdomain** (`feat-login-app.example.com`) so previews never collide with each other or the main deploy.
|
||||
- Previews are **automatically torn down** when the branch is deleted upstream.
|
||||
- Manage live previews from the app's **Preview environments** panel (`/apps/[id]`): open each branch's URL or tear it down manually. A torn-down preview is recreated on the next push to its branch.
|
||||
|
||||
### Static Sites
|
||||
|
||||
Deploy static sites and Deno-powered APIs directly from Git repositories:
|
||||
|
||||
+32
-1
@@ -43,6 +43,7 @@ import (
|
||||
// itself with internal/workload/plugin. Adding a new Source or Trigger
|
||||
// is a matter of dropping a new package and adding it to this list.
|
||||
_ "github.com/alexei/tinyforge/internal/workload/plugin/source/compose"
|
||||
_ "github.com/alexei/tinyforge/internal/workload/plugin/source/dockerfile"
|
||||
_ "github.com/alexei/tinyforge/internal/workload/plugin/source/image"
|
||||
_ "github.com/alexei/tinyforge/internal/workload/plugin/source/static"
|
||||
_ "github.com/alexei/tinyforge/internal/workload/plugin/trigger/git"
|
||||
@@ -62,6 +63,20 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Acquire single-instance lockfile BEFORE opening the DB. SQLite +
|
||||
// SetMaxOpenConns(1) does not protect against two Tinyforge processes
|
||||
// sharing a data directory; without this guard a misconfigured
|
||||
// systemd unit, container restart race, or `tinyforge` shell typo can
|
||||
// silently double-fire schedulers, double-poll registries, and
|
||||
// corrupt `extra_json` RMW. The lockfile is a PID file under
|
||||
// $DATA_DIR/tinyforge.lock — collisions with dead PIDs are reclaimed.
|
||||
releaseLock, err := store.AcquireLockfile(dataDir)
|
||||
if err != nil {
|
||||
slog.Error("could not acquire data-dir lock", "data_dir", dataDir, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer releaseLock()
|
||||
|
||||
// Open database.
|
||||
dbPath := filepath.Join(dataDir, "tinyforge.db")
|
||||
db, err := store.New(dbPath)
|
||||
@@ -78,6 +93,21 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// One-shot migration: rewrite every legacy unprefixed-hex secret
|
||||
// in the DB into the new tf1: envelope form. Idempotent (gated by
|
||||
// schema_versions version 2). Lets the rest of the codebase treat
|
||||
// envelope-presence as a stable invariant for future key rotations.
|
||||
// Failures here are logged but non-fatal: a partial migration just
|
||||
// means some columns keep working through Decrypt's legacy
|
||||
// fallback until the next manual save re-encrypts them.
|
||||
if err := db.MigrateSecretsToEnvelope(store.EnvelopeMigrator{
|
||||
HasEnvelope: crypto.HasEnvelope,
|
||||
Decrypt: func(v string) (string, error) { return crypto.Decrypt(encKey, v) },
|
||||
Encrypt: func(v string) (string, error) { return crypto.Encrypt(encKey, v) },
|
||||
}); err != nil {
|
||||
slog.Warn("secrets envelope migration", "error", err)
|
||||
}
|
||||
|
||||
// Import seed config on first launch (idempotent).
|
||||
seedPath := envOrDefault("SEED_FILE", "./tinyforge.yaml")
|
||||
if err := config.ImportSeed(db, seedPath); err != nil {
|
||||
@@ -197,7 +227,8 @@ func main() {
|
||||
switch {
|
||||
case r.Deployed:
|
||||
deployed++
|
||||
case r.Reason == webhook.ReasonBindingDisabled, r.Reason == webhook.ReasonNoMatch:
|
||||
case r.Reason == webhook.ReasonBindingDisabled, r.Reason == webhook.ReasonNoMatch,
|
||||
r.Reason == webhook.ReasonPreviewNoop:
|
||||
// not a failure — silent
|
||||
default:
|
||||
errored++
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -85,9 +85,15 @@ func (la *LocalAuth) cleanBlacklist() {
|
||||
}
|
||||
}
|
||||
|
||||
// bcryptCost is the work factor used for new password hashes. Bumped from
|
||||
// the library default (10) to 12 so cost grows with hardware. Existing
|
||||
// hashes at lower costs still verify — bcrypt encodes the cost in the
|
||||
// stored hash itself.
|
||||
const bcryptCost = 12
|
||||
|
||||
// HashPassword hashes a plaintext password using bcrypt.
|
||||
func HashPassword(password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite" // read-only candidate inspection via PRAGMA integrity_check
|
||||
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
@@ -129,6 +133,17 @@ func (e *Engine) RestorePath(id string) (string, error) {
|
||||
return "", fmt.Errorf("get backup: %w", err)
|
||||
}
|
||||
|
||||
// Filename comes from a DB row. Defence-in-depth: a backup file must live
|
||||
// directly under backupDir, so reject any value carrying a path separator
|
||||
// or traversal before joining. A poisoned row (future import path, manual
|
||||
// insert) must never let restore read — and then atomically copy over the
|
||||
// live DB — an arbitrary file. CreateBackup builds safe base names; this
|
||||
// enforces the same invariant on read.
|
||||
if backup.Filename == "" || backup.Filename == "." || backup.Filename == ".." ||
|
||||
backup.Filename != filepath.Base(backup.Filename) {
|
||||
return "", fmt.Errorf("backup: invalid filename %q", backup.Filename)
|
||||
}
|
||||
|
||||
filePath := filepath.Join(e.backupDir, backup.Filename)
|
||||
if _, err := os.Stat(filePath); err != nil {
|
||||
return "", fmt.Errorf("backup file not found: %w", err)
|
||||
@@ -137,6 +152,153 @@ func (e *Engine) RestorePath(id string) (string, error) {
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
// PrepareRestore validates a backup candidate before the caller swaps it
|
||||
// over the live DB. Runs three checks in order:
|
||||
//
|
||||
// 1. The candidate file exists and is non-empty.
|
||||
// 2. SQLite header magic matches (catches corrupted or partial downloads).
|
||||
// 3. `PRAGMA integrity_check` against a temp copy returns "ok"
|
||||
// (catches WAL/page corruption that the header check misses).
|
||||
//
|
||||
// On success returns the candidate path. On failure returns a wrapped
|
||||
// error describing which probe rejected the file, so the operator can
|
||||
// see exactly why a "restore" was refused rather than getting a corrupt
|
||||
// DB at next boot.
|
||||
//
|
||||
// We use a *temp copy* for integrity_check because attaching the
|
||||
// candidate read-only into the live process would still hold a file
|
||||
// handle SQLite considers writable on Windows.
|
||||
func (e *Engine) PrepareRestore(id string) (string, error) {
|
||||
path, err := e.RestorePath(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("restore: stat candidate: %w", err)
|
||||
}
|
||||
if info.Size() < 100 {
|
||||
return "", fmt.Errorf("restore: candidate %s is suspiciously small (%d bytes)", path, info.Size())
|
||||
}
|
||||
|
||||
// SQLite file header: "SQLite format 3\x00" (16 bytes).
|
||||
hdr, err := readHead(path, 16)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("restore: read header: %w", err)
|
||||
}
|
||||
if string(hdr) != "SQLite format 3\x00" {
|
||||
return "", fmt.Errorf("restore: candidate %s is not a SQLite database (header mismatch)", path)
|
||||
}
|
||||
|
||||
if err := integrityCheck(path); err != nil {
|
||||
return "", fmt.Errorf("restore: integrity check failed: %w", err)
|
||||
}
|
||||
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func readHead(path string, n int) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
buf := make([]byte, n)
|
||||
// io.ReadFull (not f.Read) guarantees the buffer is filled.
|
||||
// A bare Read can short-return on some filesystems / on small
|
||||
// files, which would skew the SQLite-header magic check below.
|
||||
if _, err := io.ReadFull(f, buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// integrityCheck opens the candidate read-only and runs
|
||||
// `PRAGMA integrity_check`. We use immutable=1 so the driver does not
|
||||
// try to create WAL/SHM sidecars or upgrade the journal mode on the
|
||||
// candidate — both of which fail with "attempt to write a readonly
|
||||
// database" against a backup file. Anything other than the single row
|
||||
// `"ok"` is treated as corruption.
|
||||
func integrityCheck(path string) error {
|
||||
db, err := sql.Open("sqlite", "file:"+path+"?mode=ro&immutable=1")
|
||||
if err != nil {
|
||||
return fmt.Errorf("open candidate: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
rows, err := db.Query("PRAGMA integrity_check")
|
||||
if err != nil {
|
||||
return fmt.Errorf("pragma integrity_check: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
if !rows.Next() {
|
||||
return fmt.Errorf("integrity_check returned no rows")
|
||||
}
|
||||
var result string
|
||||
if err := rows.Scan(&result); err != nil {
|
||||
return fmt.Errorf("scan integrity_check: %w", err)
|
||||
}
|
||||
if result != "ok" {
|
||||
return fmt.Errorf("integrity_check: %s", result)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AtomicReplaceDB writes a backup candidate into place atomically.
|
||||
// The caller is expected to:
|
||||
// 1. Call PrepareRestore(id) → candidatePath.
|
||||
// 2. Take a "pre-restore" backup of the current DB via CreateBackup.
|
||||
// 3. Close the live *sql.DB.
|
||||
// 4. Call AtomicReplaceDB(candidatePath, livePath).
|
||||
// 5. Trigger graceful shutdown; main() will re-open on next start.
|
||||
//
|
||||
// AtomicReplaceDB also wipes WAL/SHM sidecar files so the new DB starts
|
||||
// from a clean checkpoint state. Failure to remove sidecars is logged
|
||||
// but non-fatal — SQLite recreates them on open.
|
||||
func (e *Engine) AtomicReplaceDB(candidatePath, livePath string) error {
|
||||
// Copy candidate to a tmp file next to the live DB, then rename
|
||||
// atomically. On Windows os.Rename across volumes fails, so we
|
||||
// keep tmp on the same dir as the destination.
|
||||
tmp := livePath + ".restore.tmp"
|
||||
if err := copyFile(candidatePath, tmp); err != nil {
|
||||
return fmt.Errorf("copy candidate to %s: %w", tmp, err)
|
||||
}
|
||||
// Best-effort: remove WAL/SHM so SQLite re-checkpoints from the
|
||||
// restored main file rather than a stale WAL pointing at the old
|
||||
// DB's pages.
|
||||
for _, sidecar := range []string{livePath + "-wal", livePath + "-shm"} {
|
||||
if err := os.Remove(sidecar); err != nil && !os.IsNotExist(err) {
|
||||
slog.Warn("restore: remove sidecar", "path", sidecar, "error", err)
|
||||
}
|
||||
}
|
||||
if err := os.Rename(tmp, livePath); err != nil {
|
||||
// Clean up tmp on rename failure so we don't leak a partial file.
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("rename %s → %s: %w", tmp, livePath, err)
|
||||
}
|
||||
slog.Info("restore: database file replaced atomically", "live", livePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
_ = out.Close()
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
// Prune removes old backups exceeding the retention count.
|
||||
// Returns the number of backups pruned.
|
||||
func (e *Engine) Prune(retentionCount int) (int, error) {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
// newTestEngine spins up an isolated store + engine pair for tests.
|
||||
// Each test gets its own tempdir so backup files do not collide.
|
||||
func newTestEngine(t *testing.T) (*Engine, *store.Store, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "tinyforge.db")
|
||||
st, err := store.New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("store.New: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
eng, err := New(st, dbPath, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("backup.New: %v", err)
|
||||
}
|
||||
return eng, st, dbPath
|
||||
}
|
||||
|
||||
func TestPrepareRestore_RejectsTinyFile(t *testing.T) {
|
||||
eng, st, _ := newTestEngine(t)
|
||||
|
||||
// Plant a backup row with a tiny file masquerading as a backup.
|
||||
tinyPath := filepath.Join(eng.BackupDir(), "tinyforge-manual-junk.db")
|
||||
if err := os.WriteFile(tinyPath, []byte("hi"), 0o600); err != nil {
|
||||
t.Fatalf("write tiny: %v", err)
|
||||
}
|
||||
bk, err := st.CreateBackup(store.Backup{
|
||||
Filename: "tinyforge-manual-junk.db",
|
||||
SizeBytes: 2,
|
||||
BackupType: "manual",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBackup row: %v", err)
|
||||
}
|
||||
|
||||
if _, err := eng.PrepareRestore(bk.ID); err == nil {
|
||||
t.Fatal("expected PrepareRestore to reject tiny file, got nil")
|
||||
} else if !strings.Contains(err.Error(), "suspiciously small") {
|
||||
t.Errorf("error = %v, want 'suspiciously small'", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareRestore_RejectsNonSQLite(t *testing.T) {
|
||||
eng, st, _ := newTestEngine(t)
|
||||
|
||||
// 200 bytes of non-SQLite garbage: passes the size check, fails
|
||||
// the header magic check.
|
||||
garbagePath := filepath.Join(eng.BackupDir(), "tinyforge-manual-bogus.db")
|
||||
junk := make([]byte, 200)
|
||||
for i := range junk {
|
||||
junk[i] = byte('x')
|
||||
}
|
||||
if err := os.WriteFile(garbagePath, junk, 0o600); err != nil {
|
||||
t.Fatalf("write junk: %v", err)
|
||||
}
|
||||
bk, err := st.CreateBackup(store.Backup{
|
||||
Filename: "tinyforge-manual-bogus.db",
|
||||
SizeBytes: int64(len(junk)),
|
||||
BackupType: "manual",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBackup row: %v", err)
|
||||
}
|
||||
|
||||
if _, err := eng.PrepareRestore(bk.ID); err == nil {
|
||||
t.Fatal("expected PrepareRestore to reject non-SQLite blob, got nil")
|
||||
} else if !strings.Contains(err.Error(), "header") {
|
||||
t.Errorf("error = %v, want header mismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareRestore_AcceptsValidVacuumInto(t *testing.T) {
|
||||
eng, _, _ := newTestEngine(t)
|
||||
|
||||
// A fresh CreateBackup from the engine itself is, by construction,
|
||||
// a valid SQLite database — VACUUM INTO produces a clean copy.
|
||||
bk, err := eng.CreateBackup("manual")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBackup: %v", err)
|
||||
}
|
||||
path, err := eng.PrepareRestore(bk.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareRestore on valid backup: %v", err)
|
||||
}
|
||||
if path == "" {
|
||||
t.Errorf("PrepareRestore returned empty path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareRestore_UnknownID(t *testing.T) {
|
||||
eng, _, _ := newTestEngine(t)
|
||||
|
||||
_, err := eng.PrepareRestore("nonexistent-id")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown id, got nil")
|
||||
}
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
// fine — wrapped through RestorePath
|
||||
}
|
||||
}
|
||||
+46
-10
@@ -10,11 +10,26 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrNoKey is returned when ENCRYPTION_KEY is not set.
|
||||
var ErrNoKey = errors.New("ENCRYPTION_KEY environment variable is not set")
|
||||
|
||||
// ErrDecryptFailed wraps any cipher.Open / decoder failure. Callers
|
||||
// upgrading from the silent-fallback pattern (treat-as-plaintext when
|
||||
// decrypt errored) MUST instead surface this — a rotated key would
|
||||
// otherwise silently leak ciphertext to upstream services as if it
|
||||
// were plaintext.
|
||||
var ErrDecryptFailed = errors.New("crypto: decrypt failed (wrong key, corrupted ciphertext, or unversioned legacy value)")
|
||||
|
||||
// envelopeV1Prefix tags ciphertext produced by Encrypt going forward.
|
||||
// Older databases may carry unprefixed hex blobs from the v0 era; those
|
||||
// are still readable via Decrypt for backward compatibility, but every
|
||||
// new write goes through EncryptV1 and emits the prefix so a future key
|
||||
// rotation has a clean fail-loud signal.
|
||||
const envelopeV1Prefix = "tf1:"
|
||||
|
||||
// DeriveKey computes a 32-byte AES-256 key from the given passphrase using SHA-256.
|
||||
// This is acceptable when ENCRYPTION_KEY is a high-entropy random string (e.g., 32+ hex chars).
|
||||
// For human-chosen passphrases, consider Argon2id or PBKDF2 with a salt instead.
|
||||
@@ -35,7 +50,8 @@ func KeyFromEnv() ([32]byte, error) {
|
||||
}
|
||||
|
||||
// Encrypt encrypts plaintext using AES-256-GCM with a random nonce.
|
||||
// The returned ciphertext is hex-encoded: nonce || ciphertext+tag.
|
||||
// Returns a versioned envelope (tf1:<hex>) so downstream readers can
|
||||
// distinguish ciphertext from accidentally-stored plaintext.
|
||||
func Encrypt(key [32]byte, plaintext string) (string, error) {
|
||||
block, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
@@ -53,14 +69,34 @@ func Encrypt(key [32]byte, plaintext string) (string, error) {
|
||||
}
|
||||
|
||||
sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
return hex.EncodeToString(sealed), nil
|
||||
return envelopeV1Prefix + hex.EncodeToString(sealed), nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts a hex-encoded ciphertext produced by Encrypt.
|
||||
func Decrypt(key [32]byte, ciphertextHex string) (string, error) {
|
||||
data, err := hex.DecodeString(ciphertextHex)
|
||||
// HasEnvelope reports whether the value is a v1-prefixed ciphertext.
|
||||
// Useful for router-level "decrypt only if encrypted" decision points
|
||||
// that previously relied on `err == nil` from a try-decrypt — that
|
||||
// pattern silently masked rotated-key failures.
|
||||
func HasEnvelope(value string) bool {
|
||||
return strings.HasPrefix(value, envelopeV1Prefix)
|
||||
}
|
||||
|
||||
// Decrypt decrypts an envelope (tf1:<hex>). For backward compatibility
|
||||
// it also accepts unprefixed hex from the v0 era — but only when the
|
||||
// resulting plaintext is valid; a wrong key for legacy data now returns
|
||||
// ErrDecryptFailed instead of silently treating ciphertext as
|
||||
// plaintext.
|
||||
//
|
||||
// Callers MUST NOT swallow the error and fall back to "use as-is".
|
||||
// That pattern is the exact footgun the envelope versioning removes.
|
||||
func Decrypt(key [32]byte, ciphertext string) (string, error) {
|
||||
hexBlob := ciphertext
|
||||
if strings.HasPrefix(hexBlob, envelopeV1Prefix) {
|
||||
hexBlob = hexBlob[len(envelopeV1Prefix):]
|
||||
}
|
||||
|
||||
data, err := hex.DecodeString(hexBlob)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode hex: %w", err)
|
||||
return "", fmt.Errorf("%w: decode hex: %v", ErrDecryptFailed, err)
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key[:])
|
||||
@@ -75,15 +111,15 @@ func Decrypt(key [32]byte, ciphertextHex string) (string, error) {
|
||||
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(data) < nonceSize {
|
||||
return "", errors.New("ciphertext too short")
|
||||
return "", fmt.Errorf("%w: ciphertext too short", ErrDecryptFailed)
|
||||
}
|
||||
|
||||
nonce := data[:nonceSize]
|
||||
ciphertext := data[nonceSize:]
|
||||
body := data[nonceSize:]
|
||||
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
plaintext, err := gcm.Open(nil, nonce, body, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decrypt: %w", err)
|
||||
return "", fmt.Errorf("%w: %v", ErrDecryptFailed, err)
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
|
||||
@@ -34,7 +34,19 @@ type Deployer struct {
|
||||
dnsMu sync.RWMutex
|
||||
dns dns.Provider // nil when wildcard DNS is active
|
||||
|
||||
// proxyMu protects hot-swap of d.proxy from runtime settings updates
|
||||
// (SetProxyProvider) racing with PluginDeps() reads on the deploy path.
|
||||
proxyMu sync.RWMutex
|
||||
|
||||
// Graceful shutdown: tracks in-progress deploys.
|
||||
//
|
||||
// drainMu serializes the "is-draining check + activeWg.Add(1)" in
|
||||
// beginDispatch against the "set shuttingDown + Wait()" in Drain. Without
|
||||
// it, a dispatch could pass the draining check, Drain could then flip the
|
||||
// flag and start Wait() with a zero counter, and the dispatch could call
|
||||
// Add(1) concurrently with Wait — a documented sync.WaitGroup misuse
|
||||
// (panic risk) that also lets a deploy slip past the drain barrier.
|
||||
drainMu sync.Mutex
|
||||
activeWg sync.WaitGroup
|
||||
shuttingDown atomic.Bool
|
||||
}
|
||||
@@ -73,7 +85,11 @@ func New(
|
||||
}
|
||||
|
||||
// SetProxyProvider updates the proxy provider at runtime (e.g., when settings change).
|
||||
// Guarded by proxyMu so concurrent deploys that read d.proxy via PluginDeps()
|
||||
// observe a coherent value (previously a torn-pointer race under -race).
|
||||
func (d *Deployer) SetProxyProvider(provider proxy.Provider) {
|
||||
d.proxyMu.Lock()
|
||||
defer d.proxyMu.Unlock()
|
||||
d.proxy = provider
|
||||
}
|
||||
|
||||
@@ -110,8 +126,11 @@ func (d *Deployer) SetDNSProvider(provider dns.Provider) {
|
||||
|
||||
// Drain waits for all in-progress deploys to complete. Call this during graceful shutdown.
|
||||
func (d *Deployer) Drain() {
|
||||
if !d.shuttingDown.CompareAndSwap(false, true) {
|
||||
// Already draining.
|
||||
d.drainMu.Lock()
|
||||
already := d.shuttingDown.Swap(true)
|
||||
d.drainMu.Unlock()
|
||||
if already {
|
||||
slog.Info("deployer: drain already in progress")
|
||||
}
|
||||
slog.Info("deployer: draining in-progress deploys")
|
||||
d.activeWg.Wait()
|
||||
@@ -121,11 +140,17 @@ func (d *Deployer) Drain() {
|
||||
// ShuttingDown reports whether Drain() has been called.
|
||||
func (d *Deployer) ShuttingDown() bool { return d.shuttingDown.Load() }
|
||||
|
||||
// rejectIfDraining is exposed in case any plugin wants the same hard-stop
|
||||
// behaviour the legacy pipeline used.
|
||||
func (d *Deployer) rejectIfDraining() error {
|
||||
// beginDispatch atomically rejects when draining and otherwise registers the
|
||||
// in-flight unit on activeWg. The shuttingDown check and the Add(1) MUST be
|
||||
// done together under drainMu (see the field comment): Drain sets the flag
|
||||
// under the same mutex before Wait(), so once Wait() observes a zero counter
|
||||
// no further Add can race it. Callers must defer d.activeWg.Done() on success.
|
||||
func (d *Deployer) beginDispatch() error {
|
||||
d.drainMu.Lock()
|
||||
defer d.drainMu.Unlock()
|
||||
if d.shuttingDown.Load() {
|
||||
return fmt.Errorf("deployer is shutting down, rejecting new deploy")
|
||||
}
|
||||
d.activeWg.Add(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/metrics"
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
)
|
||||
|
||||
@@ -14,16 +15,37 @@ import (
|
||||
// triggers + image deploys still go through the legacy path, while
|
||||
// /api/hooks/generic + the unified webhook ingress go through here.
|
||||
func (d *Deployer) DispatchPlugin(ctx context.Context, w plugin.Workload, intent plugin.DeploymentIntent) error {
|
||||
if err := d.beginDispatch(); err != nil {
|
||||
metrics.DeploysTotal.Inc(w.SourceKind, "rejected_draining")
|
||||
return err
|
||||
}
|
||||
defer d.activeWg.Done()
|
||||
src, err := plugin.GetSource(w.SourceKind)
|
||||
if err != nil {
|
||||
// Unknown source: use the constant "unknown" sentinel for the
|
||||
// label so a typo-spam attack can't grow the metrics map with
|
||||
// one series per bogus source_kind. The actual user-supplied
|
||||
// value still surfaces via the wrapped error / event log.
|
||||
metrics.DeploysTotal.Inc("unknown", "unknown_source")
|
||||
return fmt.Errorf("dispatch %s: %w", w.Name, err)
|
||||
}
|
||||
return src.Deploy(ctx, d.PluginDeps(), w, intent)
|
||||
err = src.Deploy(ctx, d.PluginDeps(), w, intent)
|
||||
outcome := "success"
|
||||
if err != nil {
|
||||
outcome = "failure"
|
||||
}
|
||||
metrics.DeploysTotal.Inc(w.SourceKind, outcome)
|
||||
return err
|
||||
}
|
||||
|
||||
// DispatchTeardown routes a teardown call to the matching Source plugin.
|
||||
// Used when a workload is deleted.
|
||||
// Used when a workload is deleted. Tracked via activeWg so Drain() honours
|
||||
// in-progress teardowns just like deploys.
|
||||
func (d *Deployer) DispatchTeardown(ctx context.Context, w plugin.Workload) error {
|
||||
if err := d.beginDispatch(); err != nil {
|
||||
return err
|
||||
}
|
||||
defer d.activeWg.Done()
|
||||
src, err := plugin.GetSource(w.SourceKind)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dispatch teardown %s: %w", w.Name, err)
|
||||
@@ -33,8 +55,17 @@ func (d *Deployer) DispatchTeardown(ctx context.Context, w plugin.Workload) erro
|
||||
|
||||
// DispatchReconcile routes a Reconcile call. Periodic reconciler iterates
|
||||
// every Workload and calls this; idle Sources should make it a cheap
|
||||
// no-op.
|
||||
// no-op. Tracked via activeWg so a long-running reconcile blocks Drain().
|
||||
func (d *Deployer) DispatchReconcile(ctx context.Context, w plugin.Workload) error {
|
||||
if err := d.beginDispatch(); err != nil {
|
||||
// Silent skip — reconcile is a periodic tick, not a user-initiated
|
||||
// action, so we don't want to surface "draining" errors back to the
|
||||
// reconciler loop. The next tick after restart will catch up. Routing
|
||||
// through beginDispatch keeps the activeWg.Add atomic with the drain
|
||||
// check (see Drain) instead of a bare shuttingDown.Load + Add race.
|
||||
return nil
|
||||
}
|
||||
defer d.activeWg.Done()
|
||||
src, err := plugin.GetSource(w.SourceKind)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dispatch reconcile %s: %w", w.Name, err)
|
||||
@@ -52,10 +83,13 @@ func (d *Deployer) PluginDeps() plugin.Deps {
|
||||
d.dnsMu.RLock()
|
||||
dnsProvider := d.dns
|
||||
d.dnsMu.RUnlock()
|
||||
d.proxyMu.RLock()
|
||||
proxyProvider := d.proxy
|
||||
d.proxyMu.RUnlock()
|
||||
return plugin.Deps{
|
||||
Store: d.store,
|
||||
Docker: d.docker,
|
||||
Proxy: d.proxy,
|
||||
Proxy: proxyProvider,
|
||||
DNS: dnsProvider,
|
||||
Health: d.health,
|
||||
Notifier: d.notifier,
|
||||
|
||||
+119
-20
@@ -2,20 +2,58 @@ package docker
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/moby/moby/api/types/build"
|
||||
"github.com/moby/moby/client"
|
||||
)
|
||||
|
||||
// BuildImage builds a Docker image from a directory containing a Dockerfile.
|
||||
// The directory is packaged as a tar archive and sent to the Docker daemon.
|
||||
// The tag parameter is the image name:tag to apply (e.g., "dw-site-myapp:latest").
|
||||
// BuildImage builds a Docker image from a directory containing a Dockerfile
|
||||
// at the context root. Kept as a thin wrapper around BuildImageAt for the
|
||||
// static-site plugin which always emits its generated Dockerfile at the
|
||||
// context root. New code should prefer BuildImageAt so the Dockerfile path
|
||||
// is explicit.
|
||||
func (c *Client) BuildImage(ctx context.Context, contextDir, tag string) error {
|
||||
return c.BuildImageAt(ctx, contextDir, "Dockerfile", tag, nil)
|
||||
}
|
||||
|
||||
// BuildImageAt builds a Docker image from a tar of contextDir, using the
|
||||
// Dockerfile at `dockerfile` *inside* the context (typically "Dockerfile"
|
||||
// but may be e.g. "docker/Dockerfile" when the user-supplied repo layout
|
||||
// keeps Dockerfiles in a subfolder).
|
||||
//
|
||||
// The dockerfile argument is the path *relative to contextDir*. Empty
|
||||
// strings are normalised to "Dockerfile" so callers can pass through a
|
||||
// user config value without sanitising twice.
|
||||
//
|
||||
// logFn, if non-nil, is invoked for every non-empty `stream` line the
|
||||
// daemon emits during the build. Callers use this to forward live build
|
||||
// progress (e.g. SSE bus). Errors from the daemon are NOT delivered via
|
||||
// logFn — they surface as the returned error so the caller's failure
|
||||
// path stays the single source of truth.
|
||||
func (c *Client) BuildImageAt(ctx context.Context, contextDir, dockerfile, tag string, logFn func(line string)) error {
|
||||
if dockerfile == "" {
|
||||
dockerfile = "Dockerfile"
|
||||
}
|
||||
// Normalise to forward slashes — the tar entry names use them and the
|
||||
// Docker daemon expects the same.
|
||||
dockerfile = filepath.ToSlash(dockerfile)
|
||||
// Defence-in-depth: the dockerfile path is relative to contextDir and
|
||||
// is increasingly user/config-supplied (subfolder Dockerfiles). Reject
|
||||
// absolute paths and any `..` traversal at the boundary so a value like
|
||||
// "../../etc/passwd" can never be handed to the daemon's build options,
|
||||
// regardless of which builder backend resolves it.
|
||||
if filepath.IsAbs(dockerfile) || strings.HasPrefix(dockerfile, "/") ||
|
||||
dockerfile == ".." || strings.HasPrefix(dockerfile, "../") || strings.Contains(dockerfile, "/../") {
|
||||
return fmt.Errorf("docker build: invalid dockerfile path %q (must be relative to the build context, no traversal)", dockerfile)
|
||||
}
|
||||
// Create tar archive of the build context.
|
||||
pr, pw := io.Pipe()
|
||||
|
||||
@@ -50,16 +88,14 @@ func (c *Client) BuildImage(ctx context.Context, contextDir, tag string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", path, err)
|
||||
// Per-file close, NOT defer. `defer file.Close()` inside the
|
||||
// WalkFunc only runs when the outer goroutine returns — for a
|
||||
// build context with thousands of files (node_modules-heavy
|
||||
// repo) that leaks one fd per file until the walk completes
|
||||
// and trips EMFILE on default ulimit=1024 systems.
|
||||
if err := streamFileIntoTar(tw, path, relPath); err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if _, err := io.Copy(tw, file); err != nil {
|
||||
return fmt.Errorf("copy %s to tar: %w", relPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -69,8 +105,16 @@ func (c *Client) BuildImage(ctx context.Context, contextDir, tag string) error {
|
||||
pw.CloseWithError(err)
|
||||
}()
|
||||
|
||||
// Pin the legacy builder explicitly. On Docker Engine 23+ BuildKit
|
||||
// is the default for the CLI, but the daemon honours the explicit
|
||||
// Version field on ImageBuildOptions. Legacy builder does NOT support
|
||||
// `RUN --mount=type=bind,source=/host` so a malicious Dockerfile
|
||||
// cannot mount host paths into the build context. Switching to
|
||||
// BuildKit later requires (a) Dockerfile-content validation to
|
||||
// reject bind-mount hints, or (b) an explicit per-workload opt-in.
|
||||
resp, err := c.api.ImageBuild(ctx, pr, client.ImageBuildOptions{
|
||||
Dockerfile: "Dockerfile",
|
||||
Version: build.BuilderV1,
|
||||
Dockerfile: dockerfile,
|
||||
Tags: []string{tag},
|
||||
Remove: true,
|
||||
ForceRemove: true,
|
||||
@@ -80,16 +124,71 @@ func (c *Client) BuildImage(ctx context.Context, contextDir, tag string) error {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read the build output to completion (required for the build to finish).
|
||||
output, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
// Drain the daemon's NDJSON stream to completion. The stream MUST
|
||||
// be read for the build to finish — closing the body early aborts
|
||||
// the build. We parse line-by-line into the {Stream, Error} shape
|
||||
// the daemon emits so an honest `{"error":"..."}` line surfaces
|
||||
// without false positives from informational `{"stream":"error
|
||||
// handling: retrying..."}` chatter that the old strings.Contains
|
||||
// path would have flagged.
|
||||
type buildLine struct {
|
||||
Stream string `json:"stream,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
// Some build steps emit single lines exceeding the default 64 KiB
|
||||
// (e.g. a fat go-mod-download dump). Bump to 1 MiB so we don't
|
||||
// silently truncate and miss the trailing error line.
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
var firstErr string
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var bl buildLine
|
||||
if err := json.Unmarshal(line, &bl); err != nil {
|
||||
// Non-JSON line — daemon shouldn't produce these, but
|
||||
// don't fail the build over a parse hiccup.
|
||||
continue
|
||||
}
|
||||
if bl.Error != "" && firstErr == "" {
|
||||
firstErr = bl.Error
|
||||
}
|
||||
if logFn != nil && bl.Stream != "" {
|
||||
logFn(bl.Stream)
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return fmt.Errorf("read build output for %s: %w", tag, err)
|
||||
}
|
||||
|
||||
// Check for error in build output.
|
||||
if strings.Contains(string(output), `"error"`) {
|
||||
return fmt.Errorf("build image %s: build errors in output", tag)
|
||||
if firstErr != "" {
|
||||
return fmt.Errorf("build image %s: %s", tag, firstErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// streamFileIntoTar opens path, copies its contents into the tar writer
|
||||
// under the given relPath header, and closes the file *before returning*
|
||||
// — i.e. once per file, not deferred to the end of the entire walk.
|
||||
// Extracted so the per-iteration close discipline is obvious at the
|
||||
// callsite and the file handle isn't accidentally hoisted into the
|
||||
// caller's defer stack via a future refactor.
|
||||
func streamFileIntoTar(tw *tar.Writer, path, relPath string) error {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", path, err)
|
||||
}
|
||||
_, copyErr := io.Copy(tw, file)
|
||||
// Close BEFORE returning so the fd is released even on copy
|
||||
// failure. Capture both errors so the more-specific copy error
|
||||
// wins when both fire.
|
||||
if cerr := file.Close(); cerr != nil && copyErr == nil {
|
||||
copyErr = cerr
|
||||
}
|
||||
if copyErr != nil {
|
||||
return fmt.Errorf("copy %s to tar: %w", relPath, copyErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -27,6 +27,13 @@ const (
|
||||
|
||||
// EventStackStatus is emitted when a compose stack status changes.
|
||||
EventStackStatus EventType = "stack_status"
|
||||
|
||||
// EventBuildLog is emitted for each line of a streaming image build.
|
||||
// Per-line events are ephemeral (not persisted to the event_log) — they
|
||||
// exist to drive a live tail UI during the slow "building" phase of a
|
||||
// dockerfile-source deploy. Subscribers should filter by WorkloadID
|
||||
// because every dockerfile deploy on the box publishes on the same bus.
|
||||
EventBuildLog EventType = "build_log"
|
||||
)
|
||||
|
||||
// Event is a single event published on the bus.
|
||||
@@ -77,6 +84,14 @@ type StaticSiteStatusPayload struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// BuildLogPayload is the payload for EventBuildLog events. One event
|
||||
// per non-empty line read off the daemon's NDJSON build stream.
|
||||
type BuildLogPayload struct {
|
||||
WorkloadID string `json:"workload_id"`
|
||||
Line string `json:"line"`
|
||||
Stream string `json:"stream,omitempty"`
|
||||
}
|
||||
|
||||
// StackStatusPayload is the payload for EventStackStatus events.
|
||||
type StackStatusPayload struct {
|
||||
StackID string `json:"stack_id"`
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
// Package metrics provides a minimal Prometheus text-format exposition
|
||||
// of Tinyforge's operational counters. We deliberately do NOT import the
|
||||
// official client_golang library: the metrics set here is small, the text
|
||||
// format is simple, and avoiding the dependency keeps `tinyforge` a fast
|
||||
// single-binary install.
|
||||
//
|
||||
// Every counter is a sync/atomic.Int64 — cheap, lock-free, and safe to
|
||||
// touch from any goroutine. Histograms / gauges aren't modeled yet; the
|
||||
// few we need (request latency p50/p99) live downstream of slog and can
|
||||
// be added when the operator actually wants them.
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Registry holds the process-wide counter set. A single zero-value
|
||||
// Registry is ready to use — see DefaultRegistry below for the
|
||||
// recommended way to grab the global handle.
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
counters map[string]*counter
|
||||
}
|
||||
|
||||
type counter struct {
|
||||
name string
|
||||
help string
|
||||
labels []string // label names, ordered as declared at registration
|
||||
series map[string]*atomic.Int64
|
||||
// seriesMu only protects insertion of new label tuples — increments
|
||||
// on existing tuples are lock-free via the atomic.
|
||||
seriesMu sync.Mutex
|
||||
}
|
||||
|
||||
// DefaultRegistry is the process-wide registry. All Tinyforge metrics
|
||||
// register against it. Tests can instantiate their own Registry.
|
||||
var DefaultRegistry = newRegistry()
|
||||
|
||||
func newRegistry() *Registry {
|
||||
return &Registry{counters: make(map[string]*counter)}
|
||||
}
|
||||
|
||||
// NewCounter declares a counter on the default registry. Call once at
|
||||
// package init or during NewServer; subsequent calls with the same name
|
||||
// return the existing counter so re-registration is safe.
|
||||
//
|
||||
// label names define the dimensions; calls to Inc must pass values in
|
||||
// the same order. Use the empty slice for label-less counters.
|
||||
func NewCounter(name, help string, labels ...string) *Counter {
|
||||
return DefaultRegistry.NewCounter(name, help, labels...)
|
||||
}
|
||||
|
||||
// NewCounter on a specific Registry — useful in tests.
|
||||
func (r *Registry) NewCounter(name, help string, labels ...string) *Counter {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if c, ok := r.counters[name]; ok {
|
||||
return &Counter{c: c}
|
||||
}
|
||||
c := &counter{
|
||||
name: name,
|
||||
help: help,
|
||||
labels: append([]string(nil), labels...),
|
||||
series: make(map[string]*atomic.Int64),
|
||||
}
|
||||
r.counters[name] = c
|
||||
return &Counter{c: c}
|
||||
}
|
||||
|
||||
// Counter is the public handle returned by NewCounter. Pass it around as
|
||||
// a value — the underlying state lives on the registry.
|
||||
type Counter struct {
|
||||
c *counter
|
||||
}
|
||||
|
||||
// Inc atomically increments the counter for the given label values.
|
||||
// Passing the wrong number of values is a programmer error; we surface
|
||||
// it as a panic during testing rather than silently aggregating into a
|
||||
// bogus series.
|
||||
func (c Counter) Inc(labelValues ...string) {
|
||||
c.Add(1, labelValues...)
|
||||
}
|
||||
|
||||
// Add atomically adds delta. Negative delta is rejected (counters are
|
||||
// monotonic by definition).
|
||||
func (c Counter) Add(delta int64, labelValues ...string) {
|
||||
if delta < 0 {
|
||||
return
|
||||
}
|
||||
if len(labelValues) != len(c.c.labels) {
|
||||
// Programmer error. This used to panic to surface the bug, but Add
|
||||
// runs on hot paths (HTTP middleware, deploy dispatch) and several
|
||||
// callers are off the request goroutine, where a panic would take
|
||||
// down the whole process rather than a single request. Log loudly
|
||||
// and drop the sample so a mislabeled call site can never crash the
|
||||
// server; the bug still shows up immediately in the logs and in
|
||||
// tests via the error output.
|
||||
slog.Error("metrics: label count mismatch — dropping sample",
|
||||
"counter", c.c.name, "want", len(c.c.labels), "got", len(labelValues))
|
||||
return
|
||||
}
|
||||
key := encodeKey(labelValues)
|
||||
c.c.seriesMu.Lock()
|
||||
v, ok := c.c.series[key]
|
||||
if !ok {
|
||||
v = new(atomic.Int64)
|
||||
c.c.series[key] = v
|
||||
}
|
||||
c.c.seriesMu.Unlock()
|
||||
v.Add(delta)
|
||||
}
|
||||
|
||||
// encodeKey joins label values with a 0x1f separator. Prometheus label
|
||||
// values may contain anything except `"` and `\n`, which we escape on
|
||||
// exposition only — the key here is just a map index.
|
||||
func encodeKey(values []string) string {
|
||||
return strings.Join(values, "\x1f")
|
||||
}
|
||||
|
||||
// WritePrometheus dumps the registry in the text exposition format
|
||||
// Prometheus / VictoriaMetrics / OpenMetrics understands. Stable
|
||||
// ordering: counters alphabetical by name; series alphabetical by
|
||||
// encoded label tuple.
|
||||
func (r *Registry) WritePrometheus(w io.Writer) error {
|
||||
r.mu.RLock()
|
||||
names := make([]string, 0, len(r.counters))
|
||||
for n := range r.counters {
|
||||
names = append(names, n)
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
sort.Strings(names)
|
||||
|
||||
for _, name := range names {
|
||||
r.mu.RLock()
|
||||
c := r.counters[name]
|
||||
r.mu.RUnlock()
|
||||
if err := writeCounter(w, c); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeCounter(w io.Writer, c *counter) error {
|
||||
if _, err := fmt.Fprintf(w, "# HELP %s %s\n# TYPE %s counter\n", c.name, escapeHelp(c.help), c.name); err != nil {
|
||||
return err
|
||||
}
|
||||
// Snapshot the series map under a SINGLE lock acquisition. The
|
||||
// previous shape acquired+released seriesMu twice per emitted
|
||||
// series (once for the key list, once per Load), contending with
|
||||
// every hot-path Inc on the HTTP request path. The *atomic.Int64
|
||||
// pointers are stable for the lifetime of the registry (we never
|
||||
// delete entries), so reading them after the unlock is safe.
|
||||
type sample struct {
|
||||
key string
|
||||
val *atomic.Int64
|
||||
}
|
||||
c.seriesMu.Lock()
|
||||
samples := make([]sample, 0, len(c.series))
|
||||
for k, v := range c.series {
|
||||
samples = append(samples, sample{k, v})
|
||||
}
|
||||
c.seriesMu.Unlock()
|
||||
|
||||
sort.Slice(samples, func(i, j int) bool { return samples[i].key < samples[j].key })
|
||||
|
||||
for _, s := range samples {
|
||||
val := s.val.Load()
|
||||
labels := decodeKey(s.key, c.labels)
|
||||
if labels == "" {
|
||||
if _, err := fmt.Fprintf(w, "%s %d\n", c.name, val); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "%s{%s} %d\n", c.name, labels, val); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeKey(key string, names []string) string {
|
||||
if key == "" || len(names) == 0 {
|
||||
return ""
|
||||
}
|
||||
values := strings.Split(key, "\x1f")
|
||||
if len(values) != len(names) {
|
||||
// Should not happen — encodeKey/decode are symmetric.
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, len(names))
|
||||
for i, n := range names {
|
||||
parts[i] = fmt.Sprintf(`%s="%s"`, n, escapeLabelValue(values[i]))
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func escapeHelp(s string) string {
|
||||
r := strings.NewReplacer("\\", "\\\\", "\n", "\\n")
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
func escapeLabelValue(s string) string {
|
||||
r := strings.NewReplacer("\\", "\\\\", "\n", "\\n", `"`, `\"`)
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
// ── Pre-declared counters ────────────────────────────────────────────
|
||||
//
|
||||
// These are the counters Tinyforge surfaces to operators. Adding more is
|
||||
// a one-line NewCounter call at the call site — no central catalogue,
|
||||
// just keep names lowercase_snake with the `tinyforge_` prefix.
|
||||
|
||||
var (
|
||||
HTTPRequestsTotal = NewCounter(
|
||||
"tinyforge_http_requests_total",
|
||||
"Total HTTP requests handled, partitioned by method and outcome class.",
|
||||
"method", "status_class",
|
||||
)
|
||||
DeploysTotal = NewCounter(
|
||||
"tinyforge_deploys_total",
|
||||
"Total deploys dispatched, partitioned by source kind and outcome.",
|
||||
"source_kind", "outcome",
|
||||
)
|
||||
WebhookDeliveriesTotal = NewCounter(
|
||||
"tinyforge_webhook_deliveries_total",
|
||||
"Total inbound webhook deliveries, partitioned by outcome.",
|
||||
"outcome",
|
||||
)
|
||||
SchedulerTicksTotal = NewCounter(
|
||||
"tinyforge_scheduler_ticks_total",
|
||||
"Total scheduler ticks. The dispatched counter is the success measure.",
|
||||
)
|
||||
SchedulerDispatchedTotal = NewCounter(
|
||||
"tinyforge_scheduler_dispatched_total",
|
||||
"Triggers actually dispatched by the scheduler.",
|
||||
)
|
||||
OutboundNotifyTotal = NewCounter(
|
||||
"tinyforge_outbound_notify_total",
|
||||
"Outbound notification dispatch attempts, partitioned by outcome.",
|
||||
"outcome",
|
||||
)
|
||||
)
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/metrics"
|
||||
)
|
||||
|
||||
// Event represents a deployment / site-sync notification payload.
|
||||
@@ -83,17 +85,68 @@ type TestResult struct {
|
||||
// Notifications are fire-and-forget by default — failures are logged but do
|
||||
// not propagate. SendSyncForTest is the exception, used only by the manual
|
||||
// test endpoint.
|
||||
//
|
||||
// outboundSem caps the number of in-flight outbound notifications. Without
|
||||
// it a single burst (e.g. 1000 event triggers firing on a noisy log scan)
|
||||
// would spawn 1000 simultaneous TCP connections, which both DoSes the
|
||||
// receiver and exhausts local FDs.
|
||||
type Notifier struct {
|
||||
httpClient *http.Client
|
||||
wg sync.WaitGroup
|
||||
httpClient *http.Client
|
||||
wg sync.WaitGroup
|
||||
outboundSem chan struct{}
|
||||
}
|
||||
|
||||
// maxOutboundNotifications bounds the in-flight outbound webhook fan-out.
|
||||
// Sized to keep small bursts non-blocking while preventing a runaway storm
|
||||
// from starving the rest of the process. Tunable later via settings if any
|
||||
// operator legitimately needs more concurrency.
|
||||
const maxOutboundNotifications = 32
|
||||
|
||||
// New creates a Notifier with sensible defaults.
|
||||
func New() *Notifier {
|
||||
// Transport with bounded host pooling so a slow receiver cannot pin
|
||||
// arbitrarily many sockets open. MaxConnsPerHost mirrors the worker
|
||||
// pool size; idle pruning keeps long-lived processes from holding
|
||||
// stale TCP entries indefinitely.
|
||||
//
|
||||
// NOTE: we deliberately do NOT apply the staticsite SSRF dialer here.
|
||||
// Notification URLs are admin-configured, and an admin already has
|
||||
// Docker-socket (host-root-equivalent) access, so the SSRF surface adds
|
||||
// nothing they couldn't already reach. Blocking loopback/private targets
|
||||
// would instead break the common self-hosted pattern of notifying a
|
||||
// same-host sidecar/bridge (e.g. service-to-notification-bridge on
|
||||
// 127.0.0.1). See the security review (rated LOW / out of trust boundary).
|
||||
tr := &http.Transport{
|
||||
MaxIdleConns: 64,
|
||||
MaxIdleConnsPerHost: 8,
|
||||
MaxConnsPerHost: maxOutboundNotifications,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
}
|
||||
return &Notifier{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: tr,
|
||||
},
|
||||
outboundSem: make(chan struct{}, maxOutboundNotifications),
|
||||
}
|
||||
}
|
||||
|
||||
// acquireSlot reserves an outbound slot, respecting ctx so a backed-up
|
||||
// queue cannot starve a request that already has its own deadline.
|
||||
func (n *Notifier) acquireSlot(ctx context.Context) bool {
|
||||
select {
|
||||
case n.outboundSem <- struct{}{}:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Notifier) releaseSlot() {
|
||||
select {
|
||||
case <-n.outboundSem:
|
||||
default:
|
||||
// Drained during shutdown — never block.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,8 +181,15 @@ func (n *Notifier) SendSigned(webhookURL, secret string, tier Tier, event Event)
|
||||
n.wg.Add(1)
|
||||
go func() {
|
||||
defer n.wg.Done()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if !n.acquireSlot(ctx) {
|
||||
slog.Warn("notify: dropped — outbound queue saturated",
|
||||
"tier", tier, "host", safeHost(webhookURL), "delivery", delivery, "event", event.Type)
|
||||
metrics.OutboundNotifyTotal.Inc("dropped")
|
||||
return
|
||||
}
|
||||
defer n.releaseSlot()
|
||||
|
||||
_, err := n.doSend(ctx, webhookURL, secret, tier, delivery, event)
|
||||
// URL host only — never log the secret or full URL with user-info.
|
||||
@@ -138,11 +198,13 @@ func (n *Notifier) SendSigned(webhookURL, secret string, tier Tier, event Event)
|
||||
slog.Warn("notify: webhook send failed",
|
||||
"tier", tier, "host", host, "delivery", delivery,
|
||||
"event", event.Type, "signed", secret != "", "error", err)
|
||||
metrics.OutboundNotifyTotal.Inc("failure")
|
||||
return
|
||||
}
|
||||
slog.Info("notify: webhook dispatched",
|
||||
"tier", tier, "host", host, "delivery", delivery,
|
||||
"event", event.Type, "signed", secret != "")
|
||||
metrics.OutboundNotifyTotal.Inc("success")
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -166,8 +228,15 @@ func (n *Notifier) SendPayload(webhookURL, secret, eventType string, payload any
|
||||
n.wg.Add(1)
|
||||
go func() {
|
||||
defer n.wg.Done()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if !n.acquireSlot(ctx) {
|
||||
slog.Warn("notify: dropped trigger payload — outbound queue saturated",
|
||||
"tier", TierEventTrigger, "host", safeHost(webhookURL), "delivery", delivery, "event", eventType)
|
||||
metrics.OutboundNotifyTotal.Inc("dropped")
|
||||
return
|
||||
}
|
||||
defer n.releaseSlot()
|
||||
|
||||
_, err := n.doSendRaw(ctx, webhookURL, secret, TierEventTrigger, delivery, eventType, timestamp, payload)
|
||||
host := safeHost(webhookURL)
|
||||
@@ -175,11 +244,13 @@ func (n *Notifier) SendPayload(webhookURL, secret, eventType string, payload any
|
||||
slog.Warn("notify: trigger webhook send failed",
|
||||
"tier", TierEventTrigger, "host", host, "delivery", delivery,
|
||||
"event", eventType, "signed", secret != "", "error", err)
|
||||
metrics.OutboundNotifyTotal.Inc("failure")
|
||||
return
|
||||
}
|
||||
slog.Info("notify: trigger webhook dispatched",
|
||||
"tier", TierEventTrigger, "host", host, "delivery", delivery,
|
||||
"event", eventType, "signed", secret != "")
|
||||
metrics.OutboundNotifyTotal.Inc("success")
|
||||
}()
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/metrics"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin/trigger/schedule"
|
||||
@@ -124,6 +125,7 @@ func (s *Scheduler) loop(ctx context.Context) {
|
||||
// TickOnce runs a single sweep. Exposed for tests and for the boot
|
||||
// kick. On error per-trigger the loop continues with the next row.
|
||||
func (s *Scheduler) TickOnce(ctx context.Context) {
|
||||
metrics.SchedulerTicksTotal.Inc()
|
||||
rows, err := s.store.ListTriggers("schedule")
|
||||
if err != nil {
|
||||
slog.Warn("scheduler: list triggers", "error", err)
|
||||
@@ -226,5 +228,6 @@ func (s *Scheduler) fire(ctx context.Context, t store.Trigger, now time.Time) {
|
||||
slog.Warn("scheduler: dispatch", "trigger", t.Name, "error", err)
|
||||
return
|
||||
}
|
||||
metrics.SchedulerDispatchedTotal.Inc()
|
||||
slog.Info("scheduler: fired", "trigger", t.Name, "kind", t.Kind, "at", ts)
|
||||
}
|
||||
|
||||
@@ -92,17 +92,27 @@ func (c *Compose) Ps(ctx context.Context, projectName, yamlPath string) ([]Servi
|
||||
}
|
||||
|
||||
// Logs runs `docker compose -p <projectName> logs --no-color --tail=<n> <service>`.
|
||||
// If service is empty, logs for all services are returned.
|
||||
// If service is empty, logs for all services are returned. The service arg
|
||||
// is preceded by `--` so a service name that begins with `-` cannot be
|
||||
// re-parsed as a flag by the docker CLI (flag-injection guard).
|
||||
func (c *Compose) Logs(ctx context.Context, projectName, service string, tail int) (string, error) {
|
||||
args := []string{"logs", "--no-color", fmt.Sprintf("--tail=%d", tail)}
|
||||
if service != "" {
|
||||
args = append(args, service)
|
||||
args = append(args, "--", service)
|
||||
}
|
||||
return c.run(ctx, projectName, args...)
|
||||
}
|
||||
|
||||
// run executes `docker compose -p <projectName> <args...>` and returns combined output.
|
||||
// run executes `docker compose -p <projectName> <args...>` and returns
|
||||
// combined output. projectName is verified not to begin with `-` because
|
||||
// `docker compose -p '--foo'` would otherwise be re-parsed as a flag —
|
||||
// the callers already sanitize project names through projectNameSanitizer,
|
||||
// but a belt-and-braces refusal here means any future caller cannot
|
||||
// accidentally bypass the sanitizer.
|
||||
func (c *Compose) run(ctx context.Context, projectName string, args ...string) (string, error) {
|
||||
if projectName == "" || strings.HasPrefix(projectName, "-") {
|
||||
return "", fmt.Errorf("docker compose: refusing project name %q", projectName)
|
||||
}
|
||||
full := append([]string{"compose", "-p", projectName}, args...)
|
||||
cmd := exec.CommandContext(ctx, c.binary, full...)
|
||||
var buf bytes.Buffer
|
||||
|
||||
+146
-6
@@ -2,6 +2,7 @@ package stack
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
@@ -15,11 +16,25 @@ type ComposeSpec struct {
|
||||
}
|
||||
|
||||
// ServiceSpec captures the subset of compose service fields we inspect.
|
||||
//
|
||||
// All host-escape-adjacent fields are decoded here even though Tinyforge
|
||||
// itself never reads them at runtime — surfacing them to Validate() is the
|
||||
// only way to *reject* them. Add new fields here when blocking a new
|
||||
// escape vector.
|
||||
type ServiceSpec struct {
|
||||
Image string `yaml:"image,omitempty"`
|
||||
Ports []any `yaml:"ports,omitempty"`
|
||||
Labels map[string]string `yaml:"labels,omitempty"`
|
||||
Privileged bool `yaml:"privileged,omitempty"`
|
||||
Image string `yaml:"image,omitempty"`
|
||||
Build any `yaml:"build,omitempty"` // banned — see Validate
|
||||
Ports []any `yaml:"ports,omitempty"`
|
||||
Labels map[string]string `yaml:"labels,omitempty"`
|
||||
Privileged bool `yaml:"privileged,omitempty"`
|
||||
Volumes []any `yaml:"volumes,omitempty"`
|
||||
NetworkMode string `yaml:"network_mode,omitempty"`
|
||||
Pid string `yaml:"pid,omitempty"`
|
||||
Ipc string `yaml:"ipc,omitempty"`
|
||||
UsernsMode string `yaml:"userns_mode,omitempty"`
|
||||
CapAdd []string `yaml:"cap_add,omitempty"`
|
||||
Devices []any `yaml:"devices,omitempty"`
|
||||
SecurityOpt []string `yaml:"security_opt,omitempty"`
|
||||
}
|
||||
|
||||
// Parse decodes YAML into a ComposeSpec. Returns a descriptive error on failure.
|
||||
@@ -35,10 +50,20 @@ func Parse(yamlText string) (ComposeSpec, error) {
|
||||
}
|
||||
|
||||
// Validate enforces Tinyforge-level constraints beyond compose schema validity.
|
||||
// All blocked fields below are documented host-escape vectors: any one of
|
||||
// them on its own gives the container root on the host. Tinyforge already
|
||||
// owns the docker socket, so the threat model is "any admin == host root,"
|
||||
// and these blocks raise the bar for any *future* viewer-to-admin
|
||||
// escalation as well as honest-mistake guardrails.
|
||||
//
|
||||
// Current rules:
|
||||
// - No service may set `privileged: true`.
|
||||
// - Every service must declare an image (compose supports build: too, but
|
||||
// Tinyforge v1 disallows building from context to avoid arbitrary-code exec).
|
||||
// - Every service must declare an image (build contexts disallowed).
|
||||
// - No host-IPC / host-PID / host-userns / host networking.
|
||||
// - No `cap_add`, `security_opt`, `devices`.
|
||||
// - `volumes` may not bind-mount the docker socket, /, /etc, /var, /proc,
|
||||
// /sys, /root, or /home — list is conservative; operators with real
|
||||
// bind-mount needs should ship a Source plugin or a dedicated wizard.
|
||||
func Validate(spec ComposeSpec) error {
|
||||
for name, svc := range spec.Services {
|
||||
if svc.Privileged {
|
||||
@@ -47,6 +72,121 @@ func Validate(spec ComposeSpec) error {
|
||||
if svc.Image == "" {
|
||||
return fmt.Errorf("service %q: image is required (build contexts not supported)", name)
|
||||
}
|
||||
if svc.Build != nil {
|
||||
return fmt.Errorf("service %q: build: is not supported (use image:)", name)
|
||||
}
|
||||
if isBlockedNamespaceMode(svc.NetworkMode) {
|
||||
return fmt.Errorf("service %q: network_mode %q is not allowed", name, svc.NetworkMode)
|
||||
}
|
||||
if isBlockedNamespaceMode(svc.Pid) {
|
||||
return fmt.Errorf("service %q: pid: %q is not allowed", name, svc.Pid)
|
||||
}
|
||||
if isBlockedNamespaceMode(svc.Ipc) {
|
||||
return fmt.Errorf("service %q: ipc: %q is not allowed", name, svc.Ipc)
|
||||
}
|
||||
if isHostMode(svc.UsernsMode) {
|
||||
return fmt.Errorf("service %q: userns_mode %q is not allowed", name, svc.UsernsMode)
|
||||
}
|
||||
if len(svc.CapAdd) > 0 {
|
||||
return fmt.Errorf("service %q: cap_add is not allowed", name)
|
||||
}
|
||||
if len(svc.SecurityOpt) > 0 {
|
||||
return fmt.Errorf("service %q: security_opt is not allowed", name)
|
||||
}
|
||||
if len(svc.Devices) > 0 {
|
||||
return fmt.Errorf("service %q: devices is not allowed", name)
|
||||
}
|
||||
for _, v := range svc.Volumes {
|
||||
if host, ok := bindMountHostPath(v); ok {
|
||||
if isBlockedBindMount(host) {
|
||||
return fmt.Errorf("service %q: bind-mounting %q is not allowed", name, host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isHostMode reports a host-namespace share, i.e. network_mode / pid / ipc /
|
||||
// userns_mode set to "host". (It deliberately does NOT match "host-gateway",
|
||||
// which is an extra_hosts value, not a namespace mode — matching it here only
|
||||
// produced misleading rejections.)
|
||||
func isHostMode(v string) bool {
|
||||
return v == "host"
|
||||
}
|
||||
|
||||
// isBlockedNamespaceMode reports a namespace mode that must be rejected for
|
||||
// network_mode / pid / ipc: either host sharing ("host") or joining another
|
||||
// container's / compose service's namespace ("container:<id>",
|
||||
// "service:<name>"). The container/service joins are a lateral-movement and
|
||||
// sandbox-escape vector — a malicious service could attach to a victim
|
||||
// container's network or PID namespace.
|
||||
func isBlockedNamespaceMode(v string) bool {
|
||||
return isHostMode(v) ||
|
||||
strings.HasPrefix(v, "container:") ||
|
||||
strings.HasPrefix(v, "service:")
|
||||
}
|
||||
|
||||
// bindMountHostPath extracts the host-side path from a compose volume
|
||||
// declaration. Compose accepts two shapes: a short string "src:dst[:mode]"
|
||||
// and a long form map with a "source" key. Returns ok=false for named
|
||||
// volumes (no host source).
|
||||
func bindMountHostPath(v any) (string, bool) {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
// "named:/in/container" has no '/' or '.' prefix on the source.
|
||||
if t == "" {
|
||||
return "", false
|
||||
}
|
||||
parts := strings.SplitN(t, ":", 3)
|
||||
src := parts[0]
|
||||
if strings.HasPrefix(src, "/") || strings.HasPrefix(src, ".") || strings.HasPrefix(src, "~") {
|
||||
return src, true
|
||||
}
|
||||
return "", false
|
||||
case map[string]any:
|
||||
if typ, _ := t["type"].(string); typ != "" && typ != "bind" {
|
||||
return "", false
|
||||
}
|
||||
if src, ok := t["source"].(string); ok {
|
||||
if strings.HasPrefix(src, "/") || strings.HasPrefix(src, ".") || strings.HasPrefix(src, "~") {
|
||||
return src, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// isBlockedBindMount returns true for paths that obviously escape the
|
||||
// container's intended sandbox. Conservative deny-list — operators with
|
||||
// legitimate bind-mount needs should write a dedicated Source plugin
|
||||
// rather than tunnel them through compose.
|
||||
func isBlockedBindMount(host string) bool {
|
||||
// Normalize trailing slash so "/var" and "/var/" both match.
|
||||
clean := strings.TrimRight(host, "/")
|
||||
if clean == "" || clean == "/" {
|
||||
return true
|
||||
}
|
||||
// Relative ("./x", "../x", ".") and home-relative ("~/...") sources are
|
||||
// resolved by Docker against the compose working directory (which
|
||||
// Tinyforge controls and never intends as a host-bind source) or left
|
||||
// unexpanded — and "../" can climb out of that directory entirely. The
|
||||
// absolute-prefix deny-list below can't see these, so reject them
|
||||
// outright rather than give a false sense of coverage.
|
||||
if strings.HasPrefix(clean, ".") || strings.HasPrefix(clean, "~") {
|
||||
return true
|
||||
}
|
||||
// Specific blocked files / sockets.
|
||||
switch clean {
|
||||
case "/var/run/docker.sock", "/run/docker.sock":
|
||||
return true
|
||||
}
|
||||
// Blocked prefixes (cover sub-paths too).
|
||||
blocked := []string{"/etc", "/var", "/proc", "/sys", "/root", "/home", "/boot", "/dev"}
|
||||
for _, p := range blocked {
|
||||
if clean == p || strings.HasPrefix(clean, p+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -50,34 +50,7 @@ func ValidateBaseURL(raw string) error {
|
||||
func NewSafeHTTPClient(timeout time.Duration) *http.Client {
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
|
||||
transport := &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// If the caller passed a literal IP, skip the DNS round-trip.
|
||||
if literal := net.ParseIP(host); literal != nil {
|
||||
if reason := blockReason(literal); reason != "" {
|
||||
return nil, fmt.Errorf("%w: %s (%s)", ErrBlockedAddress, literal, reason)
|
||||
}
|
||||
return dialer.DialContext(ctx, network, addr)
|
||||
}
|
||||
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return nil, fmt.Errorf("no addresses for %s", host)
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if reason := blockReason(ip.IP); reason != "" {
|
||||
return nil, fmt.Errorf("%w: %s (%s)", ErrBlockedAddress, ip.IP, reason)
|
||||
}
|
||||
}
|
||||
// Bind to the first resolved IP so a rebind between resolution
|
||||
// and connect cannot redirect the request to a blocked address.
|
||||
return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port))
|
||||
},
|
||||
DialContext: SafeDialContext(dialer),
|
||||
MaxIdleConns: 16,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
@@ -85,6 +58,43 @@ func NewSafeHTTPClient(timeout time.Duration) *http.Client {
|
||||
return &http.Client{Timeout: timeout, Transport: transport}
|
||||
}
|
||||
|
||||
// SafeDialContext returns a DialContext that rejects loopback, link-local,
|
||||
// multicast, unspecified, and cloud-metadata addresses at connect time,
|
||||
// re-resolving and binding to the resolved IP so a DNS rebind between
|
||||
// resolution and connect cannot slip through. Exposed so other transports
|
||||
// (e.g. the outbound notification client) can apply the same SSRF policy
|
||||
// without duplicating it or losing their own connection-pool tuning.
|
||||
func SafeDialContext(dialer *net.Dialer) func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// If the caller passed a literal IP, skip the DNS round-trip.
|
||||
if literal := net.ParseIP(host); literal != nil {
|
||||
if reason := blockReason(literal); reason != "" {
|
||||
return nil, fmt.Errorf("%w: %s (%s)", ErrBlockedAddress, literal, reason)
|
||||
}
|
||||
return dialer.DialContext(ctx, network, addr)
|
||||
}
|
||||
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return nil, fmt.Errorf("no addresses for %s", host)
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if reason := blockReason(ip.IP); reason != "" {
|
||||
return nil, fmt.Errorf("%w: %s (%s)", ErrBlockedAddress, ip.IP, reason)
|
||||
}
|
||||
}
|
||||
// Bind to the first resolved IP so a rebind between resolution
|
||||
// and connect cannot redirect the request to a blocked address.
|
||||
return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port))
|
||||
}
|
||||
}
|
||||
|
||||
// blockReason returns a human label for why an IP is rejected, or ""
|
||||
// if the IP is allowed. Centralized so all callers share the same
|
||||
// policy.
|
||||
@@ -92,6 +102,13 @@ func blockReason(ip net.IP) string {
|
||||
if ip == nil {
|
||||
return "nil address"
|
||||
}
|
||||
// Normalize IPv4-mapped IPv6 (::ffff:x.x.x.x) so the loopback / link-local
|
||||
// classifiers below catch them. net.IP.To4() returns the 4-byte form for
|
||||
// IPv4-mapped addresses; net's IsLoopback already handles this, but pin
|
||||
// the conversion to avoid future surprises if the std-lib semantics drift.
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
ip = v4
|
||||
}
|
||||
switch {
|
||||
case ip.IsLoopback():
|
||||
return "loopback"
|
||||
@@ -104,5 +121,22 @@ func blockReason(ip net.IP) string {
|
||||
case ip.IsMulticast():
|
||||
return "multicast"
|
||||
}
|
||||
// Cloud metadata endpoints — AWS / GCP / Azure are covered by the
|
||||
// link-local block (169.254.169.254). The rest must be enumerated.
|
||||
if metadataIPSet[ip.String()] {
|
||||
return "cloud metadata endpoint"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// metadataIPSet enumerates well-known cloud metadata IPs that are NOT
|
||||
// covered by net.IP.IsLinkLocalUnicast. Updating this set is the lightest
|
||||
// way to keep up with new providers without changing the policy shape.
|
||||
var metadataIPSet = map[string]bool{
|
||||
// Alibaba Cloud ECS metadata.
|
||||
"100.100.100.200": true,
|
||||
// Oracle Cloud Infrastructure metadata.
|
||||
"192.0.0.192": true,
|
||||
// AWS IMDS over IPv6 (ULA — not link-local, must be listed).
|
||||
"fd00:ec2::254": true,
|
||||
}
|
||||
|
||||
@@ -234,17 +234,17 @@ func (c *Collector) sampleAll(ctx context.Context, targets []target) []store.Con
|
||||
found := make([]bool, len(targets))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
loop:
|
||||
for i, t := range targets {
|
||||
// Acquire the semaphore in the parent loop so ctx cancellation
|
||||
// short-circuits the queue rather than spawning goroutines that
|
||||
// block on an unreachable slot.
|
||||
// block on an unreachable slot. The labelled break exits the for
|
||||
// loop directly; a bare `break` inside `select` would only break
|
||||
// the select and let the loop continue.
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
break
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
break loop
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(i int, t target) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -9,6 +10,22 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// validateExtraJSON ensures the extra_json column never receives an
|
||||
// invalid JSON document. The codemap (docs/CODEMAPS/container-extra-json.md)
|
||||
// is explicit that readers tolerate unknown keys — but only if the value
|
||||
// is valid JSON at all. A buggy plugin writing `"not json"` would silently
|
||||
// break every reader, with no schema-level check to catch it. Guarding at
|
||||
// the store boundary keeps the invariant cheap and obvious.
|
||||
func validateExtraJSON(v string) error {
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
if !json.Valid([]byte(v)) {
|
||||
return fmt.Errorf("extra_json: not valid JSON (%d bytes)", len(v))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// containerColumns is the canonical column list for `containers` queries.
|
||||
// stage_id is populated by the deployer for project containers (so ListProxyRoutes
|
||||
// survives stage renames) and left empty for stacks and sites.
|
||||
@@ -42,6 +59,9 @@ func (s *Store) CreateContainer(c Container) (Container, error) {
|
||||
if c.ExtraJSON == "" {
|
||||
c.ExtraJSON = "{}"
|
||||
}
|
||||
if err := validateExtraJSON(c.ExtraJSON); err != nil {
|
||||
return Container{}, err
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO containers (`+containerColumns+`)
|
||||
@@ -77,6 +97,9 @@ func (s *Store) UpsertContainer(c Container) error {
|
||||
if c.ExtraJSON == "" {
|
||||
c.ExtraJSON = "{}"
|
||||
}
|
||||
if err := validateExtraJSON(c.ExtraJSON); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// SQLite UPSERT — INSERT...ON CONFLICT(id) DO UPDATE.
|
||||
_, err := s.db.Exec(
|
||||
@@ -129,6 +152,9 @@ func (s *Store) ReconcileContainer(c Container) error {
|
||||
if c.ExtraJSON == "" {
|
||||
c.ExtraJSON = "{}"
|
||||
}
|
||||
if err := validateExtraJSON(c.ExtraJSON); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// extra_json is deliberately NOT in the ON CONFLICT SET clause: the
|
||||
// reconciler can't observe per-face route IDs from Docker, and
|
||||
@@ -321,6 +347,9 @@ func (s *Store) UpdateContainer(c Container) error {
|
||||
if c.ExtraJSON == "" {
|
||||
c.ExtraJSON = "{}"
|
||||
}
|
||||
if err := validateExtraJSON(c.ExtraJSON); err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE containers SET workload_id=?, workload_kind=?, role=?, stage_id=?, container_id=?,
|
||||
image_ref=?, image_tag=?, host=?, state=?, port=?,
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrLockHeld is returned when another Tinyforge process appears to be
|
||||
// running against the same data directory. SQLite + SetMaxOpenConns(1)
|
||||
// makes this otherwise-silent collision a recipe for double-fired
|
||||
// schedulers, double-polled registries, and `extra_json` RMW corruption.
|
||||
var ErrLockHeld = errors.New("data directory is locked by another tinyforge process")
|
||||
|
||||
// Lockfile is a portable PID file. AcquireLockfile takes it; the returned
|
||||
// Release function removes it. The contract:
|
||||
//
|
||||
// - Lockfile is created with O_CREATE|O_EXCL — atomic on POSIX, atomic
|
||||
// on NTFS / ReFS via the equivalent.
|
||||
// - On collision, the existing file's PID is read; if the PID is dead,
|
||||
// we treat the lock as stale (process crashed without cleanup),
|
||||
// reclaim it, and proceed. Live PID → ErrLockHeld.
|
||||
// - flock is intentionally not used: cross-platform consistency wins
|
||||
// over advisory-lock semantics for the single-instance use case.
|
||||
type Lockfile struct {
|
||||
path string
|
||||
}
|
||||
|
||||
// AcquireLockfile creates a PID-file lock under dataDir. Returns a
|
||||
// Release function the caller must defer. If another live process holds
|
||||
// the lock, returns ErrLockHeld with a hint pointing at the lockfile.
|
||||
//
|
||||
// Reclaim atomicity: when the existing lockfile names a dead PID, the
|
||||
// replacement is serialized through an auxiliary reclaim lock (see
|
||||
// reclaimStaleLock) so that, of N processes booting concurrently against
|
||||
// the same stale lockfile, EXACTLY ONE reclaims it and the rest get
|
||||
// ErrLockHeld. A bare `os.Remove`+`O_EXCL` retry — or a rename, which is
|
||||
// "last-writer-wins" — cannot guarantee this: multiple reclaimers can each
|
||||
// end up believing they own the lock, defeating the single-instance guard.
|
||||
func AcquireLockfile(dataDir string) (release func(), err error) {
|
||||
path := filepath.Join(dataDir, "tinyforge.lock")
|
||||
|
||||
// First try: clean acquire.
|
||||
if rel, ok, err := tryCreateExclusive(path); ok {
|
||||
return rel, nil
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Existing lockfile — read PID and decide whether to reclaim.
|
||||
pid, readErr := readLockPID(path)
|
||||
if readErr == nil && processAlive(pid) {
|
||||
return nil, fmt.Errorf("%w (held by pid %d, lockfile=%s)", ErrLockHeld, pid, path)
|
||||
}
|
||||
// Stale lock (dead pid) or malformed file — reclaim under serialization.
|
||||
reason := "malformed existing lockfile"
|
||||
if readErr == nil {
|
||||
reason = fmt.Sprintf("stale lockfile (dead pid %d)", pid)
|
||||
}
|
||||
return reclaimStaleLock(path, reason)
|
||||
}
|
||||
|
||||
// tryCreateExclusive attempts an atomic O_CREATE|O_EXCL create at path.
|
||||
// Returns (release, true, nil) on success; (nil, false, nil) when the
|
||||
// file already exists; (nil, false, err) on any other error.
|
||||
func tryCreateExclusive(path string) (func(), bool, error) {
|
||||
f, openErr := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if openErr != nil {
|
||||
if os.IsExist(openErr) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, fmt.Errorf("open lockfile: %w", openErr)
|
||||
}
|
||||
if _, err := fmt.Fprintf(f, "%d\n", os.Getpid()); err != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(path)
|
||||
return nil, false, fmt.Errorf("write lockfile: %w", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
_ = os.Remove(path)
|
||||
return nil, false, fmt.Errorf("close lockfile: %w", err)
|
||||
}
|
||||
return func() { _ = os.Remove(path) }, true, nil
|
||||
}
|
||||
|
||||
// reclaimStaleLock replaces a stale/malformed lockfile with one holding our
|
||||
// PID, serialized by an auxiliary reclaim lock. Holding the reclaim lock
|
||||
// (O_EXCL) guarantees that only one process performs the remove-and-recreate
|
||||
// of the main lockfile at a time, so concurrent reclaimers cannot each end
|
||||
// up "owning" the lock the way a rename or unguarded remove+create would
|
||||
// allow. The reclaim lock is itself liveness-checked so a reclaimer that
|
||||
// crashed mid-reclaim cannot wedge startup forever.
|
||||
func reclaimStaleLock(lockPath, reason string) (func(), error) {
|
||||
reclaimPath := lockPath + ".reclaim"
|
||||
if err := acquireReclaimLock(reclaimPath); err != nil {
|
||||
return nil, fmt.Errorf("%w (%v; %s)", ErrLockHeld, err, reason)
|
||||
}
|
||||
defer func() { _ = os.Remove(reclaimPath) }()
|
||||
|
||||
// Serialized now. Re-check the main lock: another process may have fully
|
||||
// reclaimed it between our liveness probe and our taking the reclaim lock.
|
||||
if pid, perr := readLockPID(lockPath); perr == nil && processAlive(pid) {
|
||||
return nil, fmt.Errorf("%w (reclaimed by pid %d while we waited; %s)",
|
||||
ErrLockHeld, pid, reason)
|
||||
}
|
||||
|
||||
// Safe to replace: remove the stale file, then create a fresh exclusive
|
||||
// one. Both run while we hold the reclaim lock, so no other reclaimer can
|
||||
// observe the gap.
|
||||
if err := os.Remove(lockPath); err != nil && !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("%w (could not remove stale lockfile %s: %v; %s)",
|
||||
ErrLockHeld, lockPath, err, reason)
|
||||
}
|
||||
rel, ok, err := tryCreateExclusive(lockPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
// Should be impossible while we hold the reclaim lock; fail safe.
|
||||
return nil, fmt.Errorf("%w (lockfile reappeared during reclaim of %s; %s)",
|
||||
ErrLockHeld, lockPath, reason)
|
||||
}
|
||||
return rel, nil
|
||||
}
|
||||
|
||||
// acquireReclaimLock takes the auxiliary reclaim lock with O_EXCL. An
|
||||
// existing reclaim lock is honoured only while its recorded PID is alive (a
|
||||
// genuine concurrent reclaim); a stale one (dead/foreign PID) is removed once
|
||||
// and re-attempted so a crashed reclaimer cannot block boot indefinitely. Of
|
||||
// concurrent callers, O_EXCL ensures at most one acquires it; the rest fail
|
||||
// and back off to ErrLockHeld.
|
||||
func acquireReclaimLock(reclaimPath string) error {
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
f, err := os.OpenFile(reclaimPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err == nil {
|
||||
if _, werr := fmt.Fprintf(f, "%d\n", os.Getpid()); werr != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(reclaimPath)
|
||||
return fmt.Errorf("write reclaim lock %s: %v", reclaimPath, werr)
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
if !os.IsExist(err) {
|
||||
return fmt.Errorf("create reclaim lock %s: %v", reclaimPath, err)
|
||||
}
|
||||
// Reclaim lock present. A live owner means a real concurrent reclaim.
|
||||
if pid, perr := readLockPID(reclaimPath); perr == nil && processAlive(pid) {
|
||||
return fmt.Errorf("concurrent reclaim in progress (pid %d)", pid)
|
||||
}
|
||||
// Stale reclaim lock — clear it and retry the exclusive create once.
|
||||
if rerr := os.Remove(reclaimPath); rerr != nil && !os.IsNotExist(rerr) {
|
||||
return fmt.Errorf("remove stale reclaim lock %s: %v", reclaimPath, rerr)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("could not acquire reclaim lock %s after retry", reclaimPath)
|
||||
}
|
||||
|
||||
func readLockPID(path string) (int, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
pidStr := strings.TrimSpace(string(data))
|
||||
if pidStr == "" {
|
||||
return 0, errors.New("empty lockfile")
|
||||
}
|
||||
return strconv.Atoi(pidStr)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAcquireLockfile_FreshDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
release, err := AcquireLockfile(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("AcquireLockfile: %v", err)
|
||||
}
|
||||
defer release()
|
||||
|
||||
// Lockfile should exist with our PID.
|
||||
data, err := os.ReadFile(filepath.Join(dir, "tinyforge.lock"))
|
||||
if err != nil {
|
||||
t.Fatalf("read lockfile: %v", err)
|
||||
}
|
||||
want := fmt.Sprintf("%d\n", os.Getpid())
|
||||
if string(data) != want {
|
||||
t.Errorf("lockfile content = %q, want %q", data, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquireLockfile_HeldByLivePID_Refused(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Plant a lockfile holding the current PID (which is obviously alive).
|
||||
if err := os.WriteFile(filepath.Join(dir, "tinyforge.lock"),
|
||||
[]byte(fmt.Sprintf("%d\n", os.Getpid())), 0o600); err != nil {
|
||||
t.Fatalf("plant lockfile: %v", err)
|
||||
}
|
||||
release, err := AcquireLockfile(dir)
|
||||
if err == nil {
|
||||
release()
|
||||
t.Fatal("expected ErrLockHeld, got nil")
|
||||
}
|
||||
if !errors.Is(err, ErrLockHeld) {
|
||||
t.Errorf("error = %v, want wrap of ErrLockHeld", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquireLockfile_StalePID_Reclaimed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// PID 1 is init/launchd/systemd on POSIX and the System Idle Process
|
||||
// on Windows — never our process, and very unlikely to be dead. We
|
||||
// use a deliberately-impossible PID instead: a 31-bit value far
|
||||
// above any plausible system maximum.
|
||||
stalePID := 2147483640
|
||||
if err := os.WriteFile(filepath.Join(dir, "tinyforge.lock"),
|
||||
[]byte(fmt.Sprintf("%d\n", stalePID)), 0o600); err != nil {
|
||||
t.Fatalf("plant stale lockfile: %v", err)
|
||||
}
|
||||
release, err := AcquireLockfile(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("expected reclaim of stale lock, got: %v", err)
|
||||
}
|
||||
defer release()
|
||||
|
||||
// Verify it now holds OUR pid, not the stale one.
|
||||
data, err := os.ReadFile(filepath.Join(dir, "tinyforge.lock"))
|
||||
if err != nil {
|
||||
t.Fatalf("read lockfile after reclaim: %v", err)
|
||||
}
|
||||
want := fmt.Sprintf("%d\n", os.Getpid())
|
||||
if string(data) != want {
|
||||
t.Errorf("lockfile content after reclaim = %q, want %q", data, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquireLockfile_ConcurrentReclaim_SingleWinner(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Plant a stale lockfile (impossibly high, certainly-dead PID), then have
|
||||
// many goroutines race to reclaim it. Exactly one must win; the rest must
|
||||
// be refused with ErrLockHeld. A "last-writer-wins" reclaim would let
|
||||
// several goroutines all believe they own the lock.
|
||||
stalePID := 2147483640
|
||||
if err := os.WriteFile(filepath.Join(dir, "tinyforge.lock"),
|
||||
[]byte(fmt.Sprintf("%d\n", stalePID)), 0o600); err != nil {
|
||||
t.Fatalf("plant stale lockfile: %v", err)
|
||||
}
|
||||
|
||||
const n = 16
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
winners int
|
||||
releases []func()
|
||||
)
|
||||
start := make(chan struct{})
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
release, err := AcquireLockfile(dir)
|
||||
if err != nil {
|
||||
if !errors.Is(err, ErrLockHeld) {
|
||||
t.Errorf("loser error = %v, want wrap of ErrLockHeld", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
winners++
|
||||
releases = append(releases, release)
|
||||
mu.Unlock()
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
for _, r := range releases {
|
||||
r()
|
||||
}
|
||||
if winners != 1 {
|
||||
t.Fatalf("concurrent reclaim winners = %d, want exactly 1", winners)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquireLockfile_ReleaseRemovesFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
release, err := AcquireLockfile(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("AcquireLockfile: %v", err)
|
||||
}
|
||||
release()
|
||||
|
||||
path := filepath.Join(dir, "tinyforge.lock")
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Errorf("lockfile still present after release: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//go:build !windows
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// processAlive checks whether the given PID belongs to a running process.
|
||||
// On POSIX, kill(pid, 0) sends no signal but returns ESRCH if the PID is
|
||||
// dead, EPERM if alive-but-foreign-owned (still "alive" for our purposes).
|
||||
//
|
||||
// os.FindProcess never returns a non-nil error on Linux / macOS / *BSD
|
||||
// for any PID value — it just records the integer. The probe is purely
|
||||
// the Signal(0) result. We keep the FindProcess call to obtain the
|
||||
// *os.Process handle Signal needs; we don't branch on its error.
|
||||
func processAlive(pid int) bool {
|
||||
if pid <= 0 {
|
||||
return false
|
||||
}
|
||||
proc, _ := os.FindProcess(pid)
|
||||
if proc == nil {
|
||||
return false
|
||||
}
|
||||
err := proc.Signal(syscall.Signal(0))
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
// EPERM = alive but not ours; ESRCH = dead.
|
||||
return errors.Is(err, os.ErrPermission) || errors.Is(err, syscall.EPERM)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//go:build windows
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// processAlive returns true when the given PID is currently held by a
|
||||
// running Windows process. OpenProcess with PROCESS_QUERY_LIMITED_INFORMATION
|
||||
// is the supported way to check liveness without elevation.
|
||||
func processAlive(pid int) bool {
|
||||
if pid <= 0 {
|
||||
return false
|
||||
}
|
||||
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer windows.CloseHandle(h)
|
||||
var exitCode uint32
|
||||
if err := windows.GetExitCodeProcess(h, &exitCode); err != nil {
|
||||
// Conservative: if we can't ask, assume alive so we don't reclaim
|
||||
// an active lock. Worst case the operator sees ErrLockHeld and
|
||||
// removes the lockfile by hand.
|
||||
return true
|
||||
}
|
||||
const stillActive = 259 // STILL_ACTIVE
|
||||
return exitCode == stillActive
|
||||
}
|
||||
@@ -278,12 +278,20 @@ const (
|
||||
// containers.workload_kind and workloads.kind. After the hard cutover the
|
||||
// backing project / stack / static_site tables are gone — these constants
|
||||
// are just strings used to filter the unified containers index in the UI.
|
||||
//
|
||||
// `build` is the dockerfile-source kind: a container built from a
|
||||
// Dockerfile in a Git repo. Operationally it looks like a site (one
|
||||
// container, one optional public face) but its origin is the build
|
||||
// pipeline, not a static-asset extract. Dashboard filters that need to
|
||||
// distinguish "I built this from source" from "I served files from a
|
||||
// repo" should key on this value.
|
||||
type WorkloadKind string
|
||||
|
||||
const (
|
||||
WorkloadKindProject WorkloadKind = "project"
|
||||
WorkloadKindStack WorkloadKind = "stack"
|
||||
WorkloadKindSite WorkloadKind = "site"
|
||||
WorkloadKindBuild WorkloadKind = "build"
|
||||
)
|
||||
|
||||
// Workload is the unifying primitive that abstracts Project, Stack, and StaticSite.
|
||||
@@ -316,6 +324,31 @@ type Workload struct {
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// WorkloadNotification is one configured outbound notification route for
|
||||
// a workload. Multiple rows per workload model the "one Slack channel
|
||||
// for failures, one Discord webhook for successes" routing the legacy
|
||||
// single notification_url column could not express.
|
||||
//
|
||||
// EventTypes is a comma-separated allow-list (e.g. "build_failure" or
|
||||
// "deploy_success,deploy_failure"). An empty EventTypes means the row
|
||||
// fires for every event type — the cheapest way to keep the existing
|
||||
// single-destination behaviour expressible in the new shape.
|
||||
//
|
||||
// Secret round-trips through the same crypto envelope as other stored
|
||||
// secrets; the API layer strips it from responses.
|
||||
type WorkloadNotification struct {
|
||||
ID string `json:"id"`
|
||||
WorkloadID string `json:"workload_id"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Secret string `json:"-"`
|
||||
EventTypes string `json:"event_types"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Container is the normalized index of every Tinyforge-managed container.
|
||||
// Replaces the project-specific Instance table after migration. Subdomain/
|
||||
// proxy fields are hoisted as first-class columns because ListProxyRoutes,
|
||||
|
||||
+232
-2
@@ -55,11 +55,20 @@ func New(dbPath string) (*Store, error) {
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetConnMaxLifetime(0)
|
||||
|
||||
// Enable WAL mode and foreign keys for better concurrency and referential integrity.
|
||||
// Enable WAL mode and foreign keys for better concurrency and
|
||||
// referential integrity. `synchronous=NORMAL` pairs with WAL to skip
|
||||
// the per-write fsync — the OS still flushes on checkpoint, durability
|
||||
// is preserved across clean shutdowns, and crashes lose at most the
|
||||
// last few committed transactions (acceptable for a tinyforge box).
|
||||
// cache_size=-20000 = 20 MiB page cache, temp_store=MEMORY keeps
|
||||
// indexer scratch off disk; both are pure perf knobs.
|
||||
pragmas := []string{
|
||||
"PRAGMA journal_mode=WAL",
|
||||
"PRAGMA synchronous=NORMAL",
|
||||
"PRAGMA foreign_keys=ON",
|
||||
"PRAGMA busy_timeout=5000",
|
||||
"PRAGMA cache_size=-20000",
|
||||
"PRAGMA temp_store=MEMORY",
|
||||
}
|
||||
for _, p := range pragmas {
|
||||
if _, err := db.Exec(p); err != nil {
|
||||
@@ -284,6 +293,24 @@ func (s *Store) runMigrations() error {
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`,
|
||||
// workload_notifications: per-workload notification destinations.
|
||||
// Each row is one route (Slack channel, Discord webhook, generic
|
||||
// receiver, ...). event_types is a comma-separated allow-list —
|
||||
// empty means "all events". When zero rows exist for a workload
|
||||
// the dispatcher falls back to the legacy single notification_url
|
||||
// column on workloads so existing setups keep working unchanged.
|
||||
`CREATE TABLE IF NOT EXISTS workload_notifications (
|
||||
id TEXT PRIMARY KEY,
|
||||
workload_id TEXT NOT NULL REFERENCES workloads(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
secret TEXT NOT NULL DEFAULT '',
|
||||
event_types TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`,
|
||||
// workload_trigger_bindings: many-to-many between workloads and
|
||||
// triggers. binding_config is the per-binding override applied on
|
||||
// top of trigger.config (top-level JSON merge, binding wins).
|
||||
@@ -427,6 +454,7 @@ func (s *Store) runMigrations() error {
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_triggers_webhook_secret ON triggers(webhook_secret) WHERE webhook_secret != ''`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_bindings_workload ON workload_trigger_bindings(workload_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_bindings_trigger ON workload_trigger_bindings(trigger_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_workload_notifs_workload ON workload_notifications(workload_id)`,
|
||||
}
|
||||
for _, idx := range indexes {
|
||||
if _, err := s.db.Exec(idx); err != nil {
|
||||
@@ -434,13 +462,215 @@ func (s *Store) runMigrations() error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.backfillTriggersFromWorkloads(); err != nil {
|
||||
// schema_versions table gates one-shot data migrations like the
|
||||
// trigger backfill below. Without this, the backfill scan ran on
|
||||
// every boot even on fully-migrated DBs — wasted I/O and (more
|
||||
// importantly) made it impossible to tell whether a "no rows
|
||||
// processed" was a clean state or a missed-migration bug.
|
||||
if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_versions (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("create schema_versions: %w", err)
|
||||
}
|
||||
|
||||
if err := s.runOnce(1, "trigger backfill", s.backfillTriggersFromWorkloads); err != nil {
|
||||
// Backfill failure is non-fatal — we log and let the operator
|
||||
// retry. The version is only recorded on success.
|
||||
slog.Warn("trigger backfill", "error", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runOnce executes fn at most one time per database lifetime, recording
|
||||
// success in schema_versions. Useful for data migrations whose source
|
||||
// table eventually disappears (so re-running becomes pointless or
|
||||
// dangerous).
|
||||
func (s *Store) runOnce(version int, label string, fn func() error) error {
|
||||
var applied int
|
||||
if err := s.db.QueryRow(`SELECT COUNT(*) FROM schema_versions WHERE version = ?`, version).Scan(&applied); err != nil {
|
||||
return fmt.Errorf("check %s: %w", label, err)
|
||||
}
|
||||
if applied > 0 {
|
||||
return nil
|
||||
}
|
||||
if err := fn(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.Exec(`INSERT INTO schema_versions (version) VALUES (?)`, version); err != nil {
|
||||
return fmt.Errorf("mark %s applied: %w", label, err)
|
||||
}
|
||||
slog.Info("schema migration applied", "version", version, "label", label)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunOnce is the public counterpart of runOnce, exposed so cmd/server can
|
||||
// gate post-store-open migrations (e.g. crypto re-encryption that needs
|
||||
// the ENCRYPTION_KEY which Store does not own) through the same
|
||||
// schema_versions ledger.
|
||||
func (s *Store) RunOnce(version int, label string, fn func() error) error {
|
||||
return s.runOnce(version, label, fn)
|
||||
}
|
||||
|
||||
// EnvelopeMigrator describes the contract a crypto package implements to
|
||||
// rewrite legacy unprefixed-hex ciphertext as versioned envelope values.
|
||||
// hasEnvelope reports whether a value already carries the new prefix.
|
||||
// decrypt returns plaintext for either form; encrypt always produces the
|
||||
// new envelope form. By accepting closures the store stays free of any
|
||||
// import on internal/crypto, mirroring the rest of the package layout.
|
||||
type EnvelopeMigrator struct {
|
||||
HasEnvelope func(value string) bool
|
||||
Decrypt func(ciphertext string) (string, error)
|
||||
Encrypt func(plaintext string) (string, error)
|
||||
}
|
||||
|
||||
// MigrateSecretsToEnvelope walks every column known to carry an encrypted
|
||||
// secret and rewrites legacy unprefixed-hex values into the new
|
||||
// envelope form using the current encryption key.
|
||||
//
|
||||
// Behaviour, per-row:
|
||||
// - empty value → skip (no secret stored)
|
||||
// - already-envelope value → skip (already migrated)
|
||||
// - decrypt fails → skip (value is either plaintext from a v0 boot
|
||||
// OR ciphertext from a rotated key; either way we cannot safely
|
||||
// re-encrypt and leaving it alone preserves the existing read
|
||||
// semantics)
|
||||
// - decrypt succeeds → encrypt to envelope form + UPDATE
|
||||
//
|
||||
// The whole sweep runs in a single transaction so a power-loss
|
||||
// mid-migration leaves the DB in either the pre- or post-migration
|
||||
// state, never half. Idempotent via schema_versions version 2 — the
|
||||
// next boot is a no-op.
|
||||
//
|
||||
// Columns covered:
|
||||
// - settings.npm_password
|
||||
// - settings.cloudflare_api_token
|
||||
// - auth_settings.oidc_client_secret
|
||||
// - registries.token
|
||||
// - workload_env.value WHERE encrypted=1
|
||||
func (s *Store) MigrateSecretsToEnvelope(m EnvelopeMigrator) error {
|
||||
return s.runOnce(2, "secrets envelope migration", func() error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
// Single-row tables (settings, auth_settings) — read-update inline.
|
||||
singleRowColumns := []struct {
|
||||
table, column string
|
||||
}{
|
||||
{"settings", "npm_password"},
|
||||
{"settings", "cloudflare_api_token"},
|
||||
{"auth_settings", "oidc_client_secret"},
|
||||
}
|
||||
for _, c := range singleRowColumns {
|
||||
var v string
|
||||
err := tx.QueryRow(
|
||||
fmt.Sprintf(`SELECT %s FROM %s LIMIT 1`, c.column, c.table),
|
||||
).Scan(&v)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
continue
|
||||
}
|
||||
// auth_settings may not exist on a brand-new DB until
|
||||
// the OIDC code touches it; treat as nothing-to-migrate.
|
||||
slog.Debug("envelope migration: column read skipped",
|
||||
"table", c.table, "column", c.column, "error", err)
|
||||
continue
|
||||
}
|
||||
migrated, ok := tryMigrate(m, v)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
fmt.Sprintf(`UPDATE %s SET %s = ?`, c.table, c.column),
|
||||
migrated,
|
||||
); err != nil {
|
||||
return fmt.Errorf("update %s.%s: %w", c.table, c.column, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-row: registries.token
|
||||
if err := migrateRowColumn(tx, m,
|
||||
`SELECT id, token FROM registries WHERE token != ''`,
|
||||
`UPDATE registries SET token = ? WHERE id = ?`,
|
||||
); err != nil {
|
||||
return fmt.Errorf("registries.token: %w", err)
|
||||
}
|
||||
|
||||
// Multi-row: workload_env.value WHERE encrypted=1
|
||||
if err := migrateRowColumn(tx, m,
|
||||
`SELECT id, value FROM workload_env WHERE encrypted = 1 AND value != ''`,
|
||||
`UPDATE workload_env SET value = ? WHERE id = ?`,
|
||||
); err != nil {
|
||||
return fmt.Errorf("workload_env.value: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// migrateRowColumn applies the envelope rewrite to every (id, value)
|
||||
// pair returned by selectQ. updateQ takes (newValue, id) as parameters.
|
||||
// Each row is its own attempt; one row failing migration (decrypt fail)
|
||||
// does not abort the others.
|
||||
func migrateRowColumn(tx *sql.Tx, m EnvelopeMigrator, selectQ, updateQ string) error {
|
||||
rows, err := tx.Query(selectQ)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
type pending struct{ id, newValue string }
|
||||
var updates []pending
|
||||
for rows.Next() {
|
||||
var id, value string
|
||||
if err := rows.Scan(&id, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
newValue, ok := tryMigrate(m, value)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
updates = append(updates, pending{id, newValue})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, u := range updates {
|
||||
if _, err := tx.Exec(updateQ, u.newValue, u.id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tryMigrate returns the envelope-form ciphertext + true when the input
|
||||
// is a legacy unprefixed value that decrypts successfully with the
|
||||
// current key. Returns ("", false) for anything else: empty, already
|
||||
// envelope, plaintext, or decrypt-failed (rotated-key case).
|
||||
func tryMigrate(m EnvelopeMigrator, v string) (string, bool) {
|
||||
if v == "" {
|
||||
return "", false
|
||||
}
|
||||
if m.HasEnvelope(v) {
|
||||
return "", false
|
||||
}
|
||||
plaintext, err := m.Decrypt(v)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
enc, err := m.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return enc, true
|
||||
}
|
||||
|
||||
// backfillTriggersFromWorkloads converts embedded trigger config on
|
||||
// workload rows into standalone trigger + binding rows. Runs once per
|
||||
// boot and is idempotent — only workloads with non-empty trigger_kind
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const workloadNotificationColumns = `id, workload_id, name, url, secret,
|
||||
event_types, enabled, sort_order, created_at, updated_at`
|
||||
|
||||
func scanWorkloadNotification(scanner interface{ Scan(...any) error }) (WorkloadNotification, error) {
|
||||
var n WorkloadNotification
|
||||
var enabled int
|
||||
err := scanner.Scan(
|
||||
&n.ID, &n.WorkloadID, &n.Name, &n.URL, &n.Secret,
|
||||
&n.EventTypes, &enabled, &n.SortOrder, &n.CreatedAt, &n.UpdatedAt,
|
||||
)
|
||||
n.Enabled = enabled != 0
|
||||
return n, err
|
||||
}
|
||||
|
||||
// CreateWorkloadNotification inserts a notification route. Returns the
|
||||
// populated row (with assigned id + timestamps) so callers don't need to
|
||||
// follow up with a Get.
|
||||
func (s *Store) CreateWorkloadNotification(n WorkloadNotification) (WorkloadNotification, error) {
|
||||
if n.WorkloadID == "" {
|
||||
return WorkloadNotification{}, fmt.Errorf("workload_id is required")
|
||||
}
|
||||
if n.URL == "" {
|
||||
return WorkloadNotification{}, fmt.Errorf("url is required")
|
||||
}
|
||||
if n.ID == "" {
|
||||
n.ID = uuid.New().String()
|
||||
}
|
||||
n.CreatedAt = Now()
|
||||
n.UpdatedAt = n.CreatedAt
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO workload_notifications (`+workloadNotificationColumns+`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
n.ID, n.WorkloadID, n.Name, n.URL, n.Secret,
|
||||
n.EventTypes, BoolToInt(n.Enabled), n.SortOrder, n.CreatedAt, n.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return WorkloadNotification{}, fmt.Errorf("insert workload_notification: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ListWorkloadNotifications returns every notification row for a
|
||||
// workload ordered by (sort_order, created_at) so the UI stays stable
|
||||
// across reorderings.
|
||||
func (s *Store) ListWorkloadNotifications(workloadID string) ([]WorkloadNotification, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT `+workloadNotificationColumns+`
|
||||
FROM workload_notifications
|
||||
WHERE workload_id = ?
|
||||
ORDER BY sort_order, created_at`,
|
||||
workloadID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list workload_notifications: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []WorkloadNotification{}
|
||||
for rows.Next() {
|
||||
n, err := scanWorkloadNotification(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan workload_notification: %w", err)
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetWorkloadNotification fetches one notification row by id. Returns
|
||||
// ErrNotFound when the row does not exist so callers can return 404
|
||||
// cleanly.
|
||||
func (s *Store) GetWorkloadNotification(id string) (WorkloadNotification, error) {
|
||||
n, err := scanWorkloadNotification(s.db.QueryRow(
|
||||
`SELECT `+workloadNotificationColumns+`
|
||||
FROM workload_notifications WHERE id = ?`, id,
|
||||
))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return WorkloadNotification{}, fmt.Errorf("workload_notification %s: %w", id, ErrNotFound)
|
||||
}
|
||||
if err != nil {
|
||||
return WorkloadNotification{}, fmt.Errorf("query workload_notification: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// UpdateWorkloadNotification rewrites an existing row. WorkloadID is
|
||||
// immutable — re-anchoring a route to a different workload would invite
|
||||
// silent reassignments after a paste-bug in the UI; recreate instead.
|
||||
func (s *Store) UpdateWorkloadNotification(n WorkloadNotification) error {
|
||||
if n.ID == "" {
|
||||
return fmt.Errorf("id is required")
|
||||
}
|
||||
if n.URL == "" {
|
||||
return fmt.Errorf("url is required")
|
||||
}
|
||||
n.UpdatedAt = Now()
|
||||
res, err := s.db.Exec(
|
||||
`UPDATE workload_notifications
|
||||
SET name = ?, url = ?, secret = ?, event_types = ?,
|
||||
enabled = ?, sort_order = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
n.Name, n.URL, n.Secret, n.EventTypes,
|
||||
BoolToInt(n.Enabled), n.SortOrder, n.UpdatedAt, n.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update workload_notification: %w", err)
|
||||
}
|
||||
rows, _ := res.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("workload_notification %s: %w", n.ID, ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteWorkloadNotification drops a single notification row.
|
||||
// Idempotent: missing id returns ErrNotFound so the API can map it to
|
||||
// 404 cleanly.
|
||||
func (s *Store) DeleteWorkloadNotification(id string) error {
|
||||
res, err := s.db.Exec(`DELETE FROM workload_notifications WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete workload_notification: %w", err)
|
||||
}
|
||||
rows, _ := res.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("workload_notification %s: %w", id, ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MatchesEventType returns true when the notification row's EventTypes
|
||||
// allow-list includes eventType (or is empty, meaning "match all").
|
||||
// Helper exported so the notification dispatcher can fan-out filtering
|
||||
// inline without duplicating the comma-split parser.
|
||||
func (n WorkloadNotification) MatchesEventType(eventType string) bool {
|
||||
if !n.Enabled {
|
||||
return false
|
||||
}
|
||||
if n.EventTypes == "" {
|
||||
return true
|
||||
}
|
||||
for _, et := range strings.Split(n.EventTypes, ",") {
|
||||
if strings.TrimSpace(et) == eventType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// seedWorkloadForNotifications creates a minimal workload row so the FK
|
||||
// constraint on workload_notifications is satisfied. Returns the new
|
||||
// workload's ID for tests to reference.
|
||||
func seedWorkloadForNotifications(t *testing.T, s *Store, name string) string {
|
||||
t.Helper()
|
||||
w, err := s.CreateWorkload(Workload{
|
||||
Kind: string(WorkloadKindProject),
|
||||
Name: name,
|
||||
SourceKind: "image",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed workload: %v", err)
|
||||
}
|
||||
return w.ID
|
||||
}
|
||||
|
||||
func TestCreateWorkloadNotification_RoundTrip(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
wlID := seedWorkloadForNotifications(t, s, "app1")
|
||||
|
||||
created, err := s.CreateWorkloadNotification(WorkloadNotification{
|
||||
WorkloadID: wlID,
|
||||
Name: "Slack alerts",
|
||||
URL: "https://hooks.slack.test/x",
|
||||
Secret: "shh",
|
||||
EventTypes: "deploy_failure,build_failure",
|
||||
Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorkloadNotification: %v", err)
|
||||
}
|
||||
if created.ID == "" {
|
||||
t.Fatal("expected ID to be assigned")
|
||||
}
|
||||
|
||||
got, err := s.GetWorkloadNotification(created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got.URL != "https://hooks.slack.test/x" || got.Name != "Slack alerts" {
|
||||
t.Errorf("row mismatch: %+v", got)
|
||||
}
|
||||
if !got.Enabled {
|
||||
t.Error("expected Enabled=true")
|
||||
}
|
||||
if got.EventTypes != "deploy_failure,build_failure" {
|
||||
t.Errorf("event_types = %q", got.EventTypes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWorkloadNotification_RejectsMissingURL(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
wlID := seedWorkloadForNotifications(t, s, "app1")
|
||||
_, err := s.CreateWorkloadNotification(WorkloadNotification{
|
||||
WorkloadID: wlID,
|
||||
Name: "broken",
|
||||
URL: "",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected URL validation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListWorkloadNotifications_SortedByOrder(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
wlID := seedWorkloadForNotifications(t, s, "app1")
|
||||
|
||||
// Insert out of order; ListWorkloadNotifications should return
|
||||
// them sorted by SortOrder ascending.
|
||||
_, _ = s.CreateWorkloadNotification(WorkloadNotification{
|
||||
WorkloadID: wlID, Name: "C", URL: "https://c.test", SortOrder: 30,
|
||||
})
|
||||
_, _ = s.CreateWorkloadNotification(WorkloadNotification{
|
||||
WorkloadID: wlID, Name: "A", URL: "https://a.test", SortOrder: 10,
|
||||
})
|
||||
_, _ = s.CreateWorkloadNotification(WorkloadNotification{
|
||||
WorkloadID: wlID, Name: "B", URL: "https://b.test", SortOrder: 20,
|
||||
})
|
||||
|
||||
rows, err := s.ListWorkloadNotifications(wlID)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("len = %d, want 3", len(rows))
|
||||
}
|
||||
if rows[0].Name != "A" || rows[1].Name != "B" || rows[2].Name != "C" {
|
||||
t.Errorf("sort order wrong: %q %q %q", rows[0].Name, rows[1].Name, rows[2].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateWorkloadNotification_PersistsChanges(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
wlID := seedWorkloadForNotifications(t, s, "app1")
|
||||
n, _ := s.CreateWorkloadNotification(WorkloadNotification{
|
||||
WorkloadID: wlID, Name: "old", URL: "https://old.test", Enabled: true,
|
||||
})
|
||||
n.Name = "new"
|
||||
n.URL = "https://new.test"
|
||||
n.Enabled = false
|
||||
n.EventTypes = "deploy_success"
|
||||
if err := s.UpdateWorkloadNotification(n); err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
got, _ := s.GetWorkloadNotification(n.ID)
|
||||
if got.Name != "new" || got.URL != "https://new.test" || got.Enabled {
|
||||
t.Errorf("update did not persist: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteWorkloadNotification_ReturnsNotFoundForMissing(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
err := s.DeleteWorkloadNotification("nope")
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteWorkloadNotification_CascadesFromWorkload(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
wlID := seedWorkloadForNotifications(t, s, "app1")
|
||||
_, _ = s.CreateWorkloadNotification(WorkloadNotification{
|
||||
WorkloadID: wlID, Name: "x", URL: "https://x.test",
|
||||
})
|
||||
if err := s.DeleteWorkload(wlID); err != nil {
|
||||
t.Fatalf("delete workload: %v", err)
|
||||
}
|
||||
rows, err := s.ListWorkloadNotifications(wlID)
|
||||
if err != nil {
|
||||
t.Fatalf("list after cascade: %v", err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Errorf("expected cascade delete to remove rows, got %d", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesEventType_AllowList(t *testing.T) {
|
||||
cases := []struct {
|
||||
eventTypes string
|
||||
probe string
|
||||
want bool
|
||||
}{
|
||||
{"", "deploy_success", true}, // empty = all
|
||||
{"deploy_success,deploy_failure", "deploy_success", true},
|
||||
{"deploy_success,deploy_failure", "build_failure", false},
|
||||
{"build_failure", "build_failure", true},
|
||||
{" deploy_success , build_failure ", "build_failure", true}, // whitespace tolerated
|
||||
}
|
||||
for _, c := range cases {
|
||||
n := WorkloadNotification{Enabled: true, EventTypes: c.eventTypes}
|
||||
got := n.MatchesEventType(c.probe)
|
||||
if got != c.want {
|
||||
t.Errorf("MatchesEventType(%q, %q) = %v, want %v", c.eventTypes, c.probe, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesEventType_DisabledNeverMatches(t *testing.T) {
|
||||
n := WorkloadNotification{Enabled: false, EventTypes: ""}
|
||||
if n.MatchesEventType("any") {
|
||||
t.Error("disabled row should never match")
|
||||
}
|
||||
}
|
||||
@@ -173,11 +173,24 @@ func (s *Store) UpdateWorkload(w Workload) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteWorkload removes a workload row. Cascading deletes for the matching
|
||||
// project/stack/site row stay with the kind-specific Delete functions; this
|
||||
// only removes the workload entry.
|
||||
// DeleteWorkload removes a workload row. Cascading deletes for FK-backed
|
||||
// child tables (workload_env, workload_volumes, workload_trigger_bindings)
|
||||
// happen via SQLite's ON DELETE CASCADE. The `containers` table doesn't
|
||||
// yet have an FK to workloads (planned migration — see ops notes), so we
|
||||
// drop its rows explicitly here in the same transaction to prevent zombie
|
||||
// container rows from outliving their owning workload.
|
||||
func (s *Store) DeleteWorkload(id string) error {
|
||||
result, err := s.db.Exec(`DELETE FROM workloads WHERE id = ?`, id)
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
// Explicit container cleanup until the FK migration lands.
|
||||
if _, err := tx.Exec(`DELETE FROM containers WHERE workload_id = ?`, id); err != nil {
|
||||
return fmt.Errorf("delete containers: %w", err)
|
||||
}
|
||||
result, err := tx.Exec(`DELETE FROM workloads WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete workload: %w", err)
|
||||
}
|
||||
@@ -188,6 +201,9 @@ func (s *Store) DeleteWorkload(id string) error {
|
||||
if n == 0 {
|
||||
return fmt.Errorf("workload %s: %w", id, ErrNotFound)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -169,6 +169,18 @@ func SaveFile(rootPath, relativePath string, r io.Reader) error {
|
||||
|
||||
// safePath resolves a relative path within rootPath and validates it doesn't escape.
|
||||
// Resolves symlinks to prevent symlink-based traversal attacks.
|
||||
//
|
||||
// The check used to be `strings.HasPrefix(absResolved, absRoot)` which has
|
||||
// a classic boundary bug: a sibling root at /data/vol10 would pass the
|
||||
// prefix test for /data/vol1. The fix enforces a separator boundary so
|
||||
// the only allowed cases are absResolved == absRoot OR absResolved begins
|
||||
// with absRoot + separator.
|
||||
//
|
||||
// For paths that don't yet exist (e.g. SaveFile creating a new file),
|
||||
// EvalSymlinks returns an error and we fall back to the lexical path.
|
||||
// In that case we walk every existing ancestor with EvalSymlinks too —
|
||||
// if any ancestor is a symlink that escapes the root, we reject. This
|
||||
// closes the prior gap where pre-planted symlinks could divert writes.
|
||||
func safePath(rootPath, relativePath string) (string, error) {
|
||||
if relativePath == "" {
|
||||
return rootPath, nil
|
||||
@@ -176,7 +188,7 @@ func safePath(rootPath, relativePath string) (string, error) {
|
||||
|
||||
// Clean and ensure no traversal.
|
||||
cleaned := filepath.Clean(relativePath)
|
||||
if strings.Contains(cleaned, "..") {
|
||||
if cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) || strings.Contains(cleaned, string(filepath.Separator)+".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("path traversal not allowed")
|
||||
}
|
||||
|
||||
@@ -191,18 +203,66 @@ func safePath(rootPath, relativePath string) (string, error) {
|
||||
absRoot = realRoot
|
||||
}
|
||||
|
||||
// Resolve the target path including symlinks.
|
||||
// Resolve the target path. If the leaf doesn't exist (write path),
|
||||
// walk parent directories — any of which may already be a symlink.
|
||||
absResolved, err := filepath.Abs(absPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve path: %w", err)
|
||||
}
|
||||
if realResolved, err := filepath.EvalSymlinks(absResolved); err == nil {
|
||||
absResolved = realResolved
|
||||
} else {
|
||||
// Leaf missing — resolve the deepest existing ancestor and
|
||||
// re-join the unresolved tail. This catches a pre-planted
|
||||
// symlink in any parent dir. An error here means an ancestor
|
||||
// could not be resolved (e.g. a symlink we cannot follow): we MUST
|
||||
// reject rather than fall back to the lexical path, which still
|
||||
// carries the absRoot prefix and would let a symlink ancestor that
|
||||
// escapes the root slip past the boundary check below.
|
||||
resolved, tailErr := resolveExistingAncestor(absResolved)
|
||||
if tailErr != nil {
|
||||
return "", fmt.Errorf("path traversal not allowed")
|
||||
}
|
||||
if resolved != "" {
|
||||
absResolved = resolved
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(absResolved, absRoot) {
|
||||
if absResolved != absRoot && !strings.HasPrefix(absResolved, absRoot+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("path traversal not allowed")
|
||||
}
|
||||
|
||||
return absPath, nil
|
||||
}
|
||||
|
||||
// resolveExistingAncestor walks p upward until it finds an existing
|
||||
// directory, resolves its symlinks, then rejoins the missing tail.
|
||||
// Returns ("", nil) when no ancestor exists (vanishingly rare).
|
||||
func resolveExistingAncestor(p string) (string, error) {
|
||||
tail := ""
|
||||
cur := p
|
||||
for {
|
||||
if cur == "" || cur == "/" || cur == filepath.VolumeName(cur)+string(filepath.Separator) {
|
||||
return "", nil
|
||||
}
|
||||
info, err := os.Lstat(cur)
|
||||
if err == nil {
|
||||
real, rerr := filepath.EvalSymlinks(cur)
|
||||
if rerr != nil {
|
||||
return "", rerr
|
||||
}
|
||||
_ = info
|
||||
if tail == "" {
|
||||
return real, nil
|
||||
}
|
||||
return filepath.Join(real, tail), nil
|
||||
}
|
||||
// Move one level up.
|
||||
parent := filepath.Dir(cur)
|
||||
if parent == cur {
|
||||
return "", nil
|
||||
}
|
||||
tail = filepath.Join(filepath.Base(cur), tail)
|
||||
cur = parent
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,8 +131,14 @@ const maxWebhookBodyBytes = 256 * 1024 // 256 KiB
|
||||
// PluginDispatcher is what the plugin-workload webhook handler needs from
|
||||
// the deployer: the canonical Source-dispatch entry point plus access to
|
||||
// the same Deps bundle so Trigger.Match can read store / crypto.
|
||||
//
|
||||
// DispatchTeardown is required so the preview-deploy flow can tear down
|
||||
// an ephemeral per-branch child workload when its upstream branch is
|
||||
// deleted. Same teardown path the API /workloads/{id} DELETE route uses;
|
||||
// nil error on a clean teardown lets the caller delete the workload row.
|
||||
type PluginDispatcher interface {
|
||||
DispatchPlugin(ctx context.Context, w pluginWorkload, intent pluginIntent) error
|
||||
DispatchTeardown(ctx context.Context, w pluginWorkload) error
|
||||
PluginDeps() pluginDeps
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,10 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/metrics"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
"github.com/alexei/tinyforge/internal/workload/preview"
|
||||
)
|
||||
|
||||
// maxTriggerFanOutConcurrency caps how many bindings dispatch in
|
||||
@@ -44,6 +46,17 @@ const (
|
||||
ReasonConfigError = "config merge error"
|
||||
ReasonMatchError = "match error"
|
||||
ReasonDispatchFailed = "dispatch failed"
|
||||
ReasonPreviewError = "preview materialize error"
|
||||
ReasonPreviewTorndown = "preview torn down"
|
||||
// ReasonPreviewNoop: a branch-delete webhook arrived but no preview was
|
||||
// ever materialized for that branch — a legitimate clean skip, distinct
|
||||
// from "no binding matched" so it isn't misreported as a wiring problem.
|
||||
ReasonPreviewNoop = "preview noop"
|
||||
// ReasonPreviewOrphaned: the preview container was torn down but its
|
||||
// workload row could not be deleted, leaving an orphan row. Surfaced
|
||||
// distinctly so the partial failure is visible rather than masquerading
|
||||
// as a clean teardown.
|
||||
ReasonPreviewOrphaned = "preview torn down (row orphaned)"
|
||||
)
|
||||
|
||||
// handleTriggerWebhook processes an inbound webhook for a first-class
|
||||
@@ -172,7 +185,7 @@ func (h *Handler) handleTriggerWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Deployed:
|
||||
deployed++
|
||||
case r.Reason == ReasonBindingDisabled:
|
||||
case r.Reason == ReasonBindingDisabled, r.Reason == ReasonPreviewNoop:
|
||||
skipped++
|
||||
case r.Reason == ReasonNoMatch:
|
||||
noMatch++
|
||||
@@ -194,8 +207,10 @@ func (h *Handler) handleTriggerWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
case noMatch == len(results)-skipped:
|
||||
delivery.Detail = "no binding matched"
|
||||
default:
|
||||
delivery.Detail = fmt.Sprintf("matched=0 skipped=%d errored=%d", skipped, errored)
|
||||
delivery.Detail = fmt.Sprintf("matched=0 skipped=%d errored=%d nomatch=%d",
|
||||
skipped, errored, noMatch)
|
||||
}
|
||||
metrics.WebhookDeliveriesTotal.Inc(delivery.Outcome)
|
||||
respondWebhookJSON(w, http.StatusOK, map[string]any{
|
||||
"success": true,
|
||||
"trigger": trg.Name,
|
||||
@@ -326,6 +341,18 @@ func (h *Handler) fireBinding(
|
||||
if intent.TriggeredBy == "" {
|
||||
intent.TriggeredBy = "trigger-webhook"
|
||||
}
|
||||
|
||||
// Preview-deploy fork: the git trigger plugin attaches preview_branch
|
||||
// metadata when BranchPattern matches a non-baseline branch. Route
|
||||
// the dispatch through a per-branch child workload rather than
|
||||
// redeploying the parent template. The fork is intentionally before
|
||||
// the dispatch so the template's container never gets clobbered by
|
||||
// a feature-branch push.
|
||||
if previewBranch := intent.Metadata["preview_branch"]; previewBranch != "" {
|
||||
fired, reason := h.handlePreviewIntent(ctx, row, intent, previewBranch)
|
||||
return fired, reason
|
||||
}
|
||||
|
||||
if err := h.plugins.DispatchPlugin(ctx, pwl, *intent); err != nil {
|
||||
slog.Warn("webhook: dispatch failed",
|
||||
"trigger", trg.Name, "workload", row.Name, "error", err)
|
||||
@@ -336,3 +363,72 @@ func (h *Handler) fireBinding(
|
||||
return true, intent.Reason
|
||||
}
|
||||
|
||||
// handlePreviewIntent dispatches an intent that targeted a non-baseline
|
||||
// branch on a preview-template workload. Two paths:
|
||||
//
|
||||
// 1. Branch deleted: find the matching preview workload, dispatch
|
||||
// Teardown, then delete the workload row so the dashboard reflects
|
||||
// the upstream state.
|
||||
// 2. Branch pushed: materialize (or reuse) the preview workload, then
|
||||
// dispatch the deploy against it. The template workload itself is
|
||||
// never deployed against a feature branch.
|
||||
//
|
||||
// On any error the helper logs and returns a generic reason — the
|
||||
// fan-out caller treats these the same as a normal dispatch failure.
|
||||
func (h *Handler) handlePreviewIntent(
|
||||
ctx context.Context,
|
||||
template store.Workload,
|
||||
intent *plugin.DeploymentIntent,
|
||||
branch string,
|
||||
) (bool, string) {
|
||||
deleted := intent.Metadata["preview_deleted"] == "1"
|
||||
if deleted {
|
||||
child, ok, err := preview.FindPreviewForBranch(h.store, template.ID, branch)
|
||||
if err != nil {
|
||||
slog.Warn("webhook: preview lookup failed",
|
||||
"template", template.Name, "branch", branch, "error", err)
|
||||
return false, ReasonPreviewError
|
||||
}
|
||||
if !ok {
|
||||
// Branch was deleted upstream but we never materialized a
|
||||
// preview for it — nothing to do. Report as a distinct noop so
|
||||
// it isn't bucketed as "no binding matched".
|
||||
return false, ReasonPreviewNoop
|
||||
}
|
||||
childPwl := toPluginWorkload(child)
|
||||
if err := h.plugins.DispatchTeardown(ctx, childPwl); err != nil {
|
||||
slog.Warn("webhook: preview teardown dispatch failed",
|
||||
"template", template.Name, "preview", child.Name, "error", err)
|
||||
return false, ReasonDispatchFailed
|
||||
}
|
||||
if err := h.store.DeleteWorkload(child.ID); err != nil {
|
||||
// Container is gone but the row is orphaned. Surface this as a
|
||||
// distinct reason so the partial failure is visible rather than
|
||||
// reported as a clean teardown; the operator can delete the row
|
||||
// from the dashboard if it sticks around.
|
||||
slog.Warn("webhook: preview row delete failed (orphaned row)",
|
||||
"template", template.Name, "preview", child.Name, "error", err)
|
||||
return true, ReasonPreviewOrphaned
|
||||
}
|
||||
slog.Info("webhook: preview torn down",
|
||||
"template", template.Name, "branch", branch, "preview", child.Name)
|
||||
return true, ReasonPreviewTorndown
|
||||
}
|
||||
|
||||
child, err := preview.MaterializeForBranch(h.store, template, branch)
|
||||
if err != nil {
|
||||
slog.Warn("webhook: preview materialize failed",
|
||||
"template", template.Name, "branch", branch, "error", err)
|
||||
return false, ReasonPreviewError
|
||||
}
|
||||
childPwl := toPluginWorkload(child)
|
||||
if err := h.plugins.DispatchPlugin(ctx, childPwl, *intent); err != nil {
|
||||
slog.Warn("webhook: preview dispatch failed",
|
||||
"template", template.Name, "preview", child.Name, "error", err)
|
||||
return false, ReasonDispatchFailed
|
||||
}
|
||||
slog.Info("webhook: triggered preview deploy",
|
||||
"template", template.Name, "branch", branch, "preview", child.Name, "reason", intent.Reason)
|
||||
return true, intent.Reason
|
||||
}
|
||||
|
||||
|
||||
@@ -327,6 +327,10 @@ func parseGitLabPushEvent(body []byte, headers http.Header) vendorParseResult {
|
||||
Ref: probe.Ref,
|
||||
CommitSHA: probe.After,
|
||||
Pusher: pusher,
|
||||
// GitLab does not emit `deleted: true`; the canonical signal
|
||||
// is an all-zero `after` SHA. Same parser helper used for the
|
||||
// GitHub / Gitea fallback so the two branches agree.
|
||||
Deleted: isZeroSHA(probe.After),
|
||||
},
|
||||
}
|
||||
if strings.HasPrefix(probe.Ref, "refs/heads/") {
|
||||
@@ -346,6 +350,7 @@ func parseGenericGitPush(body []byte) (plugin.InboundEvent, error) {
|
||||
var probe struct {
|
||||
Ref string `json:"ref"`
|
||||
After string `json:"after"`
|
||||
Deleted bool `json:"deleted"`
|
||||
Repository struct {
|
||||
FullName string `json:"full_name"`
|
||||
CloneURL string `json:"clone_url"`
|
||||
@@ -370,6 +375,12 @@ func parseGenericGitPush(body []byte) (plugin.InboundEvent, error) {
|
||||
if pusher == "" {
|
||||
pusher = probe.Pusher.Username
|
||||
}
|
||||
// Branch / tag deletion is signalled either by the explicit
|
||||
// `deleted: true` flag (GitHub / Gitea) or by an all-zero `after`
|
||||
// SHA (older shapes). Both are honoured so the preview-deploy flow
|
||||
// can tear down ephemeral workloads even when a vendor omits the
|
||||
// boolean flag.
|
||||
deleted := probe.Deleted || isZeroSHA(probe.After)
|
||||
evt := plugin.InboundEvent{
|
||||
Kind: "git-push",
|
||||
Git: &plugin.GitEvent{
|
||||
@@ -377,6 +388,7 @@ func parseGenericGitPush(body []byte) (plugin.InboundEvent, error) {
|
||||
Ref: probe.Ref,
|
||||
CommitSHA: probe.After,
|
||||
Pusher: pusher,
|
||||
Deleted: deleted,
|
||||
},
|
||||
}
|
||||
if strings.HasPrefix(probe.Ref, "refs/heads/") {
|
||||
@@ -388,3 +400,19 @@ func parseGenericGitPush(body []byte) (plugin.InboundEvent, error) {
|
||||
}
|
||||
return evt, nil
|
||||
}
|
||||
|
||||
// isZeroSHA returns true when sha is the canonical "no commit" sentinel
|
||||
// (40 zeros) that vendors emit on the `after` field of a branch- or
|
||||
// tag-delete push event. Length-tolerant because some test fixtures
|
||||
// truncate the SHA.
|
||||
func isZeroSHA(sha string) bool {
|
||||
if sha == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range sha {
|
||||
if r != '0' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(sha) >= 7
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/crypto"
|
||||
"github.com/alexei/tinyforge/internal/notify"
|
||||
)
|
||||
|
||||
// DispatchNotificationForWorkload sends `event` to every notification
|
||||
// route configured for the workload. Resolution order:
|
||||
//
|
||||
// 1. workload_notifications rows matching `event.Type` — multi-route
|
||||
// fan-out (e.g. Slack alerts + Discord successes per workload).
|
||||
// 2. If zero matching rows AND the legacy single-URL columns on the
|
||||
// workload row are set, send to that URL — backwards compat for
|
||||
// installs that pre-date the new table.
|
||||
// 3. Otherwise, fall through to settings.notification_url so the global
|
||||
// destination still fires for workloads with no per-row config.
|
||||
//
|
||||
// Secrets are decrypted via deps.EncKey before sending. A failed decrypt
|
||||
// degrades to "send unsigned" with a warning rather than dropping the
|
||||
// notification — the operator still gets the alert, they just need to
|
||||
// re-save the secret. Fire-and-forget: failures are logged inside
|
||||
// deps.Notifier and never bubble up here.
|
||||
//
|
||||
// Callers (static / dockerfile / image / compose plugins) pass an
|
||||
// already-populated Event; this helper does not synthesize the payload
|
||||
// shape, only the routing.
|
||||
func DispatchNotificationForWorkload(deps Deps, w Workload, event notify.Event) {
|
||||
if deps.Notifier == nil {
|
||||
return
|
||||
}
|
||||
rows, err := deps.Store.ListWorkloadNotifications(w.ID)
|
||||
if err != nil {
|
||||
slog.Warn("notify: list workload routes failed",
|
||||
"workload", w.ID, "error", err)
|
||||
rows = nil
|
||||
}
|
||||
|
||||
matched := 0
|
||||
for _, n := range rows {
|
||||
if !n.MatchesEventType(event.Type) {
|
||||
continue
|
||||
}
|
||||
matched++
|
||||
secret := ""
|
||||
if n.Secret != "" {
|
||||
dec, derr := crypto.Decrypt(deps.EncKey, n.Secret)
|
||||
if derr != nil {
|
||||
slog.Warn("notify: decrypt workload secret failed — sending unsigned",
|
||||
"workload", w.ID, "route", n.Name, "error", derr)
|
||||
} else {
|
||||
secret = dec
|
||||
}
|
||||
}
|
||||
deps.Notifier.SendSigned(n.URL, secret, notify.TierSite, event)
|
||||
}
|
||||
if matched > 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Legacy fallback: single per-workload destination on workloads.notification_url.
|
||||
if w.NotificationURL != "" {
|
||||
deps.Notifier.SendSigned(w.NotificationURL, w.NotificationSecret, notify.TierSite, event)
|
||||
return
|
||||
}
|
||||
|
||||
// Global fallback so a one-line config in settings still notifies
|
||||
// every workload without a per-row override.
|
||||
settings, err := deps.Store.GetSettings()
|
||||
if err != nil {
|
||||
slog.Warn("notify: settings lookup for global fallback failed",
|
||||
"workload", w.ID, "error", err)
|
||||
return
|
||||
}
|
||||
if settings.NotificationURL == "" {
|
||||
return
|
||||
}
|
||||
deps.Notifier.SendSigned(settings.NotificationURL, settings.NotificationSecret, notify.TierSettings, event)
|
||||
}
|
||||
@@ -32,6 +32,23 @@ type Config struct {
|
||||
|
||||
type source struct{}
|
||||
|
||||
// composeRunner is the slice of stack.Compose this plugin actually
|
||||
// drives. Defined locally per the "interfaces where they are used"
|
||||
// idiom so the plugin can be unit-tested without a real docker compose
|
||||
// binary. `*stack.Compose` satisfies it implicitly.
|
||||
type composeRunner interface {
|
||||
Up(ctx context.Context, projectName, yamlPath string) (string, error)
|
||||
Down(ctx context.Context, projectName string, removeVolumes bool) (string, error)
|
||||
Ps(ctx context.Context, projectName, yamlPath string) ([]stack.Service, error)
|
||||
}
|
||||
|
||||
// newComposeRunner returns the runner the plugin should call. Tests
|
||||
// swap this var with a fake; production code never touches it. The
|
||||
// indirection costs one function-pointer dereference per Deploy /
|
||||
// Teardown / Reconcile call — negligible against the docker compose
|
||||
// exec it gates.
|
||||
var newComposeRunner = func() composeRunner { return stack.NewCompose("") }
|
||||
|
||||
func init() { plugin.RegisterSource(&source{}) }
|
||||
|
||||
func (*source) Kind() string { return "compose" }
|
||||
@@ -82,7 +99,7 @@ func (*source) Deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload,
|
||||
return fmt.Errorf("compose source: write yaml: %w", err)
|
||||
}
|
||||
|
||||
compose := stack.NewCompose("")
|
||||
compose := newComposeRunner()
|
||||
out, err := compose.Up(ctx, projectName, yamlPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compose source: docker compose up: %w (output: %s)", err, truncate(out, 1024))
|
||||
@@ -105,7 +122,7 @@ func (*source) Teardown(ctx context.Context, deps plugin.Deps, w plugin.Workload
|
||||
cfg, _ := plugin.SourceConfigOf[Config](w)
|
||||
projectName := composeProjectName(cfg.ComposeProjectName, w)
|
||||
|
||||
compose := stack.NewCompose("")
|
||||
compose := newComposeRunner()
|
||||
if _, err := compose.Down(ctx, projectName, true); err != nil {
|
||||
// Log but proceed — the DB rows must not be orphaned.
|
||||
slog.Warn("compose source: docker compose down", "workload", w.ID, "error", err)
|
||||
@@ -139,7 +156,7 @@ func (*source) Reconcile(ctx context.Context, deps plugin.Deps, w plugin.Workloa
|
||||
projectName := composeProjectName(cfg.ComposeProjectName, w)
|
||||
yamlPath, _ := writeYAMLIfChanged(w.ID, cfg.ComposeYAML)
|
||||
|
||||
compose := stack.NewCompose("")
|
||||
compose := newComposeRunner()
|
||||
services, err := compose.Ps(ctx, projectName, yamlPath)
|
||||
if err != nil {
|
||||
// Likely no compose project running for this workload. Mark
|
||||
@@ -162,7 +179,7 @@ func (*source) Reconcile(ctx context.Context, deps plugin.Deps, w plugin.Workloa
|
||||
|
||||
// syncContainers shares its body with Reconcile minus the missing-row
|
||||
// fallback — Deploy expects compose ps to succeed since `up` just ran.
|
||||
func syncContainers(ctx context.Context, deps plugin.Deps, compose *stack.Compose, w plugin.Workload, projectName, yamlPath string) error {
|
||||
func syncContainers(ctx context.Context, deps plugin.Deps, compose composeRunner, w plugin.Workload, projectName, yamlPath string) error {
|
||||
services, err := compose.Ps(ctx, projectName, yamlPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compose ps: %w", err)
|
||||
@@ -204,7 +221,17 @@ var projectNameSanitizer = regexp.MustCompile(`[^a-z0-9_-]`)
|
||||
|
||||
func composeProjectName(explicit string, w plugin.Workload) string {
|
||||
if explicit != "" {
|
||||
return explicit
|
||||
// Apply the same sanitizer to operator-supplied names so a value
|
||||
// like "--foo" cannot reach the docker CLI and be re-parsed as a
|
||||
// flag. Reuses the canonical lower+[^a-z0-9_-]→"-" + trim path.
|
||||
san := strings.ToLower(explicit)
|
||||
san = projectNameSanitizer.ReplaceAllString(san, "-")
|
||||
san = strings.Trim(san, "-")
|
||||
if san != "" {
|
||||
return san
|
||||
}
|
||||
// Fall through to the derived name if sanitization stripped
|
||||
// everything (operator passed e.g. "---" — degenerate input).
|
||||
}
|
||||
name := strings.ToLower(w.Name)
|
||||
name = projectNameSanitizer.ReplaceAllString(name, "-")
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/stack"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
)
|
||||
|
||||
// fakeRunner stands in for *stack.Compose. Every method records its
|
||||
// inputs and returns whatever the test set on the corresponding field.
|
||||
// Defaults are happy-path: empty services from Ps, no error from Up /
|
||||
// Down. Fields are slice-typed so a single fakeRunner can serve a
|
||||
// sequence of calls (Deploy issues Up + Ps in order).
|
||||
type fakeRunner struct {
|
||||
mu sync.Mutex
|
||||
|
||||
upCalls []runnerCall
|
||||
upOuts []string
|
||||
upErrs []error
|
||||
downCalls []runnerCall
|
||||
downOuts []string
|
||||
downErrs []error
|
||||
psCalls []runnerCall
|
||||
psResults [][]stack.Service
|
||||
psErrs []error
|
||||
upCallIdx int
|
||||
psCallIdx int
|
||||
downCallI int
|
||||
}
|
||||
|
||||
type runnerCall struct {
|
||||
ProjectName string
|
||||
YAMLPath string
|
||||
RemoveVolumes bool
|
||||
}
|
||||
|
||||
func (f *fakeRunner) Up(_ context.Context, projectName, yamlPath string) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.upCalls = append(f.upCalls, runnerCall{ProjectName: projectName, YAMLPath: yamlPath})
|
||||
out, err := pop(f.upOuts, f.upErrs, f.upCallIdx)
|
||||
f.upCallIdx++
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (f *fakeRunner) Down(_ context.Context, projectName string, removeVolumes bool) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.downCalls = append(f.downCalls, runnerCall{ProjectName: projectName, RemoveVolumes: removeVolumes})
|
||||
out, err := pop(f.downOuts, f.downErrs, f.downCallI)
|
||||
f.downCallI++
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (f *fakeRunner) Ps(_ context.Context, projectName, yamlPath string) ([]stack.Service, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.psCalls = append(f.psCalls, runnerCall{ProjectName: projectName, YAMLPath: yamlPath})
|
||||
|
||||
idx := f.psCallIdx
|
||||
f.psCallIdx++
|
||||
var svcs []stack.Service
|
||||
if idx < len(f.psResults) {
|
||||
svcs = f.psResults[idx]
|
||||
}
|
||||
var err error
|
||||
if idx < len(f.psErrs) {
|
||||
err = f.psErrs[idx]
|
||||
}
|
||||
return svcs, err
|
||||
}
|
||||
|
||||
// pop returns the nth element of outs/errs or zero values when n is
|
||||
// past the end. Lets a test set a single expected response without
|
||||
// padding slices for every other call.
|
||||
func pop(outs []string, errs []error, n int) (string, error) {
|
||||
var out string
|
||||
if n < len(outs) {
|
||||
out = outs[n]
|
||||
}
|
||||
var err error
|
||||
if n < len(errs) {
|
||||
err = errs[n]
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
// withFakeRunner swaps newComposeRunner for the duration of one test
|
||||
// and restores the original on cleanup. Tests that need to inspect the
|
||||
// fake post-hoc keep the returned pointer.
|
||||
func withFakeRunner(t *testing.T, f *fakeRunner) {
|
||||
t.Helper()
|
||||
orig := newComposeRunner
|
||||
newComposeRunner = func() composeRunner { return f }
|
||||
t.Cleanup(func() { newComposeRunner = orig })
|
||||
}
|
||||
|
||||
func testStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
st, err := store.New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
// seedWorkload creates the parent workload row that container rows FK
|
||||
// onto. Returns the workload's ID so callers can reuse it.
|
||||
func seedWorkload(t *testing.T, st *store.Store, name, yamlText string) string {
|
||||
t.Helper()
|
||||
cfg := Config{ComposeYAML: yamlText}
|
||||
body, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal config: %v", err)
|
||||
}
|
||||
w, err := st.CreateWorkload(store.Workload{
|
||||
Kind: "plugin",
|
||||
Name: name,
|
||||
SourceKind: "compose",
|
||||
SourceConfig: string(body),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create workload: %v", err)
|
||||
}
|
||||
return w.ID
|
||||
}
|
||||
|
||||
func TestDeploy_HappyPath(t *testing.T) {
|
||||
withTempDir(t) // isolates the YAML scratch dir under t.TempDir()
|
||||
|
||||
deps := plugin.Deps{Store: testStore(t)}
|
||||
yamlText := "services:\n web:\n image: nginx:alpine\n"
|
||||
wid := seedWorkload(t, deps.Store, "myapp", yamlText)
|
||||
w := plugin.Workload{
|
||||
ID: wid,
|
||||
Name: "myapp",
|
||||
SourceKind: "compose",
|
||||
SourceConfig: mustMarshalConfig(t, Config{ComposeYAML: yamlText}),
|
||||
}
|
||||
|
||||
fake := &fakeRunner{
|
||||
psResults: [][]stack.Service{{
|
||||
{Service: "web", State: "running", Status: "Up 5 seconds"},
|
||||
}},
|
||||
}
|
||||
withFakeRunner(t, fake)
|
||||
|
||||
src := &source{}
|
||||
if err := src.Deploy(context.Background(), deps, w, plugin.DeploymentIntent{}); err != nil {
|
||||
t.Fatalf("Deploy: %v", err)
|
||||
}
|
||||
|
||||
// Up called exactly once with the workload-derived project name.
|
||||
if len(fake.upCalls) != 1 {
|
||||
t.Fatalf("Up called %d times, want 1", len(fake.upCalls))
|
||||
}
|
||||
if !strings.HasPrefix(fake.upCalls[0].ProjectName, "tf-myapp-") {
|
||||
t.Errorf("Up projectName = %q, want prefix tf-myapp-", fake.upCalls[0].ProjectName)
|
||||
}
|
||||
if !strings.HasSuffix(fake.upCalls[0].YAMLPath, "compose.yml") {
|
||||
t.Errorf("Up yamlPath = %q, want suffix compose.yml", fake.upCalls[0].YAMLPath)
|
||||
}
|
||||
|
||||
// Ps follows Up to enumerate the resulting containers.
|
||||
if len(fake.psCalls) != 1 {
|
||||
t.Fatalf("Ps called %d times, want 1", len(fake.psCalls))
|
||||
}
|
||||
|
||||
// Service row written.
|
||||
row, err := deps.Store.GetContainerByID(wid + ":web")
|
||||
if err != nil {
|
||||
t.Fatalf("get container row: %v", err)
|
||||
}
|
||||
if row.WorkloadID != wid {
|
||||
t.Errorf("row.WorkloadID = %q, want %q", row.WorkloadID, wid)
|
||||
}
|
||||
if row.Role != "web" {
|
||||
t.Errorf("row.Role = %q, want %q", row.Role, "web")
|
||||
}
|
||||
if row.State != "running" {
|
||||
t.Errorf("row.State = %q, want %q", row.State, "running")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeploy_EmptyYAMLConfig_RejectsBeforeExec(t *testing.T) {
|
||||
deps := plugin.Deps{Store: testStore(t)}
|
||||
wid := seedWorkload(t, deps.Store, "empty", "services:\n web:\n image: x\n")
|
||||
w := plugin.Workload{
|
||||
ID: wid,
|
||||
Name: "empty",
|
||||
SourceKind: "compose",
|
||||
SourceConfig: mustMarshalConfig(t, Config{ComposeYAML: ""}),
|
||||
}
|
||||
|
||||
fake := &fakeRunner{}
|
||||
withFakeRunner(t, fake)
|
||||
|
||||
src := &source{}
|
||||
err := src.Deploy(context.Background(), deps, w, plugin.DeploymentIntent{})
|
||||
if err == nil {
|
||||
t.Fatal("Deploy accepted empty compose_yaml")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "empty compose_yaml") {
|
||||
t.Errorf("error = %v, want substring \"empty compose_yaml\"", err)
|
||||
}
|
||||
if len(fake.upCalls) != 0 {
|
||||
t.Errorf("Up should not have been called; got %d calls", len(fake.upCalls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeploy_UpFailure_PropagatesAndIncludesTruncatedOutput(t *testing.T) {
|
||||
withTempDir(t)
|
||||
|
||||
deps := plugin.Deps{Store: testStore(t)}
|
||||
yamlText := "services:\n web:\n image: bad-image\n"
|
||||
wid := seedWorkload(t, deps.Store, "fail", yamlText)
|
||||
w := plugin.Workload{
|
||||
ID: wid,
|
||||
Name: "fail",
|
||||
SourceKind: "compose",
|
||||
SourceConfig: mustMarshalConfig(t, Config{ComposeYAML: yamlText}),
|
||||
}
|
||||
|
||||
bigOut := strings.Repeat("docker compose log noise ", 200) // > 1024 bytes
|
||||
fake := &fakeRunner{
|
||||
upOuts: []string{bigOut},
|
||||
upErrs: []error{errors.New("exit status 1")},
|
||||
}
|
||||
withFakeRunner(t, fake)
|
||||
|
||||
src := &source{}
|
||||
err := src.Deploy(context.Background(), deps, w, plugin.DeploymentIntent{})
|
||||
if err == nil {
|
||||
t.Fatal("Deploy accepted Up failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "docker compose up") {
|
||||
t.Errorf("error = %v, want substring \"docker compose up\"", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "exit status 1") {
|
||||
t.Errorf("error = %v, want wrapped Up err", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "(truncated)") {
|
||||
t.Errorf("error = %v, want truncated-output marker", err)
|
||||
}
|
||||
// Ps must not be called when Up failed.
|
||||
if len(fake.psCalls) != 0 {
|
||||
t.Errorf("Ps called %d times after Up failure; want 0", len(fake.psCalls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeploy_UpSucceedsButPsFails_SurfacesError(t *testing.T) {
|
||||
// `up` succeeded but enumerate failed — Deploy must surface so the UI
|
||||
// doesn't show an empty containers index for a running stack.
|
||||
withTempDir(t)
|
||||
|
||||
deps := plugin.Deps{Store: testStore(t)}
|
||||
yamlText := "services:\n web:\n image: nginx\n"
|
||||
wid := seedWorkload(t, deps.Store, "psfail", yamlText)
|
||||
w := plugin.Workload{
|
||||
ID: wid,
|
||||
Name: "psfail",
|
||||
SourceKind: "compose",
|
||||
SourceConfig: mustMarshalConfig(t, Config{ComposeYAML: yamlText}),
|
||||
}
|
||||
|
||||
fake := &fakeRunner{
|
||||
psErrs: []error{errors.New("compose ps boom")},
|
||||
}
|
||||
withFakeRunner(t, fake)
|
||||
|
||||
src := &source{}
|
||||
err := src.Deploy(context.Background(), deps, w, plugin.DeploymentIntent{})
|
||||
if err == nil {
|
||||
t.Fatal("Deploy ignored Ps failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "sync container rows") {
|
||||
t.Errorf("error = %v, want substring \"sync container rows\"", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeardown_DropsContainerRows_EvenWhenDownFails(t *testing.T) {
|
||||
// docker compose down failing must not orphan rows in the DB.
|
||||
withTempDir(t)
|
||||
deps := plugin.Deps{Store: testStore(t)}
|
||||
wid := seedWorkload(t, deps.Store, "tdown", "services:\n web:\n image: nginx\n")
|
||||
|
||||
// Seed two service rows the way Deploy would.
|
||||
for _, role := range []string{"web", "db"} {
|
||||
if err := deps.Store.UpsertContainer(store.Container{
|
||||
ID: wid + ":" + role,
|
||||
WorkloadID: wid,
|
||||
WorkloadKind: "compose",
|
||||
Role: role,
|
||||
Host: "local",
|
||||
State: "running",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed container: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
fake := &fakeRunner{downErrs: []error{errors.New("compose project unknown")}}
|
||||
withFakeRunner(t, fake)
|
||||
|
||||
src := &source{}
|
||||
w := plugin.Workload{
|
||||
ID: wid,
|
||||
Name: "tdown",
|
||||
SourceKind: "compose",
|
||||
SourceConfig: mustMarshalConfig(t, Config{ComposeYAML: "services:\n web:\n image: nginx\n"}),
|
||||
}
|
||||
if err := src.Teardown(context.Background(), deps, w); err != nil {
|
||||
t.Fatalf("Teardown: %v", err)
|
||||
}
|
||||
|
||||
// Down requested removeVolumes=true (matches the docstring claim).
|
||||
if len(fake.downCalls) != 1 {
|
||||
t.Fatalf("Down calls = %d, want 1", len(fake.downCalls))
|
||||
}
|
||||
if !fake.downCalls[0].RemoveVolumes {
|
||||
t.Errorf("Down removeVolumes = false, want true (workload teardown is destructive)")
|
||||
}
|
||||
|
||||
// Rows gone despite the Down error.
|
||||
for _, role := range []string{"web", "db"} {
|
||||
if _, err := deps.Store.GetContainerByID(wid + ":" + role); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("container row %q survived teardown: err=%v", role, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeardown_HappyPath(t *testing.T) {
|
||||
withTempDir(t)
|
||||
deps := plugin.Deps{Store: testStore(t)}
|
||||
wid := seedWorkload(t, deps.Store, "tdown2", "services:\n web:\n image: nginx\n")
|
||||
|
||||
if err := deps.Store.UpsertContainer(store.Container{
|
||||
ID: wid + ":web",
|
||||
WorkloadID: wid,
|
||||
WorkloadKind: "compose",
|
||||
Role: "web",
|
||||
Host: "local",
|
||||
State: "running",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
fake := &fakeRunner{}
|
||||
withFakeRunner(t, fake)
|
||||
|
||||
src := &source{}
|
||||
w := plugin.Workload{
|
||||
ID: wid,
|
||||
Name: "tdown2",
|
||||
SourceKind: "compose",
|
||||
SourceConfig: mustMarshalConfig(t, Config{ComposeYAML: "services:\n web:\n image: nginx\n"}),
|
||||
}
|
||||
if err := src.Teardown(context.Background(), deps, w); err != nil {
|
||||
t.Fatalf("Teardown: %v", err)
|
||||
}
|
||||
if len(fake.downCalls) != 1 {
|
||||
t.Errorf("Down calls = %d, want 1", len(fake.downCalls))
|
||||
}
|
||||
if _, err := deps.Store.GetContainerByID(wid + ":web"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("container row survived teardown: err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcile_PsSuccess_UpsertsRows(t *testing.T) {
|
||||
withTempDir(t)
|
||||
deps := plugin.Deps{Store: testStore(t)}
|
||||
yamlText := "services:\n web:\n image: nginx\n db:\n image: postgres\n"
|
||||
wid := seedWorkload(t, deps.Store, "rec", yamlText)
|
||||
|
||||
fake := &fakeRunner{
|
||||
psResults: [][]stack.Service{{
|
||||
{Service: "web", State: "running"},
|
||||
{Service: "db", State: "running"},
|
||||
}},
|
||||
}
|
||||
withFakeRunner(t, fake)
|
||||
|
||||
src := &source{}
|
||||
w := plugin.Workload{
|
||||
ID: wid,
|
||||
Name: "rec",
|
||||
SourceKind: "compose",
|
||||
SourceConfig: mustMarshalConfig(t, Config{ComposeYAML: yamlText}),
|
||||
}
|
||||
if err := src.Reconcile(context.Background(), deps, w); err != nil {
|
||||
t.Fatalf("Reconcile: %v", err)
|
||||
}
|
||||
|
||||
for _, role := range []string{"web", "db"} {
|
||||
row, err := deps.Store.GetContainerByID(wid + ":" + role)
|
||||
if err != nil {
|
||||
t.Errorf("row %q missing after reconcile: %v", role, err)
|
||||
continue
|
||||
}
|
||||
if row.State != "running" {
|
||||
t.Errorf("row %q state = %q, want \"running\"", role, row.State)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcile_PsFailure_MarksExistingRowsMissing(t *testing.T) {
|
||||
// When compose ps fails (project unknown to Docker), the reconciler
|
||||
// flips existing rows to "missing" rather than deleting them — the UI
|
||||
// surfaces the desync to the operator.
|
||||
withTempDir(t)
|
||||
deps := plugin.Deps{Store: testStore(t)}
|
||||
yamlText := "services:\n web:\n image: nginx\n"
|
||||
wid := seedWorkload(t, deps.Store, "missing", yamlText)
|
||||
|
||||
if err := deps.Store.UpsertContainer(store.Container{
|
||||
ID: wid + ":web",
|
||||
WorkloadID: wid,
|
||||
WorkloadKind: "compose",
|
||||
Role: "web",
|
||||
Host: "local",
|
||||
State: "running",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
fake := &fakeRunner{psErrs: []error{errors.New("no such project")}}
|
||||
withFakeRunner(t, fake)
|
||||
|
||||
src := &source{}
|
||||
w := plugin.Workload{
|
||||
ID: wid,
|
||||
Name: "missing",
|
||||
SourceKind: "compose",
|
||||
SourceConfig: mustMarshalConfig(t, Config{ComposeYAML: yamlText}),
|
||||
}
|
||||
if err := src.Reconcile(context.Background(), deps, w); err != nil {
|
||||
t.Fatalf("Reconcile returned %v; should be nil even on Ps failure", err)
|
||||
}
|
||||
|
||||
row, err := deps.Store.GetContainerByID(wid + ":web")
|
||||
if err != nil {
|
||||
t.Fatalf("row missing entirely (should be marked, not deleted): %v", err)
|
||||
}
|
||||
if row.State != "missing" {
|
||||
t.Errorf("row.State = %q, want \"missing\"", row.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcile_FallsBackToStatusWhenStateEmpty(t *testing.T) {
|
||||
// Some compose versions populate Status (human string) but not State
|
||||
// (enum) for non-running services. upsertServiceRow falls back to
|
||||
// Status; verify that here.
|
||||
withTempDir(t)
|
||||
deps := plugin.Deps{Store: testStore(t)}
|
||||
yamlText := "services:\n worker:\n image: alpine\n"
|
||||
wid := seedWorkload(t, deps.Store, "fallback", yamlText)
|
||||
|
||||
fake := &fakeRunner{
|
||||
psResults: [][]stack.Service{{
|
||||
{Service: "worker", State: "", Status: "Exit 0"},
|
||||
}},
|
||||
}
|
||||
withFakeRunner(t, fake)
|
||||
|
||||
src := &source{}
|
||||
w := plugin.Workload{
|
||||
ID: wid,
|
||||
Name: "fallback",
|
||||
SourceKind: "compose",
|
||||
SourceConfig: mustMarshalConfig(t, Config{ComposeYAML: yamlText}),
|
||||
}
|
||||
if err := src.Reconcile(context.Background(), deps, w); err != nil {
|
||||
t.Fatalf("Reconcile: %v", err)
|
||||
}
|
||||
|
||||
row, err := deps.Store.GetContainerByID(wid + ":worker")
|
||||
if err != nil {
|
||||
t.Fatalf("get row: %v", err)
|
||||
}
|
||||
if row.State != "Exit 0" {
|
||||
t.Errorf("row.State = %q, want \"Exit 0\" (Status fallback)", row.State)
|
||||
}
|
||||
}
|
||||
|
||||
// mustMarshalConfig is a small helper that converts a Config to the
|
||||
// raw-JSON shape SourceConfig expects. Tests use it instead of
|
||||
// hand-rolling the string so a Config field rename can't drift the test
|
||||
// fixture from the production decoder.
|
||||
func mustMarshalConfig(t *testing.T, cfg Config) json.RawMessage {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal config: %v", err)
|
||||
}
|
||||
return json.RawMessage(b)
|
||||
}
|
||||
|
||||
// Compile-time guards: *stack.Compose must continue to satisfy
|
||||
// composeRunner so the production path keeps building, and the fake
|
||||
// must continue to satisfy it too so a drift in the interface shape
|
||||
// fails the build here rather than at runtime.
|
||||
var (
|
||||
_ composeRunner = (*stack.Compose)(nil)
|
||||
_ composeRunner = (*fakeRunner)(nil)
|
||||
)
|
||||
@@ -0,0 +1,574 @@
|
||||
package dockerfile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/crypto"
|
||||
"github.com/alexei/tinyforge/internal/docker"
|
||||
"github.com/alexei/tinyforge/internal/events"
|
||||
"github.com/alexei/tinyforge/internal/notify"
|
||||
"github.com/alexei/tinyforge/internal/proxy"
|
||||
"github.com/alexei/tinyforge/internal/staticsite"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
)
|
||||
|
||||
// healthCheckDelay is the grace window after StartContainer before we
|
||||
// probe IsContainerRunning. Mirrors the static plugin's window — short
|
||||
// enough not to slow happy-path deploys, long enough to catch
|
||||
// crash-on-boot failures (missing env, bad CMD, port conflict).
|
||||
const healthCheckDelay = 3 * time.Second
|
||||
|
||||
// deploy runs one end-to-end sync of a dockerfile workload:
|
||||
//
|
||||
// 1. fetch the latest commit SHA from the configured git provider
|
||||
// 2. skip if SHA + container + proxy are all still healthy
|
||||
// 3. clone the repo into a temp dir
|
||||
// 4. resolve the build context + Dockerfile location
|
||||
// 5. `docker build -t <tag> -f <dockerfile> <context>`
|
||||
// 6. recreate the container with the new image
|
||||
// 7. health-probe the container, surface logs on failure
|
||||
// 8. reconfigure the proxy route
|
||||
// 9. tear down the previous container (different ID) once we're sure
|
||||
// the new one is healthy and proxied
|
||||
//
|
||||
// Each step writes its own status update so the dashboard's runtime-
|
||||
// state panel can show a useful intermediate state when the deploy
|
||||
// stalls on the slow step (almost always the build).
|
||||
func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plugin.DeploymentIntent) error {
|
||||
cfg, err := plugin.SourceConfigOf[Config](w)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dockerfile source: decode config: %w", err)
|
||||
}
|
||||
|
||||
prev, prevContainer, err := loadState(deps, w)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Force a full rebuild on manual / promote / first-time deploys
|
||||
// (no Reason at all also implies manual). Schedule / git triggers
|
||||
// honour the unchanged-SHA short-circuit so cron polling does not
|
||||
// rebuild minute-by-minute when nothing changed.
|
||||
force := intent.Reason == "" || intent.Reason == "manual" || intent.Reason == "promote"
|
||||
|
||||
// Decrypt the access token if present. Token never escapes this
|
||||
// frame: any error message routes through sanitizeError(_, token)
|
||||
// which redacts the literal substring.
|
||||
token := ""
|
||||
if cfg.AccessToken != "" {
|
||||
decrypted, derr := crypto.Decrypt(deps.EncKey, cfg.AccessToken)
|
||||
if derr != nil {
|
||||
slog.Warn("dockerfile source: failed to decrypt access token",
|
||||
"workload", w.Name, "error", derr)
|
||||
} else {
|
||||
token = decrypted
|
||||
}
|
||||
}
|
||||
|
||||
provider, err := staticsite.NewGitProvider(staticsite.ProviderType(cfg.Provider), cfg.BaseURL, token)
|
||||
if err != nil {
|
||||
updateStatus(deps, w, "failed", prev.LastCommitSHA,
|
||||
sanitizeError(fmt.Sprintf("create provider: %v", err), token))
|
||||
return fmt.Errorf("create provider: %w", err)
|
||||
}
|
||||
|
||||
latestSHA, err := provider.GetLatestCommitSHA(ctx, cfg.RepoOwner, cfg.RepoName, cfg.Branch)
|
||||
if err != nil {
|
||||
updateStatus(deps, w, "failed", prev.LastCommitSHA,
|
||||
sanitizeError(fmt.Sprintf("fetch commit SHA: %v", err), token))
|
||||
return fmt.Errorf("get latest commit: %w", err)
|
||||
}
|
||||
|
||||
domain := primaryDomain(deps, w)
|
||||
|
||||
prevContainerID := ""
|
||||
prevProxyRouteID := ""
|
||||
if prevContainer != nil {
|
||||
prevContainerID = prevContainer.ContainerID
|
||||
prevProxyRouteID = prevContainer.ProxyRouteID
|
||||
}
|
||||
// Short-circuit: SHA unchanged AND container is still running AND
|
||||
// (if there's a public face) the proxy route still exists. Manual
|
||||
// deploys skip this entirely.
|
||||
//
|
||||
// We deliberately do NOT gate this on prev.Status == "deployed". A
|
||||
// transient failure (e.g. a one-off proxy-check error) leaves the
|
||||
// persisted status as "failed"; if we required "deployed" here, every
|
||||
// subsequent cron/git poll with the same SHA would fall through to a
|
||||
// full clone + docker build despite a perfectly healthy running
|
||||
// container — a rebuild storm that burns CPU/disk until a new commit
|
||||
// lands. Instead we trust the live container/proxy state and heal the
|
||||
// stale status via healUnchanged.
|
||||
if !force && latestSHA == prev.LastCommitSHA && prevContainerID != "" {
|
||||
running, _ := deps.Docker.IsContainerRunning(ctx, prevContainerID)
|
||||
switch {
|
||||
case !running:
|
||||
slog.Info("dockerfile: container not running, forcing redeploy", "workload", w.Name)
|
||||
case domain != "":
|
||||
proxyOK, perr := deps.Proxy.RouteExists(ctx, domain)
|
||||
switch {
|
||||
case perr != nil:
|
||||
slog.Warn("dockerfile: proxy check failed, forcing redeploy",
|
||||
"workload", w.Name, "error", perr)
|
||||
case !proxyOK:
|
||||
slog.Info("dockerfile: proxy route missing, forcing redeploy", "workload", w.Name)
|
||||
default:
|
||||
return healUnchanged(deps, w, prev, latestSHA)
|
||||
}
|
||||
default:
|
||||
return healUnchanged(deps, w, prev, latestSHA)
|
||||
}
|
||||
}
|
||||
|
||||
updateStatus(deps, w, "syncing", prev.LastCommitSHA, "")
|
||||
publishEvent(deps, w, "syncing")
|
||||
|
||||
// Clone the repo into a temp dir. We always download the entire
|
||||
// repo tree (folderPath = ""); a ContextPath subset is applied
|
||||
// at build time, not at download time, so a Dockerfile in
|
||||
// `./docker/Dockerfile` with `ContextPath=""` still works.
|
||||
cloneDir, err := os.MkdirTemp("", "tf-build-"+idShort(w)+"-*")
|
||||
if err != nil {
|
||||
updateStatus(deps, w, "failed", prev.LastCommitSHA,
|
||||
sanitizeError(fmt.Sprintf("create clone dir: %v", err), token))
|
||||
return fmt.Errorf("create clone dir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(cloneDir)
|
||||
|
||||
if err := provider.DownloadFolder(ctx, cfg.RepoOwner, cfg.RepoName, cfg.Branch, "", cloneDir); err != nil {
|
||||
updateStatus(deps, w, "failed", prev.LastCommitSHA,
|
||||
sanitizeError(fmt.Sprintf("download repo: %v", err), token))
|
||||
return fmt.Errorf("download repo: %w", err)
|
||||
}
|
||||
|
||||
// Resolve the build context (with symlink-aware escape check) and
|
||||
// verify the Dockerfile is actually present before sending the
|
||||
// build off to the daemon.
|
||||
contextDir, err := resolveContextDir(cloneDir, cfg.ContextPath)
|
||||
if err != nil {
|
||||
updateStatus(deps, w, "failed", latestSHA,
|
||||
sanitizeError(fmt.Sprintf("resolve context: %v", err), token))
|
||||
return fmt.Errorf("resolve context: %w", err)
|
||||
}
|
||||
if err := verifyDockerfileExists(contextDir, cfg.DockerfilePath); err != nil {
|
||||
updateStatus(deps, w, "failed", latestSHA,
|
||||
sanitizeError(err.Error(), token))
|
||||
return err
|
||||
}
|
||||
|
||||
imageTag := imageTagFor(w)
|
||||
updateStatus(deps, w, "building", latestSHA, "")
|
||||
publishEvent(deps, w, "building")
|
||||
// Bridge per-line build output onto the event bus so /api/events
|
||||
// subscribers (the dashboard's live tail) can show progress while
|
||||
// the daemon chugs. The bus is non-blocking — slow subscribers drop
|
||||
// events rather than backpressure the build — so this is safe to
|
||||
// call from the hot scan loop.
|
||||
logFn := func(line string) {
|
||||
publishBuildLog(deps, w, line)
|
||||
}
|
||||
if err := deps.Docker.BuildImageAt(ctx, contextDir, cfg.DockerfilePath, imageTag, logFn); err != nil {
|
||||
updateStatus(deps, w, "failed", latestSHA,
|
||||
sanitizeError(fmt.Sprintf("docker build: %v", err), token))
|
||||
return fmt.Errorf("docker build: %w", err)
|
||||
}
|
||||
|
||||
env := buildEnv(deps, w.ID)
|
||||
containerPort := strconv.Itoa(cfg.Port)
|
||||
|
||||
settings, err := deps.Store.GetSettings()
|
||||
if err != nil {
|
||||
updateStatus(deps, w, "failed", latestSHA,
|
||||
sanitizeError(fmt.Sprintf("get settings: %v", err), token))
|
||||
return fmt.Errorf("get settings: %w", err)
|
||||
}
|
||||
|
||||
networkName := settings.Network
|
||||
networkID, err := deps.Docker.EnsureNetwork(ctx, networkName)
|
||||
if err != nil {
|
||||
updateStatus(deps, w, "failed", latestSHA,
|
||||
sanitizeError(fmt.Sprintf("ensure network: %v", err), token))
|
||||
return fmt.Errorf("ensure network: %w", err)
|
||||
}
|
||||
|
||||
containerName := containerNameFor(w)
|
||||
|
||||
// Per-face proxy labels (Traefik consumes these; NPM ignores them).
|
||||
labels := map[string]string{}
|
||||
if domain != "" {
|
||||
if l := deps.Proxy.ContainerLabels(domain, cfg.Port); l != nil {
|
||||
for k, v := range l {
|
||||
labels[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cc := docker.ContainerConfig{
|
||||
Name: containerName,
|
||||
Image: imageTag,
|
||||
Env: env,
|
||||
ExposedPorts: []string{containerPort + "/tcp"},
|
||||
NetworkName: networkName,
|
||||
NetworkID: networkID,
|
||||
Labels: labels,
|
||||
WorkloadID: w.ID,
|
||||
// Dockerfile workloads are tagged as "build" so the dashboard
|
||||
// and any filtered query can distinguish them from static sites
|
||||
// (which serve files) and image-source containers (which pull
|
||||
// pre-built images from a registry).
|
||||
WorkloadKind: string(store.WorkloadKindBuild),
|
||||
Role: "",
|
||||
}
|
||||
|
||||
containerID, err := deps.Docker.CreateContainer(ctx, cc)
|
||||
if err != nil {
|
||||
// Name conflict — best-effort cleanup of any prior container
|
||||
// (by ID first; by name as a fallback) and one retry.
|
||||
if prevContainerID != "" {
|
||||
deps.Docker.StopContainer(ctx, prevContainerID, 10)
|
||||
deps.Docker.RemoveContainer(ctx, prevContainerID, true)
|
||||
}
|
||||
removeContainerByName(ctx, deps, containerName)
|
||||
|
||||
containerID, err = deps.Docker.CreateContainer(ctx, cc)
|
||||
if err != nil {
|
||||
updateStatus(deps, w, "failed", latestSHA,
|
||||
sanitizeError(fmt.Sprintf("create container: %v", err), token))
|
||||
return fmt.Errorf("create container: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := deps.Docker.StartContainer(ctx, containerID); err != nil {
|
||||
deps.Docker.RemoveContainer(ctx, containerID, true)
|
||||
updateStatus(deps, w, "failed", latestSHA,
|
||||
sanitizeError(fmt.Sprintf("start container: %v", err), token))
|
||||
return fmt.Errorf("start container: %w", err)
|
||||
}
|
||||
|
||||
// Brief health-check window — catch crash-on-boot. ctx-aware so a
|
||||
// cancelled deploy returns promptly. On failure surface the tail
|
||||
// of the container's logs as the error reason; that's almost
|
||||
// always what the operator needs to debug.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
deps.Docker.RemoveContainer(ctx, containerID, true)
|
||||
updateStatus(deps, w, "failed", latestSHA, "deploy cancelled before health check")
|
||||
return ctx.Err()
|
||||
case <-time.After(healthCheckDelay):
|
||||
}
|
||||
running, runErr := deps.Docker.IsContainerRunning(ctx, containerID)
|
||||
if runErr != nil || !running {
|
||||
logMsg := "container exited immediately after start"
|
||||
if logs, logErr := deps.Docker.ContainerLogs(ctx, containerID, false, "40"); logErr == nil {
|
||||
buf, _ := io.ReadAll(logs)
|
||||
logs.Close()
|
||||
if len(buf) > 0 {
|
||||
// Pass `env` so any decrypted KEY=VALUE pair that the
|
||||
// container's startup output happens to echo (think
|
||||
// `RUN echo $DB_PASSWORD` in a debug Dockerfile) is
|
||||
// redacted before it lands in the operator-visible
|
||||
// last_error field.
|
||||
logMsg = sanitizeErrorWithSecrets(string(buf), token, env)
|
||||
}
|
||||
}
|
||||
deps.Docker.RemoveContainer(ctx, containerID, true)
|
||||
updateStatus(deps, w, "failed", latestSHA, logMsg)
|
||||
return fmt.Errorf("container not running: %s", logMsg)
|
||||
}
|
||||
|
||||
// Resolve proxy target: in-network DNS by default, NPM-remote
|
||||
// override uses (settings.ServerIP, hostPort).
|
||||
forwardHost := containerName
|
||||
forwardPort := cfg.Port
|
||||
if settings.NpmRemote && settings.ProxyProvider == "npm" {
|
||||
if settings.ServerIP != "" {
|
||||
hostPort, hpErr := deps.Docker.InspectContainerPort(ctx, containerID, containerPort+"/tcp")
|
||||
if hpErr != nil {
|
||||
slog.Warn("dockerfile: could not get host port for remote NPM",
|
||||
"workload", w.Name, "error", hpErr)
|
||||
} else {
|
||||
forwardHost = settings.ServerIP
|
||||
forwardPort = int(hostPort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Configure proxy if a domain is set. Replace any prior route
|
||||
// in-place so traffic shifts atomically over to the new container.
|
||||
proxyRouteID := prevProxyRouteID
|
||||
if domain != "" {
|
||||
if prevProxyRouteID != "" {
|
||||
deps.Proxy.DeleteRoute(ctx, prevProxyRouteID)
|
||||
}
|
||||
routeID, rerr := deps.Proxy.ConfigureRoute(ctx, domain, forwardHost, forwardPort, proxy.RouteOptions{
|
||||
SSLCertificateID: settings.SSLCertificateID,
|
||||
})
|
||||
if rerr != nil {
|
||||
slog.Warn("dockerfile: failed to configure proxy",
|
||||
"workload", w.Name, "domain", domain,
|
||||
"target", fmt.Sprintf("%s:%d", forwardHost, forwardPort), "error", rerr)
|
||||
} else {
|
||||
proxyRouteID = routeID
|
||||
slog.Info("dockerfile: proxy configured",
|
||||
"workload", w.Name, "domain", domain,
|
||||
"target", fmt.Sprintf("%s:%d", forwardHost, forwardPort), "routeID", routeID)
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the previous container only after the new one is healthy
|
||||
// + routed. Different-ID-than-previous tells us we created a
|
||||
// fresh one (vs returning the same ID via UpsertContainer reuse).
|
||||
if prevContainerID != "" && prevContainerID != containerID {
|
||||
deps.Docker.StopContainer(ctx, prevContainerID, 10)
|
||||
deps.Docker.RemoveContainer(ctx, prevContainerID, true)
|
||||
}
|
||||
|
||||
// Single transactional write of new state + container metadata.
|
||||
// On failure: tear down the just-created container + proxy route
|
||||
// so we don't leave orphans behind for the next deploy to trip
|
||||
// over.
|
||||
if err := saveState(deps, w, func(rs *runtimeState, c *store.Container) {
|
||||
rs.LastCommitSHA = latestSHA
|
||||
rs.LastSyncAt = store.Now()
|
||||
rs.LastError = ""
|
||||
rs.Status = "deployed"
|
||||
|
||||
c.ContainerID = containerID
|
||||
c.ProxyRouteID = proxyRouteID
|
||||
c.Subdomain = domain
|
||||
c.State = "running"
|
||||
c.Port = cfg.Port
|
||||
c.ImageRef = imageTag
|
||||
}); err != nil {
|
||||
slog.Error("dockerfile: failed to persist deploy state — rolling back",
|
||||
"workload", w.Name, "error", err)
|
||||
if proxyRouteID != "" {
|
||||
deps.Proxy.DeleteRoute(ctx, proxyRouteID)
|
||||
}
|
||||
deps.Docker.StopContainer(ctx, containerID, 10)
|
||||
deps.Docker.RemoveContainer(ctx, containerID, true)
|
||||
updateStatus(deps, w, "failed", latestSHA,
|
||||
sanitizeError(fmt.Sprintf("persist deploy state: %v", err), token))
|
||||
return fmt.Errorf("persist deploy state: %w", err)
|
||||
}
|
||||
|
||||
publishEvent(deps, w, "deployed")
|
||||
dispatchBuildNotification(deps, w, domain, "deployed", "")
|
||||
|
||||
slog.Info("dockerfile deployed",
|
||||
"workload", w.Name,
|
||||
"sha", shortSHA(latestSHA),
|
||||
"image", imageTag)
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateStatus writes the runtime-state status/error/commit and (on
|
||||
// terminal states) fires the side effects the static plugin's helper
|
||||
// does: failures land in the event log, and a "deployed" or "failed"
|
||||
// transition dispatches an outbound notification.
|
||||
//
|
||||
// The deploy success path calls saveState directly with the full
|
||||
// container metadata; this helper covers failure / intermediate
|
||||
// transitions where only state moves.
|
||||
func updateStatus(deps plugin.Deps, w plugin.Workload, status, commitSHA, errMsg string) {
|
||||
if err := saveState(deps, w, func(rs *runtimeState, c *store.Container) {
|
||||
rs.Status = status
|
||||
rs.LastError = errMsg
|
||||
if commitSHA != "" {
|
||||
rs.LastCommitSHA = commitSHA
|
||||
}
|
||||
switch status {
|
||||
case "deployed":
|
||||
c.State = "running"
|
||||
case "stopped":
|
||||
c.State = "stopped"
|
||||
case "failed":
|
||||
c.State = "failed"
|
||||
case "syncing", "building":
|
||||
// Don't churn the container row's state during in-progress
|
||||
// build/sync — leave whatever the previous deploy left.
|
||||
}
|
||||
}); err != nil {
|
||||
slog.Error("dockerfile: failed to update status",
|
||||
"id", w.ID, "status", status, "error", err)
|
||||
}
|
||||
|
||||
if status == "failed" {
|
||||
publishEvent(deps, w, "failed: "+errMsg)
|
||||
}
|
||||
|
||||
if status == "deployed" || status == "failed" {
|
||||
dispatchBuildNotification(deps, w, primaryDomain(deps, w), status, errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchBuildNotification fans the build event out to every
|
||||
// configured notification route for the workload. Multi-destination
|
||||
// fan-out (workload_notifications rows + legacy single URL + global
|
||||
// settings fallback) is centralised in plugin.DispatchNotificationForWorkload
|
||||
// so the routing rules are identical across source kinds.
|
||||
func dispatchBuildNotification(deps plugin.Deps, w plugin.Workload, domain, status, errMsg string) {
|
||||
eventType := "build_success"
|
||||
if status == "failed" {
|
||||
eventType = "build_failure"
|
||||
}
|
||||
siteURL := ""
|
||||
if domain != "" {
|
||||
siteURL = "https://" + domain
|
||||
}
|
||||
plugin.DispatchNotificationForWorkload(deps, w, notify.Event{
|
||||
Type: eventType,
|
||||
Project: w.Name,
|
||||
URL: siteURL,
|
||||
Error: errMsg,
|
||||
})
|
||||
}
|
||||
|
||||
// publishEvent emits a status event on the bus AND persists an
|
||||
// event_log row. Message shape mirrors the static plugin
|
||||
// ("Build %q: %s") so the dashboard's audit feed reads consistently
|
||||
// across both kinds.
|
||||
func publishEvent(deps plugin.Deps, w plugin.Workload, status string) {
|
||||
severity := "info"
|
||||
if strings.HasPrefix(status, "failed") {
|
||||
severity = "error"
|
||||
}
|
||||
message := fmt.Sprintf("Build %q: %s", w.Name, status)
|
||||
|
||||
metaBytes, err := json.Marshal(map[string]string{
|
||||
"workload_id": w.ID,
|
||||
"workload_name": w.Name,
|
||||
"status": status,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("dockerfile: marshal event metadata", "error", err)
|
||||
metaBytes = []byte("{}")
|
||||
}
|
||||
metadata := string(metaBytes)
|
||||
|
||||
evt, err := deps.Store.InsertEvent(store.EventLog{
|
||||
Source: "dockerfile",
|
||||
Severity: severity,
|
||||
Message: message,
|
||||
Metadata: metadata,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("dockerfile: failed to persist event log", "error", err)
|
||||
return
|
||||
}
|
||||
deps.Events.Publish(events.Event{
|
||||
Type: events.EventLog,
|
||||
Payload: events.EventLogPayload{
|
||||
ID: evt.ID,
|
||||
Source: "dockerfile",
|
||||
Severity: severity,
|
||||
Message: message,
|
||||
Metadata: metadata,
|
||||
CreatedAt: evt.CreatedAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// publishBuildLog emits one EventBuildLog per non-empty daemon "stream"
|
||||
// line. The trailing newline the daemon emits per line is trimmed so the
|
||||
// UI can render each event as its own row without smuggled blanks.
|
||||
// Strictly best-effort: the bus drops events under backpressure (slow
|
||||
// subscriber, no subscriber at all) and never blocks the build loop.
|
||||
func publishBuildLog(deps plugin.Deps, w plugin.Workload, line string) {
|
||||
trimmed := strings.TrimRight(line, "\r\n")
|
||||
if trimmed == "" {
|
||||
return
|
||||
}
|
||||
deps.Events.Publish(events.Event{
|
||||
Type: events.EventBuildLog,
|
||||
Payload: events.BuildLogPayload{
|
||||
WorkloadID: w.ID,
|
||||
Line: trimmed,
|
||||
Stream: "stdout",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// healUnchanged is the no-rebuild short-circuit result: the SHA matches and
|
||||
// the live container + proxy are healthy, so there is nothing to deploy. If a
|
||||
// prior transient failure left the persisted status as something other than
|
||||
// "deployed", repair it so the dashboard reflects reality and we stop treating
|
||||
// a healthy workload as failed. We heal via saveState directly (NOT
|
||||
// updateStatus) so this reconciliation does not fire a spurious build-success
|
||||
// notification on every poll.
|
||||
func healUnchanged(deps plugin.Deps, w plugin.Workload, prev runtimeState, latestSHA string) error {
|
||||
slog.Info("dockerfile: no changes", "workload", w.Name, "sha", shortSHA(latestSHA))
|
||||
if prev.Status == "deployed" {
|
||||
return nil
|
||||
}
|
||||
if err := saveState(deps, w, func(rs *runtimeState, c *store.Container) {
|
||||
rs.Status = "deployed"
|
||||
rs.LastError = ""
|
||||
c.State = "running"
|
||||
}); err != nil {
|
||||
slog.Warn("dockerfile: failed to heal stale status to deployed",
|
||||
"workload", w.Name, "error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeContainerByName enumerates Docker's view and best-effort drops
|
||||
// EVERY matching container so a name conflict in CreateContainer is
|
||||
// recoverable. Container names are unique per daemon, but the recovery
|
||||
// path exists precisely because a conflict occurred — a prior partial
|
||||
// deploy can leave more than one matching artifact, so we must not stop
|
||||
// at the first. Mirrors the static plugin's helper of the same name.
|
||||
func removeContainerByName(ctx context.Context, deps plugin.Deps, name string) {
|
||||
containers, err := deps.Docker.ListContainers(ctx, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, c := range containers {
|
||||
if c.Name == name {
|
||||
deps.Docker.StopContainer(ctx, c.ID, 10)
|
||||
deps.Docker.RemoveContainer(ctx, c.ID, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// primaryDomain mirrors the static plugin's helper of the same name —
|
||||
// derives an FQDN from the workload's first enabled public face, with
|
||||
// the same bare-subdomain + settings.Domain fall-through.
|
||||
func primaryDomain(deps plugin.Deps, w plugin.Workload) string {
|
||||
for _, f := range w.PublicFaces {
|
||||
if f.Subdomain == "" && f.Domain == "" {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case f.Subdomain != "" && f.Domain != "":
|
||||
return f.Subdomain + "." + f.Domain
|
||||
case f.Subdomain == "" && f.Domain != "":
|
||||
return f.Domain
|
||||
case f.Subdomain != "" && f.Domain == "":
|
||||
settings, err := deps.Store.GetSettings()
|
||||
if err != nil || settings.Domain == "" {
|
||||
return f.Subdomain
|
||||
}
|
||||
return f.Subdomain + "." + settings.Domain
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// shortSHA truncates a commit SHA for log lines. Keeps the deploy log
|
||||
// readable without losing the "is this the same commit?" signal.
|
||||
func shortSHA(sha string) string {
|
||||
if len(sha) > 8 {
|
||||
return sha[:8]
|
||||
}
|
||||
return sha
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Package dockerfile implements the "dockerfile" source: a git-repo-backed
|
||||
// deployable that builds a Docker image from a user-supplied Dockerfile
|
||||
// and runs one container. This is the "self-hosted Vercel" Source —
|
||||
// users point at a Git repo containing a Dockerfile and Tinyforge
|
||||
// handles clone → build → run → proxy in one shot, with no external CI
|
||||
// pipeline.
|
||||
//
|
||||
// Architecturally the plugin sits between `static` (clones a Git repo,
|
||||
// builds an image, runs one container) and `image` (richer runtime
|
||||
// shape: ports, healthcheck, env, volumes). The deploy pipeline mirrors
|
||||
// static — same git-fetch, same image-tag/container-name shape, same
|
||||
// container-row state persistence — but the build step uses the
|
||||
// operator's Dockerfile instead of generating one.
|
||||
//
|
||||
// The full pipeline is implemented inline in this package
|
||||
// (deploy.go / teardown.go / reconcile.go) so a new dockerfile source
|
||||
// kind is usable immediately on init() — no separate registration step
|
||||
// in the deployer.
|
||||
package dockerfile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
)
|
||||
|
||||
// Config is the per-workload source config blob. Mirrors the shape of
|
||||
// the static plugin's Config so the UI wizard can largely reuse the
|
||||
// existing Git-discovery + branch-picker + repo-picker components.
|
||||
//
|
||||
// Build-side fields:
|
||||
//
|
||||
// - DockerfilePath: path to the Dockerfile *within the context*
|
||||
// directory. Defaults to "Dockerfile". Use e.g. "docker/Dockerfile"
|
||||
// when the operator's repo keeps Dockerfiles in a subfolder.
|
||||
// - ContextPath: subfolder of the cloned repo to use as the build
|
||||
// context. Defaults to "" (repo root). Use e.g. "./api" when the
|
||||
// repo's Dockerfile lives next to a backend service in a monorepo.
|
||||
//
|
||||
// Runtime-side fields:
|
||||
//
|
||||
// - Port: container port the workload listens on. Required.
|
||||
// - Healthcheck: optional curl-style probe; empty disables.
|
||||
//
|
||||
// Env vars and volume mounts are handled out-of-band via the
|
||||
// workload_env and workload_volumes tables, mirroring the image source.
|
||||
type Config struct {
|
||||
Provider string `json:"provider"` // "gitea" | "github" | "gitlab"; "" = autodetect
|
||||
BaseURL string `json:"base_url"` // e.g. https://git.example.com
|
||||
RepoOwner string `json:"repo_owner"`
|
||||
RepoName string `json:"repo_name"`
|
||||
Branch string `json:"branch"`
|
||||
ContextPath string `json:"context_path"` // path within repo (root by default)
|
||||
DockerfilePath string `json:"dockerfile_path"` // relative to context_path; "Dockerfile" by default
|
||||
AccessToken string `json:"access_token"` // encrypted; optional for public repos
|
||||
|
||||
Port int `json:"port"`
|
||||
Healthcheck string `json:"healthcheck,omitempty"`
|
||||
}
|
||||
|
||||
type source struct{}
|
||||
|
||||
// Eager registration — the deploy pipeline lives entirely inside this
|
||||
// package, so the kind is usable as soon as init() fires.
|
||||
func init() { plugin.RegisterSource(&source{}) }
|
||||
|
||||
func (*source) Kind() string { return "dockerfile" }
|
||||
|
||||
func (*source) SchemaSample() any {
|
||||
return Config{
|
||||
Provider: "gitea",
|
||||
BaseURL: "https://git.example.com",
|
||||
RepoOwner: "owner",
|
||||
RepoName: "myservice",
|
||||
Branch: "main",
|
||||
ContextPath: "",
|
||||
DockerfilePath: "Dockerfile",
|
||||
Port: 8080,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate rejects obviously-malformed configs before the deploy
|
||||
// pipeline materializes a temp dir, downloads a repo, and burns
|
||||
// minutes of build time on input that was never going to work.
|
||||
func (*source) Validate(cfg json.RawMessage) error {
|
||||
var c Config
|
||||
if len(cfg) == 0 {
|
||||
return fmt.Errorf("dockerfile source: config is required")
|
||||
}
|
||||
if err := json.Unmarshal(cfg, &c); err != nil {
|
||||
return fmt.Errorf("dockerfile source: invalid json: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(c.RepoOwner) == "" || strings.TrimSpace(c.RepoName) == "" {
|
||||
return fmt.Errorf("dockerfile source: repo_owner and repo_name are required")
|
||||
}
|
||||
if c.Port <= 0 || c.Port > 65535 {
|
||||
return fmt.Errorf("dockerfile source: port must be between 1 and 65535 (got %d)", c.Port)
|
||||
}
|
||||
// Defense in depth: a leading "/" or any ".." segment in
|
||||
// DockerfilePath / ContextPath would escape the build context. The
|
||||
// plugin's deploy() does its own normalization too; rejecting here
|
||||
// gives the operator a clear error at save-time instead of a
|
||||
// confusing "no such file" mid-build.
|
||||
for _, p := range []string{c.DockerfilePath, c.ContextPath} {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(p, "/") {
|
||||
return fmt.Errorf("dockerfile source: %q must be relative", p)
|
||||
}
|
||||
if strings.Contains(p, "..") {
|
||||
return fmt.Errorf("dockerfile source: %q must not contain '..'", p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*source) Deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plugin.DeploymentIntent) error {
|
||||
return deploy(ctx, deps, w, intent)
|
||||
}
|
||||
|
||||
func (*source) Teardown(ctx context.Context, deps plugin.Deps, w plugin.Workload) error {
|
||||
return teardown(ctx, deps, w)
|
||||
}
|
||||
|
||||
func (*source) Reconcile(ctx context.Context, deps plugin.Deps, w plugin.Workload) error {
|
||||
return reconcile(ctx, deps, w)
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package dockerfile
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
)
|
||||
|
||||
// ── Source interface plumbing ───────────────────────────────────────
|
||||
|
||||
func TestSource_Kind(t *testing.T) {
|
||||
if (&source{}).Kind() != "dockerfile" {
|
||||
t.Fatalf("Kind = %q, want \"dockerfile\"", (&source{}).Kind())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSource_Registered_AtInit(t *testing.T) {
|
||||
// init() runs once on import; we just verify the registry returns
|
||||
// our concrete kind. A failure here is a regression of the global
|
||||
// plugin.RegisterSource path or our package-level init.
|
||||
got, err := plugin.GetSource("dockerfile")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSource(dockerfile): %v", err)
|
||||
}
|
||||
if got.Kind() != "dockerfile" {
|
||||
t.Fatalf("registered source has wrong kind: %q", got.Kind())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSource_SchemaSample_RoundTrips(t *testing.T) {
|
||||
s := (&source{}).SchemaSample()
|
||||
raw, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal sample: %v", err)
|
||||
}
|
||||
if err := (&source{}).Validate(raw); err != nil {
|
||||
t.Fatalf("Validate(sample) = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validate ────────────────────────────────────────────────────────
|
||||
|
||||
func TestValidate_RejectsEmpty(t *testing.T) {
|
||||
if err := (&source{}).Validate(nil); err == nil {
|
||||
t.Fatal("expected error on empty config, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_RejectsMissingRepo(t *testing.T) {
|
||||
cases := []Config{
|
||||
{RepoName: "x", Port: 80}, // owner missing
|
||||
{RepoOwner: "y", Port: 80}, // name missing
|
||||
{RepoOwner: " ", RepoName: "x", Port: 80}, // owner whitespace-only
|
||||
}
|
||||
for i, c := range cases {
|
||||
raw, _ := json.Marshal(c)
|
||||
if err := (&source{}).Validate(raw); err == nil {
|
||||
t.Errorf("case %d: expected error, got nil", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_RejectsBadPort(t *testing.T) {
|
||||
for _, port := range []int{0, -1, 70000} {
|
||||
raw, _ := json.Marshal(Config{RepoOwner: "a", RepoName: "b", Port: port})
|
||||
if err := (&source{}).Validate(raw); err == nil {
|
||||
t.Errorf("port %d: expected error, got nil", port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_RejectsPathEscape(t *testing.T) {
|
||||
cases := []Config{
|
||||
{RepoOwner: "a", RepoName: "b", Port: 80, DockerfilePath: "/etc/passwd"},
|
||||
{RepoOwner: "a", RepoName: "b", Port: 80, DockerfilePath: "../../etc/passwd"},
|
||||
{RepoOwner: "a", RepoName: "b", Port: 80, ContextPath: "../../"},
|
||||
{RepoOwner: "a", RepoName: "b", Port: 80, ContextPath: "/etc"},
|
||||
}
|
||||
for i, c := range cases {
|
||||
raw, _ := json.Marshal(c)
|
||||
if err := (&source{}).Validate(raw); err == nil {
|
||||
t.Errorf("case %d: expected path-escape rejection, got nil", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_AcceptsValid(t *testing.T) {
|
||||
raw, _ := json.Marshal(Config{
|
||||
RepoOwner: "owner",
|
||||
RepoName: "repo",
|
||||
Port: 8080,
|
||||
DockerfilePath: "docker/Dockerfile",
|
||||
ContextPath: "services/api",
|
||||
})
|
||||
if err := (&source{}).Validate(raw); err != nil {
|
||||
t.Fatalf("Validate(valid) = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Naming helpers ──────────────────────────────────────────────────
|
||||
|
||||
func TestNaming_SameNameDifferentIDs_NoCollision(t *testing.T) {
|
||||
a := plugin.Workload{ID: "aaaaaaaa-rest", Name: "svc"}
|
||||
b := plugin.Workload{ID: "bbbbbbbb-rest", Name: "svc"}
|
||||
if containerNameFor(a) == containerNameFor(b) {
|
||||
t.Errorf("container names collide: %q", containerNameFor(a))
|
||||
}
|
||||
if imageTagFor(a) == imageTagFor(b) {
|
||||
t.Errorf("image tags collide: %q", imageTagFor(a))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNaming_ShortIDsPassThrough(t *testing.T) {
|
||||
w := plugin.Workload{ID: "abc", Name: "tiny"}
|
||||
if !strings.HasSuffix(containerNameFor(w), "-abc") {
|
||||
t.Errorf("container name lost short id: %q", containerNameFor(w))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Context + Dockerfile resolution ─────────────────────────────────
|
||||
|
||||
func TestResolveContextDir_Empty_ReturnsRoot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
got, err := resolveContextDir(dir, "")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveContextDir: %v", err)
|
||||
}
|
||||
if real, _ := filepath.EvalSymlinks(dir); got != real && got != dir {
|
||||
t.Errorf("got %q, want %q (or symlink-resolved equivalent)", got, dir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveContextDir_Subfolder_OK(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sub := filepath.Join(dir, "api")
|
||||
if err := os.MkdirAll(sub, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
got, err := resolveContextDir(dir, "api")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveContextDir: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(got, "api") {
|
||||
t.Errorf("got %q, expected suffix 'api'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveContextDir_NonexistentSubfolder(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if _, err := resolveContextDir(dir, "missing"); err == nil {
|
||||
t.Fatal("expected error for missing subfolder")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveContextDir_RejectsEscape(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// resolveContextDir is the second wall — Validate is the first.
|
||||
// We pass an absolute escape via a synthesized symlink. Even if
|
||||
// the user bypasses Validate (e.g. by direct DB edit), this must
|
||||
// still reject.
|
||||
outside := t.TempDir()
|
||||
link := filepath.Join(dir, "escape")
|
||||
if err := os.Symlink(outside, link); err != nil {
|
||||
t.Skipf("symlink unsupported in this environment: %v", err)
|
||||
}
|
||||
if _, err := resolveContextDir(dir, "escape"); err == nil {
|
||||
t.Fatal("expected escape-path rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyDockerfileExists_Present(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte("FROM scratch\n"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := verifyDockerfileExists(dir, ""); err != nil {
|
||||
t.Fatalf("verifyDockerfileExists(default) = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyDockerfileExists_Missing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := verifyDockerfileExists(dir, ""); err == nil {
|
||||
t.Fatal("expected error for missing Dockerfile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyDockerfileExists_CustomPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "docker"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "docker", "Dockerfile.prod"), []byte("FROM scratch\n"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := verifyDockerfileExists(dir, "docker/Dockerfile.prod"); err != nil {
|
||||
t.Fatalf("verifyDockerfileExists(custom) = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyDockerfileExists_RejectsAbsolutePath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := verifyDockerfileExists(dir, "/etc/passwd"); err == nil {
|
||||
t.Fatal("expected error for absolute dockerfile path")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sanitiser ───────────────────────────────────────────────────────
|
||||
|
||||
func TestSanitizeError_RedactsToken(t *testing.T) {
|
||||
tok := "ghp_supersecret"
|
||||
got := sanitizeError("401 from gitea token="+tok+" ok", tok)
|
||||
if strings.Contains(got, tok) {
|
||||
t.Errorf("token leaked: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "[REDACTED]") {
|
||||
t.Errorf("missing [REDACTED] marker: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeError_CollapsesWhitespace(t *testing.T) {
|
||||
got := sanitizeError("a\nb\rc\td", "")
|
||||
if strings.ContainsAny(got, "\n\r\t") {
|
||||
t.Errorf("did not collapse: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeError_TruncatesUTF8Safe(t *testing.T) {
|
||||
// 1000 copies of a 2-byte rune = 2000 bytes, well over the 240
|
||||
// cap. Output must remain valid UTF-8 (no torn rune at the cap).
|
||||
long := strings.Repeat("é", 1000)
|
||||
got := sanitizeError(long, "")
|
||||
if !strings.HasSuffix(got, "…") {
|
||||
t.Errorf("missing ellipsis: %q", got)
|
||||
}
|
||||
// Walk the result: every byte should be either an ASCII char or
|
||||
// part of a complete UTF-8 sequence. utf8.ValidString is the
|
||||
// canonical guard but a simple "ends on rune boundary" check
|
||||
// suffices for this fixture.
|
||||
if !isValidUTF8Slice([]byte(got)) {
|
||||
t.Errorf("truncation produced broken UTF-8: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func isValidUTF8Slice(b []byte) bool {
|
||||
for i := 0; i < len(b); {
|
||||
switch {
|
||||
case b[i] < 0x80:
|
||||
i++
|
||||
case b[i] < 0xC0:
|
||||
return false // continuation byte at sequence start
|
||||
case b[i] < 0xE0:
|
||||
if i+1 >= len(b) {
|
||||
return false
|
||||
}
|
||||
i += 2
|
||||
case b[i] < 0xF0:
|
||||
if i+2 >= len(b) {
|
||||
return false
|
||||
}
|
||||
i += 3
|
||||
default:
|
||||
if i+3 >= len(b) {
|
||||
return false
|
||||
}
|
||||
i += 4
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ── State row ID ────────────────────────────────────────────────────
|
||||
|
||||
func TestContainerRowID_Deterministic(t *testing.T) {
|
||||
w := plugin.Workload{ID: "abcd1234-rest"}
|
||||
a := containerRowID(w)
|
||||
b := containerRowID(w)
|
||||
if a != b {
|
||||
t.Errorf("containerRowID not deterministic: %q vs %q", a, b)
|
||||
}
|
||||
if !strings.HasSuffix(a, ":dockerfile") {
|
||||
t.Errorf("containerRowID missing suffix: %q", a)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package dockerfile
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/crypto"
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
)
|
||||
|
||||
// buildEnv flattens workload_env rows into the KEY=VALUE list Docker
|
||||
// expects. Mirrors the static plugin's env helper exactly so the two
|
||||
// plugins handle decrypt failures the same way: log + skip the one
|
||||
// entry rather than fail the deploy. Bricking a build because one
|
||||
// rotated key missed an env entry would be worse than running with
|
||||
// the variable unset and a single warning in the operator's log.
|
||||
func buildEnv(deps plugin.Deps, workloadID string) []string {
|
||||
rows, err := deps.Store.ListWorkloadEnv(workloadID)
|
||||
if err != nil {
|
||||
slog.Warn("dockerfile source: list workload env", "workload", workloadID, "error", err)
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(rows))
|
||||
for _, e := range rows {
|
||||
value := e.Value
|
||||
if e.Encrypted {
|
||||
decrypted, err := crypto.Decrypt(deps.EncKey, e.Value)
|
||||
if err != nil {
|
||||
slog.Warn("dockerfile source: decrypt env value",
|
||||
"workload", workloadID, "key", e.Key, "error", err)
|
||||
continue
|
||||
}
|
||||
value = decrypted
|
||||
}
|
||||
out = append(out, e.Key+"="+value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package dockerfile
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// resolveContextDir picks the directory the Docker build context will
|
||||
// be packed from, defensively. Returns an error rather than a directory
|
||||
// outside the cloned tree even if ContextPath contains a tricky
|
||||
// sequence — Validate already rejects ".." and leading "/", but
|
||||
// EvalSymlinks here is the second wall.
|
||||
//
|
||||
// ctx may be "" (use cloneRoot as-is) or a relative subpath like
|
||||
// "./api" or "services/api".
|
||||
func resolveContextDir(cloneRoot, ctx string) (string, error) {
|
||||
cloneRoot, err := filepath.Abs(cloneRoot)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("abs cloneRoot: %w", err)
|
||||
}
|
||||
if real, err := filepath.EvalSymlinks(cloneRoot); err == nil {
|
||||
cloneRoot = real
|
||||
}
|
||||
if ctx == "" || ctx == "." || ctx == "./" {
|
||||
return cloneRoot, nil
|
||||
}
|
||||
candidate := filepath.Join(cloneRoot, filepath.FromSlash(ctx))
|
||||
candidate, err = filepath.Abs(candidate)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("abs candidate: %w", err)
|
||||
}
|
||||
// Resolve symlinks BEFORE the prefix check so a planted symlink
|
||||
// inside the clone cannot escape the build context.
|
||||
if real, err := filepath.EvalSymlinks(candidate); err == nil {
|
||||
candidate = real
|
||||
}
|
||||
if candidate != cloneRoot && !strings.HasPrefix(candidate, cloneRoot+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("context path %q escapes clone root", ctx)
|
||||
}
|
||||
info, err := os.Stat(candidate)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stat context_path %q: %w", ctx, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", fmt.Errorf("context_path %q is not a directory", ctx)
|
||||
}
|
||||
return candidate, nil
|
||||
}
|
||||
|
||||
// verifyDockerfileExists checks that the named Dockerfile is present in
|
||||
// the resolved context. Returns a focused error for the operator instead
|
||||
// of letting the daemon error out with a less obvious message later.
|
||||
//
|
||||
// dockerfilePath is the value from Config.DockerfilePath — relative to
|
||||
// the context dir, "Dockerfile" by default.
|
||||
func verifyDockerfileExists(contextDir, dockerfilePath string) error {
|
||||
if dockerfilePath == "" {
|
||||
dockerfilePath = "Dockerfile"
|
||||
}
|
||||
if strings.HasPrefix(dockerfilePath, "/") || strings.Contains(dockerfilePath, "..") {
|
||||
return fmt.Errorf("dockerfile_path %q must be relative and contain no '..'", dockerfilePath)
|
||||
}
|
||||
full := filepath.Join(contextDir, filepath.FromSlash(dockerfilePath))
|
||||
info, err := os.Stat(full)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("Dockerfile not found at %s/%s", filepath.Base(contextDir), dockerfilePath)
|
||||
}
|
||||
return fmt.Errorf("stat Dockerfile %q: %w", dockerfilePath, err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return fmt.Errorf("dockerfile_path %q points at a directory, not a file", dockerfilePath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sanitizeError clamps an error string before it lands in
|
||||
// containers.extra_json (last_error) or echoes through an outbound
|
||||
// notification webhook. Mirrors the static-plugin helper of the same
|
||||
// name so both plugins agree on the surface area they expose to
|
||||
// operators.
|
||||
func sanitizeError(msg, accessToken string) string {
|
||||
return sanitizeErrorWithSecrets(msg, accessToken, nil)
|
||||
}
|
||||
|
||||
// sanitizeErrorWithSecrets is the dockerfile-plugin-specific extension:
|
||||
// when capturing container build/runtime logs into last_error we ALSO
|
||||
// need to redact decrypted env-var values, because a malicious or
|
||||
// debug-laden Dockerfile can `RUN echo $SECRET` and land a runtime
|
||||
// secret in operator-readable state via /api/workloads/{id}/runtime-state.
|
||||
//
|
||||
// envKV is the same []string the docker client receives — entries shaped
|
||||
// "KEY=VALUE". We split on the first '=' and redact every non-empty
|
||||
// VALUE longer than 3 chars (shorter values produce too many false-
|
||||
// positive substring matches against words like "is" / "of").
|
||||
func sanitizeErrorWithSecrets(msg, accessToken string, envKV []string) string {
|
||||
if msg == "" {
|
||||
return ""
|
||||
}
|
||||
if accessToken != "" {
|
||||
msg = strings.ReplaceAll(msg, accessToken, "[REDACTED]")
|
||||
}
|
||||
for _, kv := range envKV {
|
||||
eq := strings.IndexByte(kv, '=')
|
||||
if eq < 0 {
|
||||
continue
|
||||
}
|
||||
value := kv[eq+1:]
|
||||
if len(value) < 4 {
|
||||
continue
|
||||
}
|
||||
msg = strings.ReplaceAll(msg, value, "[REDACTED]")
|
||||
}
|
||||
msg = strings.Map(func(r rune) rune {
|
||||
switch r {
|
||||
case '\n', '\r', '\t':
|
||||
return ' '
|
||||
}
|
||||
return r
|
||||
}, msg)
|
||||
const maxLen = 240
|
||||
if len(msg) > maxLen {
|
||||
// Rune-aware truncation: walk back to the previous rune
|
||||
// boundary so multi-byte chars at the cap don't tear.
|
||||
cut := maxLen
|
||||
for cut > 0 && !isRuneStart(msg[cut]) {
|
||||
cut--
|
||||
}
|
||||
msg = msg[:cut] + "…"
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// isRuneStart reports whether b is a leading byte of a UTF-8 sequence.
|
||||
// Used to walk back from a byte-offset cut to a rune boundary.
|
||||
func isRuneStart(b byte) bool {
|
||||
return b&0xC0 != 0x80
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package dockerfile
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
)
|
||||
|
||||
// idShort is the first 8 chars of the workload ID. Same shape as the
|
||||
// static plugin — workload names are not UNIQUE in the schema, the ID
|
||||
// short suffix is what keeps two same-named workloads from clobbering
|
||||
// each other's container/image artifacts.
|
||||
func idShort(w plugin.Workload) string {
|
||||
if len(w.ID) < 8 {
|
||||
return w.ID
|
||||
}
|
||||
return w.ID[:8]
|
||||
}
|
||||
|
||||
// containerNameFor is the deterministic container name. Prefix `tf-build-`
|
||||
// distinguishes a dockerfile-built container from `dw-site-` (static) and
|
||||
// per-stage image names at a glance in `docker ps`.
|
||||
func containerNameFor(w plugin.Workload) string {
|
||||
return fmt.Sprintf("tf-build-%s-%s", w.Name, idShort(w))
|
||||
}
|
||||
|
||||
// imageTagFor is the deterministic image tag the build step emits. Same
|
||||
// shape as the container name so `docker images` shows the linkage at a
|
||||
// glance.
|
||||
func imageTagFor(w plugin.Workload) string {
|
||||
return fmt.Sprintf("tf-build-%s-%s:latest", w.Name, idShort(w))
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package dockerfile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
)
|
||||
|
||||
// reconcile syncs the container row's state column with Docker reality
|
||||
// for this workload's single container, and marks the runtime state as
|
||||
// "failed" if the container is gone or has crashed. Same shape as the
|
||||
// static plugin's reconcile — minimal, no automatic re-build on a
|
||||
// missing container. The dashboard surfaces the failed status; the
|
||||
// operator triggers redeploy explicitly.
|
||||
//
|
||||
// Auto-redeploy could be added later, but it should be gated on a
|
||||
// per-workload toggle: a crash loop with auto-rebuild would burn CPU
|
||||
// rebuilding the same broken commit forever.
|
||||
func reconcile(ctx context.Context, deps plugin.Deps, w plugin.Workload) error {
|
||||
st, prevContainer, err := loadState(deps, w)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if prevContainer == nil || prevContainer.ContainerID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
running, err := deps.Docker.IsContainerRunning(ctx, prevContainer.ContainerID)
|
||||
if err != nil {
|
||||
// Most likely "no such container" — mark missing so the UI
|
||||
// surfaces it; runtime status moves to "failed" so the
|
||||
// dashboard and operator event triggers see the regression.
|
||||
if uerr := deps.Store.UpdateContainerState(prevContainer.ID, "missing"); uerr != nil {
|
||||
slog.Warn("dockerfile: mark missing", "workload", w.Name, "error", uerr)
|
||||
}
|
||||
if st.Status == "deployed" {
|
||||
if uerr := saveState(deps, w, func(rs *runtimeState, c *store.Container) {
|
||||
rs.Status = "failed"
|
||||
rs.LastError = "container not found"
|
||||
c.State = "missing"
|
||||
}); uerr != nil {
|
||||
slog.Warn("dockerfile: persist missing-state", "workload", w.Name, "error", uerr)
|
||||
}
|
||||
publishEvent(deps, w, "failed: container not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
desired := "running"
|
||||
if !running {
|
||||
desired = "stopped"
|
||||
}
|
||||
if prevContainer.State != desired {
|
||||
if err := deps.Store.UpdateContainerState(prevContainer.ID, desired); err != nil {
|
||||
slog.Warn("dockerfile: state sync", "workload", w.Name, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !running && st.Status == "deployed" {
|
||||
if err := saveState(deps, w, func(rs *runtimeState, c *store.Container) {
|
||||
rs.Status = "failed"
|
||||
rs.LastError = "container stopped unexpectedly"
|
||||
c.State = "stopped"
|
||||
}); err != nil {
|
||||
slog.Warn("dockerfile: persist crashed-state", "workload", w.Name, "error", err)
|
||||
}
|
||||
publishEvent(deps, w, "failed: container stopped unexpectedly")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package dockerfile
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
)
|
||||
|
||||
// runtimeState is the per-workload state we persist inside the
|
||||
// container row's extra_json blob. Mirrors the static plugin's
|
||||
// runtimeState shape so anyone reading the DB can interpret the two
|
||||
// kinds identically.
|
||||
//
|
||||
// LastImageDigest is the build's image ID — distinct from a registry
|
||||
// digest (we never push) but useful for "did the build actually
|
||||
// produce a different artifact?" diffing when we add caching later.
|
||||
type runtimeState struct {
|
||||
LastCommitSHA string `json:"last_commit_sha,omitempty"`
|
||||
LastImageDigest string `json:"last_image_digest,omitempty"`
|
||||
LastSyncAt string `json:"last_sync_at,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// runtimeStateKeys lists every JSON field name owned by runtimeState.
|
||||
// saveState strips these from the generic map before re-emitting so
|
||||
// the typed values do not double-write under both their JSON tag and
|
||||
// any subsequent extension's tag.
|
||||
var runtimeStateKeys = []string{
|
||||
"last_commit_sha", "last_image_digest", "last_sync_at", "last_error", "status",
|
||||
}
|
||||
|
||||
// containerRowID is the deterministic container row ID. Stable across
|
||||
// redeploys so saveState upserts in place.
|
||||
func containerRowID(w plugin.Workload) string {
|
||||
return w.ID + ":dockerfile"
|
||||
}
|
||||
|
||||
// loadState returns the persisted runtime state plus the underlying
|
||||
// container row. Both values are zero on first deploy.
|
||||
func loadState(deps plugin.Deps, w plugin.Workload) (runtimeState, *store.Container, error) {
|
||||
row, err := deps.Store.GetContainerByID(containerRowID(w))
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return runtimeState{}, nil, nil
|
||||
}
|
||||
return runtimeState{}, nil, fmt.Errorf("dockerfile source: load state: %w", err)
|
||||
}
|
||||
st := runtimeState{}
|
||||
if row.ExtraJSON != "" && row.ExtraJSON != "{}" {
|
||||
if err := json.Unmarshal([]byte(row.ExtraJSON), &st); err != nil {
|
||||
slog.Debug("dockerfile source: decode extra_json", "workload", w.ID, "error", err)
|
||||
}
|
||||
}
|
||||
return st, &row, nil
|
||||
}
|
||||
|
||||
// saveLocks serializes per-workload RMW of the container row. Same
|
||||
// pattern as the static plugin — SQLite's MaxOpenConns=1 serializes
|
||||
// statements but not the caller's read-then-write intent, so two
|
||||
// concurrent deploys for the same workload could stomp each other's
|
||||
// container_id / proxy_route_id without this mutex.
|
||||
//
|
||||
// Entries are reference-counted and removed only when the last holder
|
||||
// releases. This bounds memory (no per-workload-ID leak) WITHOUT the
|
||||
// use-after-delete hazard of deleting an entry on teardown: deleting a
|
||||
// live entry while a concurrent saveState still holds (or is about to
|
||||
// lock) it would let a fresh saveState mint a SECOND mutex for the same
|
||||
// workload, losing the RMW serialization the lock exists to provide.
|
||||
var saveLocks struct {
|
||||
mu sync.Mutex
|
||||
locks map[string]*saveLock
|
||||
}
|
||||
|
||||
type saveLock struct {
|
||||
mu sync.Mutex
|
||||
refs int
|
||||
}
|
||||
|
||||
// acquireSaveLock returns the per-workload lock (creating it on first use),
|
||||
// registers this caller as a holder, and takes the lock. Pair with
|
||||
// releaseSaveLock. The outer mutex is held only for the bookkeeping; callers
|
||||
// contend on the returned per-workload lock.
|
||||
func acquireSaveLock(workloadID string) *saveLock {
|
||||
saveLocks.mu.Lock()
|
||||
if saveLocks.locks == nil {
|
||||
saveLocks.locks = map[string]*saveLock{}
|
||||
}
|
||||
l, ok := saveLocks.locks[workloadID]
|
||||
if !ok {
|
||||
l = &saveLock{}
|
||||
saveLocks.locks[workloadID] = l
|
||||
}
|
||||
l.refs++
|
||||
saveLocks.mu.Unlock()
|
||||
l.mu.Lock()
|
||||
return l
|
||||
}
|
||||
|
||||
// releaseSaveLock unlocks and drops the caller's reference, removing the map
|
||||
// entry once no holders remain. Because refs is incremented under saveLocks.mu
|
||||
// before the entry can be observed for deletion, an entry with a pending
|
||||
// acquirer is never deleted.
|
||||
func releaseSaveLock(workloadID string, l *saveLock) {
|
||||
l.mu.Unlock()
|
||||
saveLocks.mu.Lock()
|
||||
l.refs--
|
||||
if l.refs == 0 {
|
||||
delete(saveLocks.locks, workloadID)
|
||||
}
|
||||
saveLocks.mu.Unlock()
|
||||
}
|
||||
|
||||
// saveState upserts the container row, calling mutate so callers can
|
||||
// adjust both the typed runtime state and the row's first-class fields
|
||||
// in one transaction. Unknown keys in extra_json survive the round-trip
|
||||
// so future writers can extend the blob without forcing this struct to
|
||||
// grow.
|
||||
func saveState(deps plugin.Deps, w plugin.Workload, mutate func(*runtimeState, *store.Container)) error {
|
||||
lk := acquireSaveLock(w.ID)
|
||||
defer releaseSaveLock(w.ID, lk)
|
||||
|
||||
prev, prevRow, err := loadState(deps, w)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
row := store.Container{
|
||||
ID: containerRowID(w),
|
||||
WorkloadID: w.ID,
|
||||
WorkloadKind: string(store.WorkloadKindBuild),
|
||||
Host: "local",
|
||||
}
|
||||
if prevRow != nil {
|
||||
row = *prevRow
|
||||
}
|
||||
|
||||
generic := map[string]json.RawMessage{}
|
||||
if row.ExtraJSON != "" && row.ExtraJSON != "{}" {
|
||||
if err := json.Unmarshal([]byte(row.ExtraJSON), &generic); err != nil {
|
||||
slog.Debug("dockerfile source: decode extra_json (generic)", "workload", w.ID, "error", err)
|
||||
}
|
||||
}
|
||||
for _, k := range runtimeStateKeys {
|
||||
delete(generic, k)
|
||||
}
|
||||
|
||||
state := prev
|
||||
mutate(&state, &row)
|
||||
|
||||
typedBytes, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dockerfile source: marshal state: %w", err)
|
||||
}
|
||||
typedMap := map[string]json.RawMessage{}
|
||||
if err := json.Unmarshal(typedBytes, &typedMap); err != nil {
|
||||
return fmt.Errorf("dockerfile source: re-decode typed state: %w", err)
|
||||
}
|
||||
for k, v := range typedMap {
|
||||
generic[k] = v
|
||||
}
|
||||
|
||||
merged, err := json.Marshal(generic)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dockerfile source: marshal merged state: %w", err)
|
||||
}
|
||||
row.ExtraJSON = string(merged)
|
||||
row.LastSeenAt = store.Now()
|
||||
|
||||
if err := deps.Store.UpsertContainer(row); err != nil {
|
||||
return fmt.Errorf("dockerfile source: upsert container row: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package dockerfile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
)
|
||||
|
||||
// teardown drops every artifact deploy created: the running container,
|
||||
// the proxy route, the container index row. Idempotent — a workload
|
||||
// that never deployed is a no-op.
|
||||
//
|
||||
// The built image tag is left in place: removing it would invalidate
|
||||
// the docker build cache (next deploy of the same workload would
|
||||
// rebuild from scratch). Operators can prune unused images via the
|
||||
// existing Settings → Prune Images path.
|
||||
func teardown(ctx context.Context, deps plugin.Deps, w plugin.Workload) error {
|
||||
_, prevContainer, err := loadState(deps, w)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if prevContainer == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Proxy first so traffic stops landing on a container that is
|
||||
// about to disappear.
|
||||
if prevContainer.ProxyRouteID != "" {
|
||||
if err := deps.Proxy.DeleteRoute(ctx, prevContainer.ProxyRouteID); err != nil {
|
||||
slog.Warn("dockerfile: failed to remove proxy route", "workload", w.Name, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if prevContainer.ContainerID != "" {
|
||||
if err := deps.Docker.RemoveContainer(ctx, prevContainer.ContainerID, true); err != nil {
|
||||
slog.Warn("dockerfile: failed to remove container", "workload", w.Name, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := deps.Store.DeleteContainer(prevContainer.ID); err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||
slog.Warn("dockerfile: failed to delete container row", "workload", w.Name, "error", err)
|
||||
}
|
||||
// The per-workload save-mutex is reference-counted (see state.go) and
|
||||
// frees itself when the last holder releases, so teardown no longer
|
||||
// deletes it explicitly — doing so could race a concurrent saveState
|
||||
// and break the RMW serialization the lock provides.
|
||||
return nil
|
||||
}
|
||||
@@ -444,22 +444,12 @@ func updateStatus(deps plugin.Deps, w plugin.Workload, status, commitSHA, errMsg
|
||||
}
|
||||
|
||||
// dispatchSiteNotification fires a site_sync_success or
|
||||
// site_sync_failure event to the configured outbound webhook.
|
||||
// Resolution: per-workload URL+secret first, then fall through to
|
||||
// settings.notification_url/secret. Always best-effort.
|
||||
// site_sync_failure event for the workload via the shared multi-route
|
||||
// dispatcher in plugin.DispatchNotificationForWorkload. Resolution
|
||||
// order (workload_notifications → legacy single URL → settings global)
|
||||
// is identical to the dockerfile plugin's path so receivers see
|
||||
// consistent fan-out behaviour across source kinds.
|
||||
func dispatchSiteNotification(deps plugin.Deps, w plugin.Workload, domain, status, errMsg string) {
|
||||
if deps.Notifier == nil {
|
||||
return
|
||||
}
|
||||
settings, err := deps.Store.GetSettings()
|
||||
if err != nil {
|
||||
slog.Warn("static site: notify settings lookup failed", "site", w.ID, "error", err)
|
||||
return
|
||||
}
|
||||
url, secret, tier := resolveSiteTarget(w, settings)
|
||||
if url == "" {
|
||||
return
|
||||
}
|
||||
eventType := "site_sync_success"
|
||||
if status == "failed" {
|
||||
eventType = "site_sync_failure"
|
||||
@@ -468,7 +458,7 @@ func dispatchSiteNotification(deps plugin.Deps, w plugin.Workload, domain, statu
|
||||
if domain != "" {
|
||||
siteURL = "https://" + domain
|
||||
}
|
||||
deps.Notifier.SendSigned(url, secret, tier, notify.Event{
|
||||
plugin.DispatchNotificationForWorkload(deps, w, notify.Event{
|
||||
Type: eventType,
|
||||
Project: w.Name,
|
||||
URL: siteURL,
|
||||
@@ -476,16 +466,6 @@ func dispatchSiteNotification(deps plugin.Deps, w plugin.Workload, domain, statu
|
||||
})
|
||||
}
|
||||
|
||||
// resolveSiteTarget mirrors the legacy resolveSiteTarget helper but
|
||||
// reads notification config off the workload row (where it now lives
|
||||
// post-refactor) rather than the static_sites row.
|
||||
func resolveSiteTarget(w plugin.Workload, settings store.Settings) (string, string, notify.Tier) {
|
||||
if w.NotificationURL != "" {
|
||||
return w.NotificationURL, w.NotificationSecret, notify.TierSite
|
||||
}
|
||||
return settings.NotificationURL, settings.NotificationSecret, notify.TierSettings
|
||||
}
|
||||
|
||||
// publishEvent emits a static_site_status event on the bus AND
|
||||
// persists an event_log row so the dashboard's audit trail picks it
|
||||
// up. Message format ("Static site \"%s\": %s") is preserved verbatim
|
||||
|
||||
@@ -165,30 +165,42 @@ func TestContainerRowID_Deterministic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockFor_ReturnsSameLockForSameWorkload(t *testing.T) {
|
||||
// Suffix by t.Name() so the package-global saveLocks map cannot
|
||||
// bleed key state between tests (or between -count=N runs).
|
||||
func TestSaveLock_FreedWhenIdle(t *testing.T) {
|
||||
// After the last holder releases, the reference-counted entry must be
|
||||
// removed from the map so the lock table cannot grow without bound.
|
||||
// Suffix by t.Name() so the package-global saveLocks map cannot bleed
|
||||
// key state between tests (or between -count=N runs).
|
||||
key := t.Name() + "-wid"
|
||||
a := lockFor(key)
|
||||
b := lockFor(key)
|
||||
if a != b {
|
||||
t.Fatalf("lockFor returned distinct locks for same workload: %p vs %p", a, b)
|
||||
lk := acquireSaveLock(key)
|
||||
saveLocks.mu.Lock()
|
||||
_, present := saveLocks.locks[key]
|
||||
saveLocks.mu.Unlock()
|
||||
if !present {
|
||||
t.Fatal("acquireSaveLock did not register the entry while held")
|
||||
}
|
||||
releaseSaveLock(key, lk)
|
||||
saveLocks.mu.Lock()
|
||||
_, stillPresent := saveLocks.locks[key]
|
||||
saveLocks.mu.Unlock()
|
||||
if stillPresent {
|
||||
t.Fatal("releaseSaveLock left the entry behind after the last holder released")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockFor_ReturnsDistinctLocksForDifferentWorkloads(t *testing.T) {
|
||||
a := lockFor(t.Name() + "-a")
|
||||
b := lockFor(t.Name() + "-b")
|
||||
if a == b {
|
||||
t.Fatalf("lockFor returned same lock for different workloads: %p", a)
|
||||
}
|
||||
func TestSaveLock_DistinctWorkloadsDoNotSerialize(t *testing.T) {
|
||||
// Two different workloads must be lockable at the same time. If they
|
||||
// shared a mutex the second acquire would block forever (deadlock).
|
||||
a := acquireSaveLock(t.Name() + "-a")
|
||||
b := acquireSaveLock(t.Name() + "-b")
|
||||
releaseSaveLock(t.Name()+"-b", b)
|
||||
releaseSaveLock(t.Name()+"-a", a)
|
||||
}
|
||||
|
||||
func TestLockFor_SerializesConcurrentAcquisitions(t *testing.T) {
|
||||
// Two goroutines holding the same lock must run sequentially. The
|
||||
// counter would race past 2 if locking were broken; with the lock,
|
||||
// the increment is observed monotonically.
|
||||
lk := lockFor(t.Name() + "-wid")
|
||||
func TestSaveLock_SerializesConcurrentAcquisitions(t *testing.T) {
|
||||
// Goroutines acquiring the same workload's lock must run sequentially.
|
||||
// The counter would race past 1 if locking were broken; with the lock,
|
||||
// peak in-flight stays at 1.
|
||||
key := t.Name() + "-wid"
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
@@ -199,8 +211,8 @@ func TestLockFor_SerializesConcurrentAcquisitions(t *testing.T) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
lk.Lock()
|
||||
defer lk.Unlock()
|
||||
lk := acquireSaveLock(key)
|
||||
defer releaseSaveLock(key, lk)
|
||||
|
||||
mu.Lock()
|
||||
counter++
|
||||
@@ -216,15 +228,15 @@ func TestLockFor_SerializesConcurrentAcquisitions(t *testing.T) {
|
||||
}
|
||||
wg.Wait()
|
||||
if peak != 1 {
|
||||
t.Fatalf("lockFor failed to serialize: peak in-flight = %d, want 1", peak)
|
||||
t.Fatalf("acquireSaveLock failed to serialize: peak in-flight = %d, want 1", peak)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockFor_ConcurrentMapAccessIsSafe(t *testing.T) {
|
||||
// Distinct workloads acquired in parallel must not panic on map
|
||||
// access — exercises the outer-mutex protection inside lockFor.
|
||||
// Each iteration uses a unique key so the test stresses the
|
||||
// insertion path (the common case for "first deploy" callers).
|
||||
func TestSaveLock_ConcurrentMapAccessIsSafe(t *testing.T) {
|
||||
// Distinct workloads acquired+released in parallel must not panic on map
|
||||
// access — exercises the outer-mutex protection inside acquire/release.
|
||||
// Each iteration uses a unique key so the test stresses the insertion +
|
||||
// refcount-cleanup paths (the common case for "first deploy" callers).
|
||||
prefix := t.Name() + "-"
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 50; i++ {
|
||||
@@ -232,9 +244,9 @@ func TestLockFor_ConcurrentMapAccessIsSafe(t *testing.T) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
lk := lockFor(prefix + strconv.Itoa(i))
|
||||
lk.Lock()
|
||||
lk.Unlock()
|
||||
key := prefix + strconv.Itoa(i)
|
||||
lk := acquireSaveLock(key)
|
||||
releaseSaveLock(key, lk)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
@@ -80,26 +80,55 @@ func loadState(deps plugin.Deps, w plugin.Workload) (runtimeState, *store.Contai
|
||||
// container_id / proxy_route_id and orphaning Docker resources. The
|
||||
// mutex caps the concurrency at 1 per workload; cross-workload
|
||||
// parallelism is unaffected.
|
||||
//
|
||||
// Entries are reference-counted and removed only when the last holder
|
||||
// releases. This bounds memory (no per-workload-ID leak) WITHOUT the
|
||||
// use-after-delete hazard of deleting an entry on teardown: deleting a
|
||||
// live entry while a concurrent saveState still holds (or is about to
|
||||
// lock) it would let a fresh saveState mint a SECOND mutex for the same
|
||||
// workload, losing the RMW serialization the lock exists to provide.
|
||||
var saveLocks struct {
|
||||
mu sync.Mutex
|
||||
locks map[string]*sync.Mutex
|
||||
locks map[string]*saveLock
|
||||
}
|
||||
|
||||
// lockFor returns the per-workload mutex, creating it on first use.
|
||||
// The outer mutex is held only briefly during map lookup; the returned
|
||||
// per-workload lock is what callers actually contend on.
|
||||
func lockFor(workloadID string) *sync.Mutex {
|
||||
type saveLock struct {
|
||||
mu sync.Mutex
|
||||
refs int
|
||||
}
|
||||
|
||||
// acquireSaveLock returns the per-workload lock (creating it on first use),
|
||||
// registers this caller as a holder, and takes the lock. Pair with
|
||||
// releaseSaveLock. The outer mutex is held only for the bookkeeping; callers
|
||||
// contend on the returned per-workload lock.
|
||||
func acquireSaveLock(workloadID string) *saveLock {
|
||||
saveLocks.mu.Lock()
|
||||
defer saveLocks.mu.Unlock()
|
||||
if saveLocks.locks == nil {
|
||||
saveLocks.locks = map[string]*sync.Mutex{}
|
||||
saveLocks.locks = map[string]*saveLock{}
|
||||
}
|
||||
m, ok := saveLocks.locks[workloadID]
|
||||
l, ok := saveLocks.locks[workloadID]
|
||||
if !ok {
|
||||
m = &sync.Mutex{}
|
||||
saveLocks.locks[workloadID] = m
|
||||
l = &saveLock{}
|
||||
saveLocks.locks[workloadID] = l
|
||||
}
|
||||
return m
|
||||
l.refs++
|
||||
saveLocks.mu.Unlock()
|
||||
l.mu.Lock()
|
||||
return l
|
||||
}
|
||||
|
||||
// releaseSaveLock unlocks and drops the caller's reference, removing the map
|
||||
// entry once no holders remain. Because refs is incremented under saveLocks.mu
|
||||
// before the entry can be observed for deletion, an entry with a pending
|
||||
// acquirer is never deleted.
|
||||
func releaseSaveLock(workloadID string, l *saveLock) {
|
||||
l.mu.Unlock()
|
||||
saveLocks.mu.Lock()
|
||||
l.refs--
|
||||
if l.refs == 0 {
|
||||
delete(saveLocks.locks, workloadID)
|
||||
}
|
||||
saveLocks.mu.Unlock()
|
||||
}
|
||||
|
||||
// saveState upserts the container row, calling mutate so callers can
|
||||
@@ -115,9 +144,8 @@ func lockFor(workloadID string) *sync.Mutex {
|
||||
// Per-workload mutex serializes concurrent callers so two parallel
|
||||
// Deploys can't read the same prior state and race their writes.
|
||||
func saveState(deps plugin.Deps, w plugin.Workload, mutate func(*runtimeState, *store.Container)) error {
|
||||
lk := lockFor(w.ID)
|
||||
lk.Lock()
|
||||
defer lk.Unlock()
|
||||
lk := acquireSaveLock(w.ID)
|
||||
defer releaseSaveLock(w.ID, lk)
|
||||
|
||||
prev, prevRow, err := loadState(deps, w)
|
||||
if err != nil {
|
||||
|
||||
@@ -185,14 +185,23 @@ func TestSaveState_RecoversFromInvalidExtraJSON(t *testing.T) {
|
||||
deps, _ := testDeps(t)
|
||||
w := plugin.Workload{ID: t.Name() + "-wid", Name: "site"}
|
||||
|
||||
// UpsertContainer now validates extra_json at the boundary, so this
|
||||
// test seeds a valid row first and corrupts it via raw SQL to
|
||||
// simulate a pre-existing bad row from an upgrade / external edit.
|
||||
if err := deps.Store.UpsertContainer(store.Container{
|
||||
ID: containerRowID(w),
|
||||
WorkloadID: w.ID,
|
||||
WorkloadKind: string(store.WorkloadKindSite),
|
||||
Host: "local",
|
||||
ExtraJSON: `{not json`,
|
||||
ExtraJSON: `{}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed bad row: %v", err)
|
||||
t.Fatalf("seed row: %v", err)
|
||||
}
|
||||
if _, err := deps.Store.DB().Exec(
|
||||
`UPDATE containers SET extra_json = ? WHERE id = ?`,
|
||||
`{not json`, containerRowID(w),
|
||||
); err != nil {
|
||||
t.Fatalf("corrupt extra_json: %v", err)
|
||||
}
|
||||
|
||||
err := saveState(deps, w, func(state *runtimeState, _ *store.Container) {
|
||||
|
||||
@@ -66,5 +66,8 @@ func teardown(ctx context.Context, deps plugin.Deps, w plugin.Workload) error {
|
||||
if err := deps.Store.DeleteContainer(prevContainer.ID); err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||
slog.Warn("static site: failed to delete container row", "site", w.Name, "error", err)
|
||||
}
|
||||
// The per-workload save-mutex is reference-counted (see state.go) and
|
||||
// frees itself when the last holder releases, so teardown no longer
|
||||
// deletes it explicitly — doing so could race a concurrent saveState.
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -18,11 +18,19 @@ import (
|
||||
// match the event repo). Mode controls whether branch pushes or tag
|
||||
// pushes fire the deploy. Branch is exact-matched when Mode=="push";
|
||||
// TagPattern is glob-matched when Mode=="tag".
|
||||
//
|
||||
// BranchPattern is the preview-deploy escape hatch: when non-empty in
|
||||
// "push" mode it overrides Branch and matches the event branch as a glob
|
||||
// (`feat/*`, `release-*`, `*` for "any branch"). The trigger returns an
|
||||
// intent whose Metadata["preview_branch"] holds the matched branch — the
|
||||
// dispatcher uses that signal to materialize an ephemeral per-branch
|
||||
// child workload rather than redeploying the parent.
|
||||
type Config struct {
|
||||
Repo string `json:"repo"`
|
||||
Mode string `json:"mode"` // "push" | "tag"
|
||||
Branch string `json:"branch"`
|
||||
TagPattern string `json:"tag_pattern"`
|
||||
Repo string `json:"repo"`
|
||||
Mode string `json:"mode"` // "push" | "tag"
|
||||
Branch string `json:"branch"`
|
||||
BranchPattern string `json:"branch_pattern"`
|
||||
TagPattern string `json:"tag_pattern"`
|
||||
}
|
||||
|
||||
type trigger struct{}
|
||||
@@ -49,7 +57,15 @@ func (*trigger) Validate(cfg json.RawMessage) error {
|
||||
}
|
||||
switch c.Mode {
|
||||
case "push":
|
||||
// Branch is optional ("" means any branch).
|
||||
// Branch is optional ("" means any branch). BranchPattern is
|
||||
// validated as a path.Match glob if present; misconfigured
|
||||
// patterns are rejected at the boundary rather than letting them
|
||||
// fail silently inside Match.
|
||||
if c.BranchPattern != "" {
|
||||
if _, err := path.Match(c.BranchPattern, "probe"); err != nil {
|
||||
return fmt.Errorf("git trigger: invalid branch_pattern %q: %w", c.BranchPattern, err)
|
||||
}
|
||||
}
|
||||
case "tag":
|
||||
pattern := c.TagPattern
|
||||
if pattern == "" {
|
||||
@@ -90,8 +106,24 @@ func (*trigger) Match(ctx context.Context, deps plugin.Deps, w plugin.Workload,
|
||||
if evt.Git.Tag != "" {
|
||||
meta["tag"] = evt.Git.Tag
|
||||
}
|
||||
// Preview-deploy signal: when BranchPattern is set AND the matched
|
||||
// branch is NOT the configured baseline Branch, flag this dispatch
|
||||
// for materialization as a per-branch child workload. The dispatcher
|
||||
// reads preview_branch and decides whether to spawn a preview row;
|
||||
// a baseline-branch push falls through to a normal redeploy of the
|
||||
// template itself.
|
||||
if cfg.Mode == "push" && cfg.BranchPattern != "" && evt.Git.Branch != "" && evt.Git.Branch != cfg.Branch {
|
||||
meta["preview_branch"] = evt.Git.Branch
|
||||
if evt.Git.Deleted {
|
||||
meta["preview_deleted"] = "1"
|
||||
}
|
||||
}
|
||||
reason := "git-push"
|
||||
if meta["preview_deleted"] == "1" {
|
||||
reason = "git-branch-deleted"
|
||||
}
|
||||
return &plugin.DeploymentIntent{
|
||||
Reason: "git-push",
|
||||
Reason: reason,
|
||||
Reference: evt.Git.CommitSHA,
|
||||
Metadata: meta,
|
||||
TriggeredAt: time.Now().UTC(),
|
||||
@@ -106,6 +138,17 @@ func refMatches(cfg Config, ref string) bool {
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
// Pattern-mode preview filter: any branch whose name matches the
|
||||
// glob is in scope. The baseline `cfg.Branch` is also allowed so
|
||||
// pushes to the template's primary branch keep redeploying the
|
||||
// template itself.
|
||||
if cfg.BranchPattern != "" {
|
||||
if cfg.Branch != "" && cfg.Branch == branch {
|
||||
return true
|
||||
}
|
||||
matched, err := path.Match(cfg.BranchPattern, branch)
|
||||
return err == nil && matched
|
||||
}
|
||||
return cfg.Branch == "" || cfg.Branch == branch
|
||||
case "tag":
|
||||
tag, ok := strings.CutPrefix(ref, "refs/tags/")
|
||||
|
||||
@@ -56,14 +56,21 @@ type ImagePushEvent struct {
|
||||
|
||||
// GitEvent covers both push (commits) and tag-create flavors. Vendor is
|
||||
// "gitea" | "github" | "gitlab" | "" (autodetected).
|
||||
//
|
||||
// Deleted is true when the push event reports a branch / tag was deleted.
|
||||
// Used by the preview-deploy flow to tear down ephemeral per-branch
|
||||
// workloads when a feature branch is removed upstream. Inferred from
|
||||
// GitHub-style `deleted: true` and Gitea's identical convention; GitLab
|
||||
// signals deletion via after-SHA zeros (parsed at vendor level).
|
||||
type GitEvent struct {
|
||||
Vendor string
|
||||
Repo string // owner/name
|
||||
Ref string // refs/heads/main or refs/tags/v1.2.3
|
||||
Branch string // populated for branch refs
|
||||
Tag string // populated for tag refs
|
||||
Vendor string
|
||||
Repo string // owner/name
|
||||
Ref string // refs/heads/main or refs/tags/v1.2.3
|
||||
Branch string // populated for branch refs
|
||||
Tag string // populated for tag refs
|
||||
CommitSHA string
|
||||
Pusher string
|
||||
Deleted bool
|
||||
}
|
||||
|
||||
// ManualEvent represents a user-initiated deploy from the UI or API.
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
// Package preview implements branch-pattern preview deploys. A "template"
|
||||
// workload is one whose git trigger has a BranchPattern configured; when
|
||||
// an inbound push event names a branch other than the template's primary
|
||||
// Branch, the dispatcher materializes (or reuses) a child workload via
|
||||
// MaterializeForBranch and dispatches the deploy against the child. The
|
||||
// child is then torn down on a matching branch-delete event.
|
||||
//
|
||||
// The package is intentionally narrow:
|
||||
// - it does not know about Docker, the proxy, or any plugin internals
|
||||
// - it operates over a Store interface so the webhook handler can mock
|
||||
// it in tests
|
||||
// - it owns the per-branch naming + subdomain mangling so the wiring
|
||||
// code (trigger fan-out) stays a pure dispatch path
|
||||
package preview
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
// Store is the slice of the persistence layer the preview package needs.
|
||||
// Defined locally so tests can fake it without dragging the full Store.
|
||||
type Store interface {
|
||||
GetWorkloadByID(id string) (store.Workload, error)
|
||||
ListChildrenByParent(parentID string) ([]store.Workload, error)
|
||||
CreateWorkload(w store.Workload) (store.Workload, error)
|
||||
DeleteWorkload(id string) error
|
||||
}
|
||||
|
||||
// branchSlugPattern strips characters that are unsafe inside a Docker
|
||||
// container name, hostname label, or filesystem path. Compiled once.
|
||||
var branchSlugPattern = regexp.MustCompile(`[^a-z0-9-]+`)
|
||||
|
||||
// slugifyBranch converts a git ref-component into a safe slug. Lowercase,
|
||||
// hyphen-only, length-capped to 32 so name + slug fit inside the Docker
|
||||
// 63-char container-name and 63-char DNS-label limits with room for the
|
||||
// `tf-build-` prefix.
|
||||
func slugifyBranch(branch string) string {
|
||||
b := strings.ToLower(branch)
|
||||
b = strings.ReplaceAll(b, "/", "-")
|
||||
b = branchSlugPattern.ReplaceAllString(b, "-")
|
||||
b = strings.Trim(b, "-")
|
||||
if b == "" {
|
||||
return "branch"
|
||||
}
|
||||
if len(b) > 32 {
|
||||
b = strings.Trim(b[:32], "-")
|
||||
if b == "" {
|
||||
b = "branch"
|
||||
}
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// findExistingPreview returns the child workload whose source_config
|
||||
// already names `branch`, if any. Linear scan over the children list —
|
||||
// fine because the bound is "branches a single team keeps open at once"
|
||||
// which is in the dozens, not thousands.
|
||||
func findExistingPreview(children []store.Workload, branch string) (store.Workload, bool) {
|
||||
for _, c := range children {
|
||||
var cfg struct {
|
||||
Branch string `json:"branch"`
|
||||
}
|
||||
if c.SourceConfig != "" {
|
||||
_ = json.Unmarshal([]byte(c.SourceConfig), &cfg)
|
||||
}
|
||||
if cfg.Branch == branch {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
return store.Workload{}, false
|
||||
}
|
||||
|
||||
// patchSourceConfigBranch returns a copy of the template's source_config
|
||||
// with the `branch` field replaced. Unknown keys round-trip so plugin-
|
||||
// specific config (port, dockerfile path, storage settings, ...) survive.
|
||||
// A malformed source_config is replaced rather than propagated so the
|
||||
// preview workload has a clean baseline.
|
||||
func patchSourceConfigBranch(sourceConfig, branch string) (string, error) {
|
||||
if branch == "" {
|
||||
return "", fmt.Errorf("preview: branch is empty")
|
||||
}
|
||||
m := map[string]json.RawMessage{}
|
||||
if sourceConfig != "" && sourceConfig != "{}" {
|
||||
if err := json.Unmarshal([]byte(sourceConfig), &m); err != nil {
|
||||
m = map[string]json.RawMessage{}
|
||||
}
|
||||
}
|
||||
enc, err := json.Marshal(branch)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("preview: encode branch: %w", err)
|
||||
}
|
||||
m["branch"] = enc
|
||||
out, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("preview: encode source_config: %w", err)
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// patchPublicFacesSubdomain prefixes every public face's Subdomain with
|
||||
// the branch slug so two preview deploys never collide on the same FQDN.
|
||||
// Faces with no subdomain are left untouched — the operator clearly
|
||||
// didn't want a per-branch host carved out for that face.
|
||||
func patchPublicFacesSubdomain(publicFaces, slug string) (string, error) {
|
||||
if publicFaces == "" || publicFaces == "[]" {
|
||||
return publicFaces, nil
|
||||
}
|
||||
var faces []map[string]any
|
||||
if err := json.Unmarshal([]byte(publicFaces), &faces); err != nil {
|
||||
// Malformed faces MUST fail loudly: returning the template's faces
|
||||
// verbatim would give the preview the SAME subdomains as the
|
||||
// template, so the preview's proxy route would clobber the template's
|
||||
// (the exact collision the slug prefix exists to prevent).
|
||||
return "", fmt.Errorf("preview: parse public_faces: %w", err)
|
||||
}
|
||||
for _, f := range faces {
|
||||
sub, ok := f["subdomain"].(string)
|
||||
if !ok || sub == "" {
|
||||
continue
|
||||
}
|
||||
f["subdomain"] = slug + "-" + sub
|
||||
}
|
||||
out, err := json.Marshal(faces)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("preview: re-encode public_faces: %w", err)
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// IsPreviewChild reports whether child was materialized as a branch preview
|
||||
// of template (vs. an operator-created stage-chain member that merely shares
|
||||
// the parent link — both use parent_workload_id). It reverses the exact
|
||||
// MaterializeForBranch naming formula — name == template.Name + "/" +
|
||||
// slugifyBranch(child's branch) — so a hand-named stage workload under the
|
||||
// same parent is never mistaken for a preview and cascade-deleted.
|
||||
func IsPreviewChild(template, child store.Workload) bool {
|
||||
if child.ParentWorkloadID != template.ID {
|
||||
return false
|
||||
}
|
||||
var cfg struct {
|
||||
Branch string `json:"branch"`
|
||||
}
|
||||
if child.SourceConfig != "" {
|
||||
_ = json.Unmarshal([]byte(child.SourceConfig), &cfg)
|
||||
}
|
||||
if cfg.Branch == "" {
|
||||
return false
|
||||
}
|
||||
return child.Name == template.Name+"/"+slugifyBranch(cfg.Branch)
|
||||
}
|
||||
|
||||
// ListPreviewChildren returns every preview workload materialized from
|
||||
// template. Used by the delete path to cascade-teardown previews so deleting
|
||||
// a template does not orphan their containers, proxy routes, and rows.
|
||||
func ListPreviewChildren(s Store, template store.Workload) ([]store.Workload, error) {
|
||||
children, err := s.ListChildrenByParent(template.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("preview: list children: %w", err)
|
||||
}
|
||||
out := make([]store.Workload, 0, len(children))
|
||||
for _, c := range children {
|
||||
if IsPreviewChild(template, c) {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MaterializeForBranch returns the existing preview workload for
|
||||
// (template, branch) or creates one if none exists. The new workload
|
||||
// inherits the template's source kind, trigger kind, notification
|
||||
// settings, and public faces (with the branch slug prefixed onto each
|
||||
// subdomain). Idempotent: a second call with the same arguments returns
|
||||
// the same workload row.
|
||||
func MaterializeForBranch(s Store, template store.Workload, branch string) (store.Workload, error) {
|
||||
if branch == "" {
|
||||
return store.Workload{}, fmt.Errorf("preview: branch is required")
|
||||
}
|
||||
|
||||
children, err := s.ListChildrenByParent(template.ID)
|
||||
if err != nil {
|
||||
return store.Workload{}, fmt.Errorf("preview: list children: %w", err)
|
||||
}
|
||||
if existing, ok := findExistingPreview(children, branch); ok {
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
slug := slugifyBranch(branch)
|
||||
newCfg, err := patchSourceConfigBranch(template.SourceConfig, branch)
|
||||
if err != nil {
|
||||
return store.Workload{}, err
|
||||
}
|
||||
newFaces, err := patchPublicFacesSubdomain(template.PublicFaces, slug)
|
||||
if err != nil {
|
||||
return store.Workload{}, err
|
||||
}
|
||||
|
||||
// Webhook + notification secrets are NOT copied to the preview. The
|
||||
// trigger dispatch reaches previews via the parent's trigger binding,
|
||||
// not via a per-preview inbound webhook, so the preview never needs
|
||||
// its own signing secret. Keeping these empty also stops the preview
|
||||
// from masquerading as a first-class workload in webhook routes.
|
||||
child := store.Workload{
|
||||
Kind: template.Kind,
|
||||
Name: template.Name + "/" + slug,
|
||||
AppID: template.AppID,
|
||||
SourceKind: template.SourceKind,
|
||||
SourceConfig: newCfg,
|
||||
TriggerKind: template.TriggerKind,
|
||||
TriggerConfig: template.TriggerConfig,
|
||||
PublicFaces: newFaces,
|
||||
ParentWorkloadID: template.ID,
|
||||
}
|
||||
created, err := s.CreateWorkload(child)
|
||||
if err != nil {
|
||||
return store.Workload{}, fmt.Errorf("preview: create child: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// FindPreviewForBranch looks up an existing preview without creating
|
||||
// one. Returns (Workload{}, false, nil) when no preview exists. Errors
|
||||
// only on a store failure.
|
||||
func FindPreviewForBranch(s Store, templateID, branch string) (store.Workload, bool, error) {
|
||||
if templateID == "" || branch == "" {
|
||||
return store.Workload{}, false, nil
|
||||
}
|
||||
children, err := s.ListChildrenByParent(templateID)
|
||||
if err != nil {
|
||||
return store.Workload{}, false, fmt.Errorf("preview: list children: %w", err)
|
||||
}
|
||||
w, ok := findExistingPreview(children, branch)
|
||||
return w, ok, nil
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
// fakeStore is a minimal in-memory store satisfying the preview.Store
|
||||
// interface. Tests verify business logic without the SQLite layer.
|
||||
type fakeStore struct {
|
||||
workloads map[string]store.Workload
|
||||
createErr error
|
||||
}
|
||||
|
||||
func newFakeStore() *fakeStore {
|
||||
return &fakeStore{workloads: map[string]store.Workload{}}
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetWorkloadByID(id string) (store.Workload, error) {
|
||||
w, ok := f.workloads[id]
|
||||
if !ok {
|
||||
return store.Workload{}, errors.New("not found")
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListChildrenByParent(parentID string) ([]store.Workload, error) {
|
||||
out := []store.Workload{}
|
||||
for _, w := range f.workloads {
|
||||
if w.ParentWorkloadID == parentID {
|
||||
out = append(out, w)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateWorkload(w store.Workload) (store.Workload, error) {
|
||||
if f.createErr != nil {
|
||||
return store.Workload{}, f.createErr
|
||||
}
|
||||
if w.ID == "" {
|
||||
w.ID = "preview-" + w.Name
|
||||
}
|
||||
f.workloads[w.ID] = w
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) DeleteWorkload(id string) error {
|
||||
delete(f.workloads, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSlugifyBranch_StripsUnsafeChars(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"main", "main"},
|
||||
{"Feature/User-Auth", "feature-user-auth"},
|
||||
{"PR#42", "pr-42"},
|
||||
{"release/v1.2.3", "release-v1-2-3"},
|
||||
{"___", "branch"},
|
||||
{strings.Repeat("a", 50), strings.Repeat("a", 32)},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := slugifyBranch(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("slugifyBranch(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchSourceConfigBranch_PreservesUnknownKeys(t *testing.T) {
|
||||
src := `{"port":3000,"dockerfile_path":"Dockerfile","branch":"main","provider":"github"}`
|
||||
out, err := patchSourceConfigBranch(src, "feat/x")
|
||||
if err != nil {
|
||||
t.Fatalf("patch: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got["branch"] != "feat/x" {
|
||||
t.Errorf("branch = %v, want feat/x", got["branch"])
|
||||
}
|
||||
if got["port"] == nil || got["dockerfile_path"] == nil || got["provider"] == nil {
|
||||
t.Errorf("unknown keys dropped: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchPublicFacesSubdomain_PrefixesSubdomains(t *testing.T) {
|
||||
faces := `[{"subdomain":"app","domain":"example.com"},{"subdomain":"","domain":"raw.example.com"}]`
|
||||
out, err := patchPublicFacesSubdomain(faces, "feat-x")
|
||||
if err != nil {
|
||||
t.Fatalf("patch: %v", err)
|
||||
}
|
||||
var got []map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got[0]["subdomain"] != "feat-x-app" {
|
||||
t.Errorf("first subdomain = %v, want feat-x-app", got[0]["subdomain"])
|
||||
}
|
||||
if got[1]["subdomain"] != "" {
|
||||
t.Errorf("empty subdomain must stay empty, got %v", got[1]["subdomain"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterializeForBranch_CreatesNewWhenMissing(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
template := store.Workload{
|
||||
ID: "tmpl-1",
|
||||
Kind: "project",
|
||||
Name: "myapp",
|
||||
AppID: "app-1",
|
||||
SourceKind: "dockerfile",
|
||||
SourceConfig: `{"branch":"main","port":3000}`,
|
||||
TriggerKind: "git",
|
||||
PublicFaces: `[{"subdomain":"www","domain":"x.test"}]`,
|
||||
}
|
||||
fs.workloads[template.ID] = template
|
||||
|
||||
child, err := MaterializeForBranch(fs, template, "feat/login")
|
||||
if err != nil {
|
||||
t.Fatalf("materialize: %v", err)
|
||||
}
|
||||
if child.ParentWorkloadID != template.ID {
|
||||
t.Errorf("parent = %q, want %q", child.ParentWorkloadID, template.ID)
|
||||
}
|
||||
if !strings.Contains(child.Name, "feat-login") {
|
||||
t.Errorf("name = %q, want it to include slug", child.Name)
|
||||
}
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal([]byte(child.SourceConfig), &cfg); err != nil {
|
||||
t.Fatalf("decode child source_config: %v", err)
|
||||
}
|
||||
if cfg["branch"] != "feat/login" {
|
||||
t.Errorf("child branch = %v, want feat/login", cfg["branch"])
|
||||
}
|
||||
if cfg["port"] == nil {
|
||||
t.Errorf("child should inherit template port; got %+v", cfg)
|
||||
}
|
||||
var faces []map[string]any
|
||||
if err := json.Unmarshal([]byte(child.PublicFaces), &faces); err != nil {
|
||||
t.Fatalf("decode child faces: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(faces[0]["subdomain"].(string), "feat-login-") {
|
||||
t.Errorf("face subdomain = %v, want feat-login- prefix", faces[0]["subdomain"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterializeForBranch_ReusesExisting(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
template := store.Workload{
|
||||
ID: "tmpl-1",
|
||||
Kind: "project",
|
||||
Name: "myapp",
|
||||
SourceKind: "dockerfile",
|
||||
SourceConfig: `{"branch":"main"}`,
|
||||
}
|
||||
fs.workloads[template.ID] = template
|
||||
|
||||
first, err := MaterializeForBranch(fs, template, "feat/x")
|
||||
if err != nil {
|
||||
t.Fatalf("first materialize: %v", err)
|
||||
}
|
||||
second, err := MaterializeForBranch(fs, template, "feat/x")
|
||||
if err != nil {
|
||||
t.Fatalf("second materialize: %v", err)
|
||||
}
|
||||
if first.ID != second.ID {
|
||||
t.Errorf("expected idempotence: got %q then %q", first.ID, second.ID)
|
||||
}
|
||||
if len(fs.workloads) != 2 {
|
||||
t.Errorf("expected exactly one preview created, store has %d", len(fs.workloads))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterializeForBranch_RejectsEmptyBranch(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
_, err := MaterializeForBranch(fs, store.Workload{ID: "tmpl"}, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty branch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindPreviewForBranch_MissingReturnsFalse(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
_, ok, err := FindPreviewForBranch(fs, "tmpl", "feat/x")
|
||||
if err != nil {
|
||||
t.Fatalf("find: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("expected ok=false for missing preview")
|
||||
}
|
||||
}
|
||||
Generated
+549
-1
@@ -22,7 +22,8 @@
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0"
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^4.1.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
@@ -1201,12 +1202,28 @@
|
||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/deep-eql": "*",
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -1232,6 +1249,112 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz",
|
||||
"integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.7",
|
||||
"@vitest/utils": "4.1.7",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz",
|
||||
"integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.7",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz",
|
||||
"integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz",
|
||||
"integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.7",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz",
|
||||
"integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.7",
|
||||
"@vitest/utils": "4.1.7",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz",
|
||||
"integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz",
|
||||
"integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.7",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
@@ -1253,6 +1376,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/axobject-query": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
@@ -1262,6 +1394,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
@@ -1286,6 +1427,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
|
||||
@@ -1358,6 +1505,12 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
|
||||
"integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
@@ -1415,6 +1568,24 @@
|
||||
"@typescript-eslint/types": "^8.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -1785,6 +1956,22 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
]
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -1906,6 +2093,12 @@
|
||||
"integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/sirv": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
|
||||
@@ -1929,6 +2122,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
||||
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/svelte": {
|
||||
"version": "5.55.0",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.0.tgz",
|
||||
@@ -1998,6 +2203,21 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz",
|
||||
"integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -2014,6 +2234,15 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
||||
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/totalist": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||
@@ -2129,6 +2358,111 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz",
|
||||
"integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.7",
|
||||
"@vitest/mocker": "4.1.7",
|
||||
"@vitest/pretty-format": "4.1.7",
|
||||
"@vitest/runner": "4.1.7",
|
||||
"@vitest/snapshot": "4.1.7",
|
||||
"@vitest/spy": "4.1.7",
|
||||
"@vitest/utils": "4.1.7",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"obug": "^2.1.1",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3",
|
||||
"std-env": "^4.0.0-rc.1",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.1.0",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.7",
|
||||
"@vitest/browser-preview": "4.1.7",
|
||||
"@vitest/browser-webdriverio": "4.1.7",
|
||||
"@vitest/coverage-istanbul": "4.1.7",
|
||||
"@vitest/coverage-v8": "4.1.7",
|
||||
"@vitest/ui": "4.1.7",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-playwright": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-preview": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-webdriverio": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-istanbul": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-v8": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"why-is-node-running": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/zimmerframe": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||
@@ -2766,12 +3100,28 @@
|
||||
"tailwindcss": "4.2.2"
|
||||
}
|
||||
},
|
||||
"@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/deep-eql": "*",
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"@types/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -2790,6 +3140,79 @@
|
||||
"integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==",
|
||||
"dev": true
|
||||
},
|
||||
"@vitest/expect": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz",
|
||||
"integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.7",
|
||||
"@vitest/utils": "4.1.7",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"@vitest/mocker": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz",
|
||||
"integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@vitest/spy": "4.1.7",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
}
|
||||
},
|
||||
"@vitest/pretty-format": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz",
|
||||
"integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"tinyrainbow": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"@vitest/runner": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz",
|
||||
"integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@vitest/utils": "4.1.7",
|
||||
"pathe": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"@vitest/snapshot": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz",
|
||||
"integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@vitest/pretty-format": "4.1.7",
|
||||
"@vitest/utils": "4.1.7",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"@vitest/spy": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz",
|
||||
"integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==",
|
||||
"dev": true
|
||||
},
|
||||
"@vitest/utils": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz",
|
||||
"integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@vitest/pretty-format": "4.1.7",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
@@ -2802,12 +3225,24 @@
|
||||
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
|
||||
"dev": true
|
||||
},
|
||||
"assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true
|
||||
},
|
||||
"axobject-query": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
|
||||
"dev": true
|
||||
},
|
||||
"chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"dev": true
|
||||
},
|
||||
"chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
@@ -2823,6 +3258,12 @@
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"dev": true
|
||||
},
|
||||
"convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"dev": true
|
||||
},
|
||||
"cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
|
||||
@@ -2875,6 +3316,12 @@
|
||||
"tapable": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"es-module-lexer": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
|
||||
"integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
|
||||
"dev": true
|
||||
},
|
||||
"esbuild": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
@@ -2925,6 +3372,21 @@
|
||||
"@typescript-eslint/types": "^8.2.0"
|
||||
}
|
||||
},
|
||||
"estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
||||
"dev": true
|
||||
},
|
||||
"fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -3102,6 +3564,18 @@
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"dev": true
|
||||
},
|
||||
"obug": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
|
||||
"dev": true
|
||||
},
|
||||
"pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true
|
||||
},
|
||||
"picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -3181,6 +3655,12 @@
|
||||
"integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==",
|
||||
"dev": true
|
||||
},
|
||||
"siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true
|
||||
},
|
||||
"sirv": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
|
||||
@@ -3198,6 +3678,18 @@
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true
|
||||
},
|
||||
"stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true
|
||||
},
|
||||
"std-env": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
||||
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
|
||||
"dev": true
|
||||
},
|
||||
"svelte": {
|
||||
"version": "5.55.0",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.0.tgz",
|
||||
@@ -3247,6 +3739,18 @@
|
||||
"integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==",
|
||||
"dev": true
|
||||
},
|
||||
"tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||
"dev": true
|
||||
},
|
||||
"tinyexec": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz",
|
||||
"integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==",
|
||||
"dev": true
|
||||
},
|
||||
"tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -3257,6 +3761,12 @@
|
||||
"picomatch": "^4.0.3"
|
||||
}
|
||||
},
|
||||
"tinyrainbow": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
||||
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
|
||||
"dev": true
|
||||
},
|
||||
"totalist": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||
@@ -3296,6 +3806,44 @@
|
||||
"dev": true,
|
||||
"requires": {}
|
||||
},
|
||||
"vitest": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz",
|
||||
"integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@vitest/expect": "4.1.7",
|
||||
"@vitest/mocker": "4.1.7",
|
||||
"@vitest/pretty-format": "4.1.7",
|
||||
"@vitest/runner": "4.1.7",
|
||||
"@vitest/snapshot": "4.1.7",
|
||||
"@vitest/spy": "4.1.7",
|
||||
"@vitest/utils": "4.1.7",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"obug": "^2.1.1",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3",
|
||||
"std-env": "^4.0.0-rc.1",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.1.0",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
}
|
||||
},
|
||||
"zimmerframe": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||
|
||||
+4
-2
@@ -6,7 +6,8 @@
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
@@ -17,7 +18,8 @@
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0"
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^4.1.7"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
+193
-26
@@ -168,13 +168,14 @@ function patch<T>(path: string, body: unknown): Promise<T> {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Deploys (inspect only; quick-deploy retired with /deploy page) ────
|
||||
// `inspectImage` survives because the new-app wizard can use it to pre-fill
|
||||
// image port/healthcheck. `quickDeploy` (POST /api/deploy/quick) is gone:
|
||||
// it created a legacy Project + Stage in the now-dead path.
|
||||
// ── Image inspect (new-app wizard pre-fill) ────────────────────────────
|
||||
// `inspectImage` lets the new-app wizard pre-fill image port/healthcheck
|
||||
// from a LOCAL image's metadata. It posts to the admin-gated discovery
|
||||
// endpoint (the legacy POST /api/deploy/inspect route was dropped in the
|
||||
// cutover). Local-only: it does not pull.
|
||||
|
||||
export function inspectImage(image: string, signal?: AbortSignal): Promise<InspectResult> {
|
||||
return post<InspectResult>('/api/deploy/inspect', { image }, signal);
|
||||
return post<InspectResult>('/api/discovery/image/inspect', { image }, signal);
|
||||
}
|
||||
|
||||
// ── Discovery (/apps/new wizard helpers) ───────────────────────────
|
||||
@@ -441,12 +442,76 @@ export function testNpmConnection(data: { npm_url?: string; npm_email?: string;
|
||||
return post<{ status: string }>('/api/settings/npm/test', data);
|
||||
}
|
||||
|
||||
export function listNpmCertificates(): Promise<NpmCertificate[]> {
|
||||
return get<NpmCertificate[]>('/api/settings/npm-certificates');
|
||||
// ── NPM friendly-name cache ─────────────────────────────────────────
|
||||
// The settings/NPM page first renders "Certificate #<id>" / "Access List
|
||||
// #<id>" then swaps to the friendly name once the list resolves — a visible
|
||||
// flicker on every tab re-entry and after a reload. Back the two list calls
|
||||
// with a short-lived sessionStorage cache so names resolve instantly within
|
||||
// a session (survives reloads, scoped to the tab). Pass `force` to bypass it
|
||||
// — the picker's "browse" action wants fresh data.
|
||||
const NPM_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
interface NpmCacheEntry<T> {
|
||||
ts: number;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export function listNpmAccessLists(): Promise<NpmAccessList[]> {
|
||||
return get<NpmAccessList[]>('/api/settings/npm-access-lists');
|
||||
function readNpmCache<T>(key: string): T | null {
|
||||
if (typeof sessionStorage === 'undefined') return null;
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
const entry = JSON.parse(raw) as NpmCacheEntry<T>;
|
||||
if (!entry || typeof entry.ts !== 'number') return null;
|
||||
if (Date.now() - entry.ts > NPM_CACHE_TTL_MS) return null;
|
||||
return entry.data;
|
||||
} catch {
|
||||
// Corrupt/unparseable cache — treat as a miss.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeNpmCache<T>(key: string, data: T): void {
|
||||
if (typeof sessionStorage === 'undefined') return;
|
||||
try {
|
||||
const entry: NpmCacheEntry<T> = { ts: Date.now(), data };
|
||||
sessionStorage.setItem(key, JSON.stringify(entry));
|
||||
} catch {
|
||||
// Quota/serialization failure is non-fatal — the call still returned
|
||||
// fresh data; we just don't get the cache speedup next time.
|
||||
}
|
||||
}
|
||||
|
||||
const NPM_CERTS_CACHE_KEY = 'dw_npm_certs';
|
||||
const NPM_ACCESS_LISTS_CACHE_KEY = 'dw_npm_access_lists';
|
||||
|
||||
/**
|
||||
* List NPM SSL certificates. Cached in sessionStorage for ~5 minutes so the
|
||||
* settings page resolves friendly names without a flicker on re-entry/reload.
|
||||
* Pass `force` to skip the cache and refresh (e.g. opening the picker).
|
||||
*/
|
||||
export async function listNpmCertificates(force = false): Promise<NpmCertificate[]> {
|
||||
if (!force) {
|
||||
const cached = readNpmCache<NpmCertificate[]>(NPM_CERTS_CACHE_KEY);
|
||||
if (cached) return cached;
|
||||
}
|
||||
const data = await get<NpmCertificate[]>('/api/settings/npm-certificates');
|
||||
writeNpmCache(NPM_CERTS_CACHE_KEY, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* List NPM access lists. Cached in sessionStorage for ~5 minutes (see
|
||||
* {@link listNpmCertificates}). Pass `force` to skip the cache and refresh.
|
||||
*/
|
||||
export async function listNpmAccessLists(force = false): Promise<NpmAccessList[]> {
|
||||
if (!force) {
|
||||
const cached = readNpmCache<NpmAccessList[]>(NPM_ACCESS_LISTS_CACHE_KEY);
|
||||
if (cached) return cached;
|
||||
}
|
||||
const data = await get<NpmAccessList[]>('/api/settings/npm-access-lists');
|
||||
writeNpmCache(NPM_ACCESS_LISTS_CACHE_KEY, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── Volume scopes (metadata only) ───────────────────────────────────
|
||||
@@ -495,7 +560,14 @@ export function deleteBackup(id: string): Promise<void> {
|
||||
}
|
||||
|
||||
export function restoreBackup(id: string): Promise<{ status: string; message: string }> {
|
||||
return post<{ status: string; message: string }>(`/api/backups/${id}/restore`);
|
||||
// X-Confirm-Restore echoes the backup id. The backend rejects any
|
||||
// POST whose header doesn't match the path param — this defeats
|
||||
// blind CSRF (a cross-origin form/image-tag POST can't set custom
|
||||
// headers without a preflight). Sent alongside the bearer JWT.
|
||||
return request<{ status: string; message: string }>(`/api/backups/${id}/restore`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Confirm-Restore': id }
|
||||
});
|
||||
}
|
||||
|
||||
export function backupDownloadUrl(id: string): string {
|
||||
@@ -518,49 +590,81 @@ export function getCurrentUser(): Promise<{ id: string; username: string; email:
|
||||
return get<{ id: string; username: string; email: string; role: string }>('/api/auth/me');
|
||||
}
|
||||
|
||||
// Auth settings
|
||||
export async function getAuthSettings(): Promise<any> {
|
||||
return request<any>('/api/auth/settings');
|
||||
// ── Auth settings & user management ─────────────────────────────────
|
||||
// Previously typed as `any`, which silently disabled type checking on
|
||||
// the entire user/auth surface — including the password-change call.
|
||||
// All routes are admin-gated server-side.
|
||||
|
||||
export interface AuthSettings {
|
||||
auth_mode: 'local' | 'oidc';
|
||||
oidc_client_id: string;
|
||||
oidc_client_secret: string;
|
||||
oidc_issuer_url: string;
|
||||
oidc_redirect_url: string;
|
||||
}
|
||||
|
||||
export async function updateAuthSettings(settings: any): Promise<any> {
|
||||
return request<any>('/api/auth/settings', {
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: 'admin' | 'viewer' | string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateUserInput {
|
||||
username: string;
|
||||
password: string;
|
||||
email?: string;
|
||||
role?: 'admin' | 'viewer' | string;
|
||||
}
|
||||
|
||||
export interface UpdateUserInput {
|
||||
email?: string;
|
||||
role?: 'admin' | 'viewer' | string;
|
||||
}
|
||||
|
||||
export async function getAuthSettings(): Promise<AuthSettings> {
|
||||
return request<AuthSettings>('/api/auth/settings');
|
||||
}
|
||||
|
||||
export async function updateAuthSettings(settings: AuthSettings): Promise<AuthSettings> {
|
||||
return request<AuthSettings>('/api/auth/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
}
|
||||
|
||||
export async function listUsers(): Promise<any[]> {
|
||||
return request<any[]>('/api/auth/users');
|
||||
export async function listUsers(): Promise<AuthUser[]> {
|
||||
return request<AuthUser[]>('/api/auth/users');
|
||||
}
|
||||
|
||||
export async function createUser(data: { username: string; password: string; email?: string; role?: string }): Promise<any> {
|
||||
return request<any>('/api/auth/users', {
|
||||
export async function createUser(data: CreateUserInput): Promise<AuthUser> {
|
||||
return request<AuthUser>('/api/auth/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateUser(uid: string, data: { email?: string; role?: string }): Promise<any> {
|
||||
return request<any>(`/api/auth/users/${uid}`, {
|
||||
export async function updateUser(uid: string, data: UpdateUserInput): Promise<AuthUser> {
|
||||
return request<AuthUser>(`/api/auth/users/${uid}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function changeUserPassword(uid: string, password: string): Promise<any> {
|
||||
return request<any>(`/api/auth/users/${uid}/password`, {
|
||||
export async function changeUserPassword(uid: string, password: string): Promise<void> {
|
||||
await request<void>(`/api/auth/users/${uid}/password`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteUser(uid: string): Promise<any> {
|
||||
return request<any>(`/api/auth/users/${uid}`, { method: 'DELETE' });
|
||||
export async function deleteUser(uid: string): Promise<void> {
|
||||
await request<void>(`/api/auth/users/${uid}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await request<any>('/api/auth/logout', { method: 'POST' });
|
||||
await request<void>('/api/auth/logout', { method: 'POST' });
|
||||
}
|
||||
|
||||
// ── Config Export ────────────────────────────────────────────────────
|
||||
@@ -804,6 +908,62 @@ export function deleteWorkloadEnv(id: string, envID: string): Promise<{ deleted:
|
||||
// workload to inbound deploys, create or bind a Trigger via the
|
||||
// /triggers UI (which mints a /api/webhook/triggers/{secret} URL).
|
||||
|
||||
// Per-workload outbound notification routes (Slack/Discord/etc).
|
||||
// Multi-destination fan-out — the dispatcher sends to every enabled
|
||||
// row whose event_types allow-list matches the event. An empty
|
||||
// event_types means "match every event". Secret round-trips as
|
||||
// write-only: the API returns secret_set, never the ciphertext.
|
||||
export interface WorkloadNotification {
|
||||
id: string;
|
||||
workload_id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
secret_set: boolean;
|
||||
event_types: string;
|
||||
enabled: boolean;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface WorkloadNotificationInput {
|
||||
name: string;
|
||||
url: string;
|
||||
secret?: string;
|
||||
event_types?: string;
|
||||
enabled?: boolean;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export function listWorkloadNotifications(
|
||||
id: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkloadNotification[]> {
|
||||
return get<WorkloadNotification[]>(`/api/workloads/${id}/notifications`, signal);
|
||||
}
|
||||
|
||||
export function createWorkloadNotification(
|
||||
id: string,
|
||||
body: WorkloadNotificationInput
|
||||
): Promise<WorkloadNotification> {
|
||||
return post<WorkloadNotification>(`/api/workloads/${id}/notifications`, body);
|
||||
}
|
||||
|
||||
export function updateWorkloadNotification(
|
||||
id: string,
|
||||
nid: string,
|
||||
body: WorkloadNotificationInput
|
||||
): Promise<WorkloadNotification> {
|
||||
return put<WorkloadNotification>(`/api/workloads/${id}/notifications/${nid}`, body);
|
||||
}
|
||||
|
||||
export function deleteWorkloadNotification(
|
||||
id: string,
|
||||
nid: string
|
||||
): Promise<{ success: boolean }> {
|
||||
return del<{ success: boolean }>(`/api/workloads/${id}/notifications/${nid}`);
|
||||
}
|
||||
|
||||
export function fetchWorkloadContainerLogs(
|
||||
workloadId: string,
|
||||
containerRowId: string,
|
||||
@@ -993,6 +1153,13 @@ export interface WorkloadChainNode {
|
||||
name: string;
|
||||
source_kind: string;
|
||||
trigger_kind: string;
|
||||
/** True when this child was materialized as a branch preview of the chain's
|
||||
* `self` workload (vs. an operator-created stage child). Always false for
|
||||
* the parent and self nodes. */
|
||||
is_preview?: boolean;
|
||||
/** The git branch a preview child was deployed for. Empty for non-preview
|
||||
* nodes (omitted by the server). */
|
||||
preview_branch?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<!--
|
||||
Confirm dialog with fade/scale-in animation.
|
||||
Confirm dialog with fade/scale-in animation. Adds Escape-to-cancel,
|
||||
autofocus on the confirm button, and aria-modal so assistive tech
|
||||
treats the rest of the document as inert while the dialog is open.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { IconAlert } from '$lib/components/icons';
|
||||
@@ -36,14 +38,41 @@
|
||||
? 'text-[var(--color-danger)]'
|
||||
: 'text-[var(--color-brand-600)]'
|
||||
);
|
||||
|
||||
let confirmButton: HTMLButtonElement | null = $state(null);
|
||||
|
||||
// Focus the confirm button when the dialog opens so keyboard
|
||||
// users can hit Enter immediately. Scoped to the open transition;
|
||||
// repeated opens re-focus.
|
||||
$effect(() => {
|
||||
if (open && confirmButton) {
|
||||
// queueMicrotask so the DOM is mounted before focus().
|
||||
queueMicrotask(() => confirmButton?.focus());
|
||||
}
|
||||
});
|
||||
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (!open) return;
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
oncancel();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKey} />
|
||||
|
||||
{#if open}
|
||||
<!-- Backdrop -->
|
||||
<div class="fixed inset-0 z-40 bg-[var(--surface-overlay)] animate-fade-in" role="presentation" onclick={oncancel}></div>
|
||||
|
||||
<!-- Dialog -->
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
>
|
||||
<div class="dlg w-full max-w-md animate-scale-in">
|
||||
<span class="dlg-reg dlg-reg-tl"></span>
|
||||
<span class="dlg-reg dlg-reg-tr"></span>
|
||||
@@ -55,7 +84,7 @@
|
||||
<IconAlert size={20} class={iconColorClass} />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="dlg-title">{title}</h3>
|
||||
<h3 id="confirm-dialog-title" class="dlg-title">{title}</h3>
|
||||
<p class="dlg-msg">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -68,6 +97,7 @@
|
||||
type="button"
|
||||
class="dlg-confirm {confirmVariant}"
|
||||
onclick={onconfirm}
|
||||
bind:this={confirmButton}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
actionLabel?: string;
|
||||
actionHref?: string;
|
||||
onaction?: () => void;
|
||||
/**
|
||||
* The icon prop was historically accepted but never rendered —
|
||||
* the markup uses the breathing-dot "empty-mark" element instead.
|
||||
* Kept for source compatibility (call sites still pass it) and
|
||||
* to avoid a noisy svelte-check sweep, but the value is ignored.
|
||||
*/
|
||||
icon?: 'projects' | 'instances' | 'deploys' | 'registries' | 'volumes' | 'users';
|
||||
}
|
||||
|
||||
|
||||
@@ -91,12 +91,16 @@
|
||||
switch (event.key) {
|
||||
case 'ArrowDown': {
|
||||
event.preventDefault();
|
||||
// Guard against an empty filtered list — `% 0` is NaN, which
|
||||
// would poison highlightIndex for subsequent keystrokes.
|
||||
if (flatFiltered.length === 0) return;
|
||||
highlightIndex = (highlightIndex + 1) % flatFiltered.length;
|
||||
scrollHighlightedIntoView();
|
||||
break;
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
event.preventDefault();
|
||||
if (flatFiltered.length === 0) return;
|
||||
highlightIndex = (highlightIndex - 1 + flatFiltered.length) % flatFiltered.length;
|
||||
scrollHighlightedIntoView();
|
||||
break;
|
||||
@@ -156,7 +160,7 @@
|
||||
type="button"
|
||||
class="entity-picker-close"
|
||||
onclick={onclose}
|
||||
aria-label="Close"
|
||||
aria-label={$t('common.close')}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</button>
|
||||
@@ -180,7 +184,7 @@
|
||||
type="button"
|
||||
class="entity-picker-close-inline"
|
||||
onclick={onclose}
|
||||
aria-label="Close"
|
||||
aria-label={$t('common.close')}
|
||||
>
|
||||
<IconX size={16} />
|
||||
</button>
|
||||
@@ -213,8 +217,22 @@
|
||||
onmouseenter={() => { highlightIndex = flatIdx; }}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{#if item.icon}
|
||||
<span class="entity-picker-item-icon">{@html item.icon}</span>
|
||||
{#if item.icon === 'lock'}
|
||||
<span class="entity-picker-item-icon" aria-hidden="true">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
</span>
|
||||
{:else if item.icon === 'box'}
|
||||
<span class="entity-picker-item-icon" aria-hidden="true">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>
|
||||
</span>
|
||||
{:else if item.icon === 'folder'}
|
||||
<span class="entity-picker-item-icon" aria-hidden="true">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>
|
||||
</span>
|
||||
{:else if item.icon === 'branch'}
|
||||
<span class="entity-picker-item-icon" aria-hidden="true">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>
|
||||
</span>
|
||||
{/if}
|
||||
<span class="entity-picker-item-content">
|
||||
<span class="entity-picker-item-label">{item.label}</span>
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<!--
|
||||
RegistryImagePicker — optional "browse images" affordance for the
|
||||
registry trigger's image field.
|
||||
|
||||
The registry trigger config carries only a fully-qualified image `ref`
|
||||
(no registry id). On open, this loads every configured registry's
|
||||
images and lists them in a single command palette, GROUPED by registry,
|
||||
so the operator picks a fully-qualified ref without typing it. Selecting
|
||||
a row calls `onpick(full_ref, registry_name)`; the parent writes the ref
|
||||
into its config and may use the registry name to auto-select the source
|
||||
registry. Manual text entry in the parent input is always preserved — the
|
||||
picker is purely additive convenience.
|
||||
|
||||
Lives in its own component (rather than inline in TriggerKindForm)
|
||||
because that form binds a prop named `state`, which shadows Svelte 5's
|
||||
`$state` rune. Isolating the reactive picker state here sidesteps the
|
||||
collision, and keeps the only in-flow element a single button so it
|
||||
drops cleanly into the field's input row.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
||||
import { IconBox } from '$lib/components/icons';
|
||||
import * as api from '$lib/api';
|
||||
import type { EntityPickerItem } from '$lib/types';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
interface Props {
|
||||
/** Current image ref — highlights the matching row in the picker. */
|
||||
current?: string;
|
||||
/**
|
||||
* Called with the chosen image's full_ref and the name of the registry
|
||||
* it came from. Callers that only care about the ref (e.g. the registry
|
||||
* trigger form) can ignore the second argument.
|
||||
*/
|
||||
onpick: (fullRef: string, registryName: string) => void;
|
||||
}
|
||||
|
||||
let { current = '', onpick }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let loading = $state(false);
|
||||
let loaded = $state(false);
|
||||
let items = $state<EntityPickerItem[]>([]);
|
||||
|
||||
// Lazily load every registry's images the first time the operator
|
||||
// opens the picker, flattened into one grouped list. Per-registry
|
||||
// failures are tolerated (other registries still populate). A total
|
||||
// failure leaves the list empty — the picker then shows its built-in
|
||||
// "no results" state — and never blocks manual entry in the field.
|
||||
async function ensureLoaded(): Promise<void> {
|
||||
if (loaded || loading) return;
|
||||
loading = true;
|
||||
try {
|
||||
const registries = await api.listRegistries();
|
||||
const collected: EntityPickerItem[] = [];
|
||||
await Promise.all(
|
||||
registries.map(async (reg) => {
|
||||
try {
|
||||
const images = await api.listRegistryImages(reg.id);
|
||||
for (const img of images) {
|
||||
collected.push({
|
||||
value: img.full_ref,
|
||||
label: img.full_ref,
|
||||
description: img.owner ? `${img.owner}/${img.name}` : img.name,
|
||||
group: reg.name,
|
||||
icon: 'box'
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Skip a registry we can't reach; others still load.
|
||||
}
|
||||
})
|
||||
);
|
||||
items = collected;
|
||||
loaded = true;
|
||||
} catch {
|
||||
// Total failure (e.g. /api/registries unreachable): leave items
|
||||
// empty so the picker shows its empty state. Do NOT mark loaded
|
||||
// so a later open retries.
|
||||
items = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openPicker(): Promise<void> {
|
||||
await ensureLoaded();
|
||||
open = true;
|
||||
}
|
||||
|
||||
function handleSelect(value: string): void {
|
||||
// Each picker row carries its source registry name in `group` (set when
|
||||
// the list was built). Surface it so callers can auto-select the
|
||||
// registry the image came from. Falls back to '' (public) if not found.
|
||||
const picked = items.find((i) => i.value === value);
|
||||
onpick(value, picked?.group ?? '');
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="browse-btn"
|
||||
onclick={openPicker}
|
||||
disabled={loading}
|
||||
title={$t('redeployTriggers.form.browseImagesHint')}
|
||||
>
|
||||
<IconBox size={13} />
|
||||
<span>{$t('redeployTriggers.form.browseImages')}</span>
|
||||
</button>
|
||||
|
||||
<EntityPicker
|
||||
bind:open
|
||||
{items}
|
||||
{current}
|
||||
title={$t('redeployTriggers.form.browseImagesTitle')}
|
||||
placeholder={$t('redeployTriggers.form.browseImagesSearch')}
|
||||
onselect={handleSelect}
|
||||
onclose={() => (open = false)}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.browse-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0 0.7rem;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.58rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.browse-btn:hover:not(:disabled) {
|
||||
border-color: var(--forge-accent);
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-card-hover);
|
||||
}
|
||||
.browse-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.browse-btn:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -210,7 +210,7 @@
|
||||
</div>
|
||||
{:else if !proxyConnected}
|
||||
<div class="panel-error">
|
||||
<code>{proxy.error ?? `${proxyProvider.toUpperCase()} is not reachable.`}</code>
|
||||
<code>{proxy.error ?? $t('daemons.notReachable', { provider: proxyProvider.toUpperCase() })}</code>
|
||||
<p>{$t('daemons.proxyHint')}</p>
|
||||
{#if proxy.url}
|
||||
<p class="url"><span class="kdim">URL</span> <code>{proxy.url}</code></p>
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
|
||||
{#if !loading}
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)]">
|
||||
<h3 class="mb-4 text-sm font-semibold text-[var(--text-primary)]">{$t('systemHealth.title')}</h3>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<!-- Containers -->
|
||||
<a href="/containers" class="flex items-center gap-3 rounded-lg p-3 transition-colors hover:bg-[var(--surface-card-hover)]">
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<script lang="ts">
|
||||
import { toasts, type ToastType } from '$lib/stores/toast';
|
||||
import { IconCheck, IconX, IconAlert, IconInfo } from '$lib/components/icons';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
const bgMap: Record<ToastType, string> = {
|
||||
success: 'bg-[var(--color-success)]',
|
||||
@@ -34,7 +35,7 @@
|
||||
<button
|
||||
class="ml-2 rounded-md p-0.5 text-white/70 hover:text-white transition-colors"
|
||||
onclick={() => toasts.remove(toast.id)}
|
||||
aria-label="Dismiss notification"
|
||||
aria-label={$t('common.dismissNotification')}
|
||||
>
|
||||
<IconX size={16} />
|
||||
</button>
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
<!--
|
||||
Task 6: Toggle switch to replace checkboxes.
|
||||
Toggle switch replacing raw checkboxes. Accepts an `ariaLabel` /
|
||||
`ariaLabelledby` so the switch announces its purpose to assistive
|
||||
tech regardless of surrounding markup. `label` remains a thin alias
|
||||
that defaults aria-label without forcing a visible <label>.
|
||||
-->
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
checked?: boolean;
|
||||
/** Accessible name. Falls back to ariaLabel for clarity. */
|
||||
label?: string;
|
||||
/** Explicit aria-label override. Takes precedence over label. */
|
||||
ariaLabel?: string;
|
||||
/** id of a visible label element. Takes precedence over ariaLabel. */
|
||||
ariaLabelledby?: string;
|
||||
disabled?: boolean;
|
||||
onchange?: (checked: boolean) => void;
|
||||
}
|
||||
@@ -12,6 +20,8 @@
|
||||
let {
|
||||
checked = $bindable(false),
|
||||
label = '',
|
||||
ariaLabel,
|
||||
ariaLabelledby,
|
||||
disabled = false,
|
||||
onchange
|
||||
}: Props = $props();
|
||||
@@ -21,15 +31,34 @@
|
||||
checked = !checked;
|
||||
onchange?.(checked);
|
||||
}
|
||||
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (disabled) return;
|
||||
// Native <button> already handles Space/Enter, but role="switch"
|
||||
// best-practice docs (WAI-ARIA APG) call out Space explicitly —
|
||||
// pin the behaviour so we don't depend on browser defaults.
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
}
|
||||
|
||||
const computedAriaLabel = $derived(ariaLabel ?? (label || undefined));
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={computedAriaLabel}
|
||||
aria-labelledby={ariaLabelledby}
|
||||
aria-disabled={disabled ? 'true' : undefined}
|
||||
class="toggle-switch {disabled ? 'opacity-50 cursor-not-allowed' : ''}"
|
||||
onclick={toggle}
|
||||
onkeydown={handleKey}
|
||||
{disabled}
|
||||
>
|
||||
<span class="sr-only">{label}</span>
|
||||
{#if label}
|
||||
<span class="sr-only">{label}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
gitRepo: string;
|
||||
gitMode: 'push' | 'tag';
|
||||
gitBranch: string;
|
||||
gitBranchPattern: string;
|
||||
gitTagPattern: string;
|
||||
// schedule
|
||||
schInterval: string;
|
||||
@@ -70,6 +71,7 @@
|
||||
gitRepo: init.gitRepo ?? '',
|
||||
gitMode: init.gitMode ?? 'push',
|
||||
gitBranch: init.gitBranch ?? 'main',
|
||||
gitBranchPattern: init.gitBranchPattern ?? '',
|
||||
gitTagPattern: init.gitTagPattern ?? 'v*',
|
||||
schInterval: init.schInterval ?? '24h',
|
||||
schReference: init.schReference ?? '',
|
||||
@@ -191,6 +193,7 @@
|
||||
// understands the silent rewrite.
|
||||
s.gitMode = cfg.mode === 'tag' ? 'tag' : 'push';
|
||||
s.gitBranch = typeof cfg.branch === 'string' ? cfg.branch : 'main';
|
||||
s.gitBranchPattern = typeof cfg.branch_pattern === 'string' ? cfg.branch_pattern : '';
|
||||
s.gitTagPattern = typeof cfg.tag_pattern === 'string' ? cfg.tag_pattern : 'v*';
|
||||
break;
|
||||
case 'manual':
|
||||
@@ -213,14 +216,22 @@
|
||||
tag_pattern: s.regTagPattern.trim() || '*'
|
||||
};
|
||||
} else if (s.kind === 'git') {
|
||||
config =
|
||||
s.gitMode === 'push'
|
||||
? { repo: s.gitRepo.trim(), mode: 'push', branch: s.gitBranch.trim() || 'main' }
|
||||
: {
|
||||
repo: s.gitRepo.trim(),
|
||||
mode: 'tag',
|
||||
tag_pattern: s.gitTagPattern.trim() || '*'
|
||||
};
|
||||
if (s.gitMode === 'push') {
|
||||
const branchPattern = s.gitBranchPattern.trim();
|
||||
const pushCfg: Record<string, unknown> = {
|
||||
repo: s.gitRepo.trim(),
|
||||
mode: 'push',
|
||||
branch: s.gitBranch.trim() || 'main'
|
||||
};
|
||||
if (branchPattern) pushCfg.branch_pattern = branchPattern;
|
||||
config = pushCfg;
|
||||
} else {
|
||||
config = {
|
||||
repo: s.gitRepo.trim(),
|
||||
mode: 'tag',
|
||||
tag_pattern: s.gitTagPattern.trim() || '*'
|
||||
};
|
||||
}
|
||||
} else if (s.kind === 'manual') {
|
||||
config = {};
|
||||
} else if (s.kind === 'schedule') {
|
||||
@@ -243,6 +254,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||
import RegistryImagePicker from '$lib/components/RegistryImagePicker.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
interface Props {
|
||||
@@ -296,6 +308,14 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Registry-image picker writes the chosen full_ref back into the
|
||||
// registry trigger's image field. The picker's own reactive state
|
||||
// lives in RegistryImagePicker (a prop named `state` here shadows the
|
||||
// $state rune, so the picker is isolated in its own component).
|
||||
function onRegistryImagePicked(fullRef: string): void {
|
||||
state.regImage = fullRef;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="tk-form">
|
||||
@@ -392,16 +412,19 @@
|
||||
{:else if state.kind === 'registry'}
|
||||
<label class="sub" for="{idPrefix}-image">
|
||||
<span class="sub-label">{$t('redeployTriggers.form.image')}</span>
|
||||
<input
|
||||
id="{idPrefix}-image"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={state.regImage}
|
||||
placeholder={$t('redeployTriggers.form.imagePlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
<div class="input-with-button">
|
||||
<input
|
||||
id="{idPrefix}-image"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={state.regImage}
|
||||
placeholder={$t('redeployTriggers.form.imagePlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
<RegistryImagePicker current={state.regImage} onpick={onRegistryImagePicked} />
|
||||
</div>
|
||||
<span class="hint">{$t('redeployTriggers.form.imageHint')}</span>
|
||||
</label>
|
||||
<label class="sub" for="{idPrefix}-tag">
|
||||
@@ -475,6 +498,19 @@
|
||||
/>
|
||||
<span class="hint">{$t('redeployTriggers.form.branchHint')}</span>
|
||||
</label>
|
||||
<label class="sub" for="{idPrefix}-branch-pattern">
|
||||
<span class="sub-label">{$t('redeployTriggers.form.branchPattern')}</span>
|
||||
<input
|
||||
id="{idPrefix}-branch-pattern"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={state.gitBranchPattern}
|
||||
placeholder={$t('redeployTriggers.form.branchPatternPlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<span class="hint">{$t('redeployTriggers.form.branchPatternHint')}</span>
|
||||
</label>
|
||||
{:else}
|
||||
<label class="sub" for="{idPrefix}-gtag">
|
||||
<span class="sub-label">{$t('redeployTriggers.form.tagPattern')}</span>
|
||||
@@ -711,6 +747,20 @@
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* ── Registry image picker affordance ─────────────
|
||||
The image field becomes an input + "browse" button row (the button
|
||||
is rendered by RegistryImagePicker). Manual text entry stays fully
|
||||
functional — the picker is purely additive. */
|
||||
.input-with-button {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.input-with-button > .input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kind-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* WorkloadNotificationsPanel
|
||||
*
|
||||
* Per-workload outbound notification routes. Multi-destination
|
||||
* fan-out: each row is one Slack channel / Discord webhook /
|
||||
* generic receiver, optionally filtered to a comma-separated
|
||||
* allow-list of event types. When zero rows exist the dispatcher
|
||||
* falls back to the legacy single notification URL on the workload.
|
||||
*
|
||||
* Secret is write-only: the API returns secret_set so the UI shows
|
||||
* "secret configured" / "no secret" without ever round-tripping the
|
||||
* ciphertext. To rotate, submit a new plaintext value.
|
||||
*/
|
||||
import * as api from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import ToggleSwitch from './ToggleSwitch.svelte';
|
||||
import ConfirmDialog from './ConfirmDialog.svelte';
|
||||
import { IconPlus, IconTrash, IconEdit } from './icons';
|
||||
|
||||
interface Props {
|
||||
workloadId: string;
|
||||
}
|
||||
let { workloadId }: Props = $props();
|
||||
|
||||
let rows = $state<api.WorkloadNotification[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let saving = $state(false);
|
||||
let confirmDeleteId = $state<string | null>(null);
|
||||
|
||||
// Edit form. editingId === '' means we're creating, otherwise it's
|
||||
// the row being edited. Form fields are kept flat (string) so the
|
||||
// API payload assembly stays trivial.
|
||||
let editingId = $state<string | null>(null);
|
||||
let formName = $state('');
|
||||
let formURL = $state('');
|
||||
let formSecret = $state('');
|
||||
let formEventTypes = $state('');
|
||||
let formEnabled = $state(true);
|
||||
|
||||
const formValid = $derived(formURL.trim().length > 0);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
rows = await api.listWorkloadNotifications(workloadId);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Reload whenever the workloadId prop changes — the parent (/apps/[id])
|
||||
// reuses this component instance across /apps/A → /apps/B navigation, so
|
||||
// onMount(load) alone would keep showing the previous workload's rows.
|
||||
$effect(() => {
|
||||
const _ = workloadId; // explicit dependency
|
||||
load();
|
||||
});
|
||||
|
||||
function startAdd(): void {
|
||||
editingId = '';
|
||||
formName = '';
|
||||
formURL = '';
|
||||
formSecret = '';
|
||||
formEventTypes = '';
|
||||
formEnabled = true;
|
||||
}
|
||||
|
||||
function startEdit(row: api.WorkloadNotification): void {
|
||||
editingId = row.id;
|
||||
formName = row.name;
|
||||
formURL = row.url;
|
||||
formSecret = ''; // write-only; empty means "leave unchanged"
|
||||
formEventTypes = row.event_types;
|
||||
formEnabled = row.enabled;
|
||||
}
|
||||
|
||||
function cancelEdit(): void {
|
||||
editingId = null;
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (!formValid || saving) return;
|
||||
saving = true;
|
||||
try {
|
||||
const body: api.WorkloadNotificationInput = {
|
||||
name: formName.trim(),
|
||||
url: formURL.trim(),
|
||||
event_types: formEventTypes.trim(),
|
||||
enabled: formEnabled
|
||||
};
|
||||
if (formSecret.trim()) body.secret = formSecret.trim();
|
||||
if (editingId === '') {
|
||||
await api.createWorkloadNotification(workloadId, body);
|
||||
} else if (editingId) {
|
||||
await api.updateWorkloadNotification(workloadId, editingId, body);
|
||||
}
|
||||
editingId = null;
|
||||
await load();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function doDelete(id: string): Promise<void> {
|
||||
saving = true;
|
||||
try {
|
||||
await api.deleteWorkloadNotification(workloadId, id);
|
||||
confirmDeleteId = null;
|
||||
await load();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel notif-panel" aria-labelledby="notif-heading">
|
||||
<span class="reg reg-tl" aria-hidden="true"></span>
|
||||
<span class="reg reg-tr" aria-hidden="true"></span>
|
||||
<span class="reg reg-bl" aria-hidden="true"></span>
|
||||
<span class="reg reg-br" aria-hidden="true"></span>
|
||||
|
||||
<header class="panel-head">
|
||||
<h2 class="panel-title" id="notif-heading">
|
||||
{$t('apps.detail.notifications.title')}<span class="title-accent">.</span>
|
||||
</h2>
|
||||
<span class="panel-sub">{$t('apps.detail.notifications.sub')}</span>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<div class="alert inline-alert" role="alert">
|
||||
<span class="alert-tag">ERR</span><span>{error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<p class="hint">{$t('apps.detail.notifications.loading')}</p>
|
||||
{:else if rows.length === 0 && editingId === null}
|
||||
<p class="hint">{$t('apps.detail.notifications.empty')}</p>
|
||||
<button class="forge-btn" onclick={startAdd}>
|
||||
<IconPlus size={13} />
|
||||
<span>{$t('apps.detail.notifications.addFirst')}</span>
|
||||
</button>
|
||||
{:else}
|
||||
{#if rows.length > 0}
|
||||
<ul class="notif-list">
|
||||
{#each rows as row (row.id)}
|
||||
<li class="notif-row" class:disabled={!row.enabled}>
|
||||
<div class="notif-main">
|
||||
<span class="notif-name">{row.name || '(unnamed)'}</span>
|
||||
<span class="notif-url mono">{row.url}</span>
|
||||
<span class="notif-meta">
|
||||
{#if row.event_types}
|
||||
<span class="notif-tag">{row.event_types}</span>
|
||||
{:else}
|
||||
<span class="notif-tag muted">{$t('apps.detail.notifications.allEvents')}</span>
|
||||
{/if}
|
||||
{#if row.secret_set}
|
||||
<span class="notif-tag secret">{$t('apps.detail.notifications.signed')}</span>
|
||||
{/if}
|
||||
{#if !row.enabled}
|
||||
<span class="notif-tag muted">{$t('apps.detail.notifications.disabled')}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<div class="notif-actions">
|
||||
<button
|
||||
class="forge-btn-ghost"
|
||||
onclick={() => startEdit(row)}
|
||||
aria-label={$t('apps.detail.notifications.edit')}
|
||||
>
|
||||
<IconEdit size={13} />
|
||||
</button>
|
||||
<button
|
||||
class="forge-btn-ghost danger"
|
||||
onclick={() => (confirmDeleteId = row.id)}
|
||||
aria-label={$t('apps.detail.notifications.delete')}
|
||||
>
|
||||
<IconTrash size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if editingId === null}
|
||||
<button class="forge-btn-ghost notif-add" onclick={startAdd}>
|
||||
<IconPlus size={13} />
|
||||
<span>{$t('apps.detail.notifications.add')}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if editingId !== null}
|
||||
<div class="notif-form">
|
||||
<label class="sub">
|
||||
<span class="sub-label">{$t('apps.detail.notifications.name')}</span>
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
bind:value={formName}
|
||||
placeholder={$t('apps.detail.notifications.namePlaceholder')}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
<label class="sub">
|
||||
<span class="sub-label">{$t('apps.detail.notifications.url')}</span>
|
||||
<input
|
||||
type="url"
|
||||
class="input mono"
|
||||
bind:value={formURL}
|
||||
placeholder="https://hooks.slack.com/services/..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
<label class="sub">
|
||||
<span class="sub-label">{$t('apps.detail.notifications.secret')}</span>
|
||||
<input
|
||||
type="password"
|
||||
class="input mono"
|
||||
bind:value={formSecret}
|
||||
placeholder={editingId
|
||||
? $t('apps.detail.notifications.secretEditPlaceholder')
|
||||
: $t('apps.detail.notifications.secretPlaceholder')}
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<span class="hint">{$t('apps.detail.notifications.secretHint')}</span>
|
||||
</label>
|
||||
<label class="sub">
|
||||
<span class="sub-label">{$t('apps.detail.notifications.eventTypes')}</span>
|
||||
<input
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={formEventTypes}
|
||||
placeholder={$t('apps.detail.notifications.eventTypesPlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<span class="hint">{$t('apps.detail.notifications.eventTypesHint')}</span>
|
||||
</label>
|
||||
<div class="sub toggle-row">
|
||||
<span class="sub-label">{$t('apps.detail.notifications.enabled')}</span>
|
||||
<ToggleSwitch bind:checked={formEnabled} ariaLabel={$t('apps.detail.notifications.enabled')} />
|
||||
</div>
|
||||
<div class="notif-form-actions">
|
||||
<button class="forge-btn-ghost" onclick={cancelEdit} disabled={saving}>
|
||||
{$t('apps.detail.notifications.cancel')}
|
||||
</button>
|
||||
<button class="forge-btn" onclick={save} disabled={!formValid || saving}>
|
||||
{saving ? $t('apps.detail.notifications.saving') : $t('apps.detail.notifications.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if confirmDeleteId}
|
||||
<ConfirmDialog
|
||||
open={true}
|
||||
title={$t('apps.detail.notifications.confirmDeleteTitle')}
|
||||
message={$t('apps.detail.notifications.confirmDeleteMessage')}
|
||||
confirmLabel={$t('apps.detail.notifications.delete')}
|
||||
confirmVariant="danger"
|
||||
onconfirm={() => confirmDeleteId && doDelete(confirmDeleteId)}
|
||||
oncancel={() => (confirmDeleteId = null)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.notif-panel {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.notif-list {
|
||||
list-style: none;
|
||||
margin: 0.7rem 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.notif-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.85rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
background: var(--surface-card-hover);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.notif-row.disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.notif-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.notif-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.notif-url {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.notif-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
.notif-tag {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.68rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 3px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.notif-tag.secret {
|
||||
border-color: var(--accent-warm, #c08458);
|
||||
}
|
||||
.notif-tag.muted {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.notif-actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.notif-add {
|
||||
margin-top: 0.85rem;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.notif-form {
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.95rem;
|
||||
border-top: 1px dashed var(--border-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
.notif-form .sub {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.notif-form .sub-label {
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.notif-form .toggle-row {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.notif-form-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,210 @@
|
||||
<script lang="ts">
|
||||
// Vertical step rail for multi-step wizards. Renders numbered steps with
|
||||
// a "molten" ember fill connecting completed steps — the forge control-
|
||||
// panel motif. Steps up to `maxReached` are clickable to jump back;
|
||||
// upcoming steps are inert. Collapses to a horizontal bar on narrow
|
||||
// viewports via CSS.
|
||||
interface WizardStep {
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
steps: WizardStep[];
|
||||
/** 1-based index of the active step. */
|
||||
current: number;
|
||||
/** 1-based index of the furthest step the user may navigate to. */
|
||||
maxReached: number;
|
||||
onselect: (step: number) => void;
|
||||
}
|
||||
|
||||
let { steps, current, maxReached, onselect }: Props = $props();
|
||||
|
||||
function stepState(index1: number): 'done' | 'active' | 'upcoming' {
|
||||
if (index1 < current) return 'done';
|
||||
if (index1 === current) return 'active';
|
||||
return 'upcoming';
|
||||
}
|
||||
|
||||
function pad(n: number): string {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
// Up/Down arrows move focus among the reachable (enabled) step buttons
|
||||
// so the rail reads as one grouped control to keyboard users.
|
||||
function handleKeydown(e: KeyboardEvent): void {
|
||||
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
||||
const list = (e.currentTarget as HTMLElement).closest('.rail-list');
|
||||
if (!list) return;
|
||||
const btns = Array.from(list.querySelectorAll<HTMLButtonElement>('button:not([disabled])'));
|
||||
const idx = btns.indexOf(e.currentTarget as HTMLButtonElement);
|
||||
if (idx === -1) return;
|
||||
e.preventDefault();
|
||||
const target =
|
||||
e.key === 'ArrowDown' ? Math.min(idx + 1, btns.length - 1) : Math.max(idx - 1, 0);
|
||||
btns[target]?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav class="rail" aria-label="Progress">
|
||||
<ol class="rail-list">
|
||||
{#each steps as step, i (i)}
|
||||
{@const num = i + 1}
|
||||
{@const st = stepState(num)}
|
||||
{@const reachable = num <= maxReached && num !== current}
|
||||
<li class="rail-item rail-{st}">
|
||||
<button
|
||||
type="button"
|
||||
class="rail-btn"
|
||||
disabled={!reachable}
|
||||
aria-current={st === 'active' ? 'step' : undefined}
|
||||
onclick={() => reachable && onselect(num)}
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
<span class="rail-marker" aria-hidden="true">
|
||||
{#if st === 'done'}
|
||||
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8.5l3.5 3.5L13 4" /></svg>
|
||||
{:else}
|
||||
{pad(num)}
|
||||
{/if}
|
||||
</span>
|
||||
<span class="rail-label">{step.label}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.rail {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rail-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.rail-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Connector line between markers. Drawn from this marker's centre
|
||||
upward to the previous one; ember-filled once the step is reached. */
|
||||
.rail-item:not(:first-child)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: calc(0.875rem - 1px);
|
||||
top: calc(-0.5rem);
|
||||
height: 0.5rem;
|
||||
width: 2px;
|
||||
background: var(--border-primary);
|
||||
transition: background var(--transition-slow);
|
||||
}
|
||||
|
||||
.rail-done:not(:first-child)::before,
|
||||
.rail-active:not(:first-child)::before {
|
||||
background: var(--forge-ember);
|
||||
}
|
||||
|
||||
.rail-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-2);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--text-tertiary);
|
||||
transition: color var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
|
||||
.rail-btn:not(:disabled):hover {
|
||||
background: var(--surface-card-hover);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.rail-btn:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.rail-marker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: var(--radius-full);
|
||||
border: 2px solid var(--border-primary);
|
||||
background: var(--surface-card);
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-tertiary);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.rail-done .rail-marker {
|
||||
border-color: var(--forge-ember);
|
||||
background: var(--forge-ember);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.rail-active .rail-marker {
|
||||
border-color: var(--forge-ember);
|
||||
color: var(--forge-ember);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--forge-ember) 18%, transparent);
|
||||
}
|
||||
|
||||
.rail-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
|
||||
.rail-active .rail-label {
|
||||
color: var(--text-primary);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
|
||||
.rail-done .rail-label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Horizontal rail on narrow screens: markers in a row, labels hidden. */
|
||||
@media (max-width: 820px) {
|
||||
.rail-list {
|
||||
flex-direction: row;
|
||||
gap: 0;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.rail-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.rail-item:not(:first-child)::before {
|
||||
left: auto;
|
||||
right: 50%;
|
||||
top: calc(0.875rem - 1px);
|
||||
height: 2px;
|
||||
width: 100%;
|
||||
}
|
||||
.rail-btn {
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
width: auto;
|
||||
z-index: 1;
|
||||
}
|
||||
.rail-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,181 @@
|
||||
<!--
|
||||
App manifest — a forged "spec-sheet" summarizing the whole workload on the
|
||||
wizard's Review step so the operator can confirm everything before Create.
|
||||
|
||||
Pure presentation: the page computes the row set + source-kind via `$derived`
|
||||
and passes them in. Values are rendered in a definition grid (mono uppercase
|
||||
labels in the left column, values in the right). Machine-readable values
|
||||
(image refs, repo paths, branches, ports, FQDNs) are set `mono` by the caller
|
||||
so they read as the literals they are. The source kind is shown as an ember
|
||||
badge in the manifest header.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
export interface ManifestRow {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Definition rows: Name / Source / Trigger / Public face. */
|
||||
rows: ManifestRow[];
|
||||
/** Source-kind string (image / compose / static / dockerfile) — badge. */
|
||||
sourceKind: string;
|
||||
}
|
||||
|
||||
let { rows, sourceKind }: Props = $props();
|
||||
</script>
|
||||
|
||||
<section class="manifest" aria-label={$t('apps.new.manifest.title')}>
|
||||
<span class="reg reg-tl" aria-hidden="true"></span>
|
||||
<span class="reg reg-tr" aria-hidden="true"></span>
|
||||
<span class="reg reg-bl" aria-hidden="true"></span>
|
||||
<span class="reg reg-br" aria-hidden="true"></span>
|
||||
|
||||
<header class="manifest-head">
|
||||
<span class="forge-eyebrow">
|
||||
<span class="forge-ember"></span>
|
||||
<span class="eb-word">{$t('apps.new.manifest.title')}</span>
|
||||
</span>
|
||||
{#if sourceKind}
|
||||
<span class="kind-badge mono">{sourceKind}</span>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<dl class="manifest-grid">
|
||||
{#each rows as row (row.label)}
|
||||
<div class="manifest-row">
|
||||
<dt class="manifest-label">{row.label}</dt>
|
||||
<dd class="manifest-value" class:mono={row.mono}>{row.value}</dd>
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* Forged spec-sheet: subtle bordered panel with registration corners,
|
||||
ember eyebrow header, and a label/value definition grid. Reuses the
|
||||
forge token system end-to-end — no ad-hoc colours. */
|
||||
.manifest {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-5) var(--space-5) var(--space-6);
|
||||
background: var(--surface-card-hover);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
.reg {
|
||||
position: absolute;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-color: var(--forge-accent);
|
||||
border-style: solid;
|
||||
border-width: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.reg-tl {
|
||||
top: -1px;
|
||||
left: -1px;
|
||||
border-top-width: 2px;
|
||||
border-left-width: 2px;
|
||||
border-top-left-radius: var(--radius-xl);
|
||||
}
|
||||
.reg-tr {
|
||||
top: -1px;
|
||||
right: -1px;
|
||||
border-top-width: 2px;
|
||||
border-right-width: 2px;
|
||||
border-top-right-radius: var(--radius-xl);
|
||||
}
|
||||
.reg-bl {
|
||||
bottom: -1px;
|
||||
left: -1px;
|
||||
border-bottom-width: 2px;
|
||||
border-left-width: 2px;
|
||||
border-bottom-left-radius: var(--radius-xl);
|
||||
}
|
||||
.reg-br {
|
||||
bottom: -1px;
|
||||
right: -1px;
|
||||
border-bottom-width: 2px;
|
||||
border-right-width: 2px;
|
||||
border-bottom-right-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
.manifest-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
}
|
||||
.eb-word {
|
||||
font-weight: 700;
|
||||
}
|
||||
.kind-badge {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
padding: 0.2rem 0.55rem;
|
||||
background: var(--forge-accent);
|
||||
color: #fff;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Definition grid — mono uppercase labels, values aligned in a second
|
||||
column. Collapses to stacked rows on narrow viewports. */
|
||||
.manifest-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
margin: 0;
|
||||
}
|
||||
.manifest-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(7rem, 0.32fr) 1fr;
|
||||
gap: var(--space-4);
|
||||
align-items: baseline;
|
||||
padding: var(--space-2) 0;
|
||||
border-bottom: 1px dashed var(--border-secondary);
|
||||
}
|
||||
.manifest-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.manifest-label {
|
||||
margin: 0;
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.manifest-value {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
.manifest-value.mono {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.manifest-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,222 @@
|
||||
<!--
|
||||
Compose source form. Surfaces the YAML stack + optional project name as
|
||||
proper controls instead of forcing the operator to hand-escape YAML inside
|
||||
a JSON string. The parent owns the `ComposeFormState` (from
|
||||
`$lib/workload/sourceForms`) and binds it here; serialization to the
|
||||
`source_config` object is done by the parent via `composeToConfig` so the
|
||||
shape stays byte-identical to the legacy inline path.
|
||||
|
||||
The "Advanced JSON" chip is rendered by the parent (it owns the raw-editor
|
||||
toggle); this component is purely the form-field body.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { ComposeFormState } from '$lib/workload/sourceForms';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
interface Props {
|
||||
form: ComposeFormState;
|
||||
/** Flip to the raw-JSON editor (owned by the parent). */
|
||||
onAdvanced: () => void;
|
||||
}
|
||||
|
||||
let { form = $bindable(), onAdvanced }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="editor">
|
||||
<div class="editor-head">
|
||||
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
||||
<span class="editor-title">{$t('apps.new.composeHeader')}</span>
|
||||
<span class="spacer"></span>
|
||||
<button type="button" class="json-escape" onclick={onAdvanced} title={$t('apps.new.switchToJsonTitle')}>
|
||||
{$t('apps.new.advancedJson')}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
id="app-compose-yaml"
|
||||
bind:value={form.yaml}
|
||||
rows="12"
|
||||
spellcheck="false"
|
||||
class="code-area"
|
||||
placeholder={$t('apps.new.composePlaceholder')}
|
||||
aria-label={$t('apps.new.composeAriaLabel')}
|
||||
></textarea>
|
||||
<div class="editor-foot">
|
||||
<span class="foot-status">
|
||||
<span class="foot-dot" aria-hidden="true"></span>
|
||||
{$t('apps.new.fieldConfigYaml')}
|
||||
</span>
|
||||
<span class="sep">·</span>
|
||||
<span>{form.yaml.split('\n').length} {$t('apps.new.linesUnit')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<label class="sub" for="app-compose-project">
|
||||
<span class="sub-label">{$t('apps.new.composeProjectLabel')}</span>
|
||||
<input
|
||||
id="app-compose-project"
|
||||
type="text"
|
||||
class="input"
|
||||
bind:value={form.projectName}
|
||||
placeholder={$t('apps.new.composeProjectPlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<style>
|
||||
.input {
|
||||
width: 100%;
|
||||
background: var(--surface-input);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 0.6rem 0.8rem;
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
.input:focus {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.input:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.sub {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.sub-label {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── Code editor frame ─────────────────────────── */
|
||||
.editor {
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
background: var(--surface-input);
|
||||
transition: border-color 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
.editor:focus-within {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.editor-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.8rem;
|
||||
background: var(--surface-card-hover);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.editor-head .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--border-input);
|
||||
}
|
||||
.editor-head .dot:nth-of-type(1) {
|
||||
background: #ef4444aa;
|
||||
}
|
||||
.editor-head .dot:nth-of-type(2) {
|
||||
background: #f59e0baa;
|
||||
}
|
||||
.editor-head .dot:nth-of-type(3) {
|
||||
background: #10b981aa;
|
||||
}
|
||||
.editor-title {
|
||||
margin-left: 0.4rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ── "Edit as JSON" escape hatch ─────────────────────────
|
||||
Quiet secondary text-link rather than a prominent chip, so it doesn't
|
||||
compete with the form title. */
|
||||
.json-escape {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
text-underline-offset: 3px;
|
||||
transition: color 120ms ease;
|
||||
}
|
||||
.json-escape:hover {
|
||||
color: var(--forge-accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.json-escape:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.code-area {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0.85rem 1rem;
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.55;
|
||||
color: var(--text-primary);
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
tab-size: 2;
|
||||
}
|
||||
.code-area::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.editor-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.45rem 0.8rem;
|
||||
border-top: 1px solid var(--border-primary);
|
||||
background: var(--surface-card-hover);
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.foot-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--color-success-dark);
|
||||
letter-spacing: 0.1em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.foot-status .foot-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-success);
|
||||
}
|
||||
:global([data-theme='dark']) .foot-status {
|
||||
color: #86efac;
|
||||
}
|
||||
.sep {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,286 @@
|
||||
<!--
|
||||
Dockerfile source form. Shares the provider + repo + branch + token
|
||||
git-discovery wiring with the static source (StaticDiscoveryWizard in its
|
||||
compact `dockerfile` variant — same handlers, no folder tree). The
|
||||
build-step controls (context path, dockerfile path, port) are the only
|
||||
dockerfile-specific UI.
|
||||
|
||||
The parent owns the `DockerfileFormState` (from `$lib/workload/sourceForms`)
|
||||
and binds it here; serialization to `source_config` is done by the parent
|
||||
via `dockerfileToConfig` so the shape (incl. preserved unknown keys + the
|
||||
scrubbed static-only keys) stays byte-identical. `DockerfileFormState
|
||||
extends GitSourceState`, so the same object is bound into the wizard.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { DockerfileFormState } from '$lib/workload/sourceForms';
|
||||
import StaticDiscoveryWizard from '$lib/components/workload/StaticDiscoveryWizard.svelte';
|
||||
import { IconX } from '$lib/components/icons';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
interface Props {
|
||||
form: DockerfileFormState;
|
||||
/** Flip to the raw-JSON editor (owned by the parent). */
|
||||
onAdvanced: () => void;
|
||||
/**
|
||||
* Transient discovery status — OPTIONAL pass-through to
|
||||
* StaticDiscoveryWizard (dockerfile variant: detect + test only, no
|
||||
* folder tree / mode). Each defaults to this component's own internal
|
||||
* `$state`, so a parent that doesn't bind them keeps the original
|
||||
* "resets on remount" behaviour (the detail/edit page `apps/[id]`
|
||||
* binds none of these). The create wizard `apps/new` binds them up to
|
||||
* the PAGE so the detect/test pills survive the form unmounting under
|
||||
* the Advanced-JSON / source-kind toggles.
|
||||
*/
|
||||
detectStatus?: 'idle' | 'pending' | 'ok' | 'error';
|
||||
detectError?: string;
|
||||
testStatus?: 'idle' | 'pending' | 'ok' | 'error';
|
||||
testError?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
form = $bindable(),
|
||||
onAdvanced,
|
||||
detectStatus = $bindable('idle'),
|
||||
detectError = $bindable(''),
|
||||
testStatus = $bindable('idle'),
|
||||
testError = $bindable('')
|
||||
}: Props = $props();
|
||||
|
||||
// `touched` flips true on first blur — used by the pill to avoid shouting
|
||||
// "required" the instant the user lands on the form.
|
||||
let portTouched = $state(false);
|
||||
|
||||
// A cleared <input type="number"> binds to null (not 0) in Svelte 5, and
|
||||
// `null <= 0` is false — so a bare `port <= 0` check would pass validation
|
||||
// on an empty field. Guard against null/NaN/non-positive here.
|
||||
const portValid = $derived(
|
||||
typeof form.port === 'number' && Number.isFinite(form.port) && form.port > 0
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="image-form">
|
||||
<div class="image-form-head">
|
||||
<span class="editor-title">{$t('apps.new.dockerfileHeader')}</span>
|
||||
<button type="button" class="json-escape" onclick={onAdvanced} title={$t('apps.new.switchToJsonTitle')}>
|
||||
{$t('apps.new.advancedJson')}
|
||||
</button>
|
||||
</div>
|
||||
<StaticDiscoveryWizard
|
||||
bind:git={form}
|
||||
variant="dockerfile"
|
||||
bind:detectStatus
|
||||
bind:detectError
|
||||
bind:testStatus
|
||||
bind:testError
|
||||
idPrefix="app-df"
|
||||
/>
|
||||
|
||||
<!-- Build-step controls — the only dockerfile-only UI. The form is a
|
||||
two-phase form (locate the code, describe how to build it). A
|
||||
forge-eyebrow divider phrases the conceptual break. -->
|
||||
<div class="df-section-break" aria-hidden="false">
|
||||
<span class="forge-eyebrow">
|
||||
<span class="forge-ember"></span>
|
||||
<span class="eb-word">{$t('apps.new.dockerfileBuildEyebrow')}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="sub" for="app-df-context">
|
||||
<span class="sub-label">{$t('apps.new.dockerfileContextPath')}</span>
|
||||
<input
|
||||
id="app-df-context"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={form.contextPath}
|
||||
placeholder={$t('apps.new.dockerfileContextPathPlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</label>
|
||||
<label class="sub" for="app-df-dockerfile">
|
||||
<span class="sub-label">{$t('apps.new.dockerfilePath')}</span>
|
||||
<input
|
||||
id="app-df-dockerfile"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={form.dockerfilePath}
|
||||
placeholder="Dockerfile"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="sub" for="app-df-port">
|
||||
<span class="sub-label"
|
||||
>{$t('apps.new.dockerfilePort')}<span class="req-star" aria-label={$t('apps.new.fieldRequired')}
|
||||
>*</span
|
||||
></span
|
||||
>
|
||||
<input
|
||||
id="app-df-port"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
class="input mono"
|
||||
bind:value={form.port}
|
||||
onblur={() => (portTouched = true)}
|
||||
placeholder="8080"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{#if portTouched && !portValid}
|
||||
<div class="discover-pill discover-pill-bad">
|
||||
<IconX size={12} />
|
||||
<span>{$t('apps.new.dockerfilePortRequired')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<p class="hint image-form-foot">{$t('apps.new.dockerfileFoot')}</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.input {
|
||||
width: 100%;
|
||||
background: var(--surface-input);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 0.6rem 0.8rem;
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
.input:focus {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.input:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.sub {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.sub-label {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
/* Required-field marker — same danger hue as the page-level `.req`
|
||||
badge, rendered as a compact asterisk. */
|
||||
.req-star {
|
||||
margin-left: 0.2rem;
|
||||
color: var(--color-danger);
|
||||
font-weight: 700;
|
||||
}
|
||||
.editor-title {
|
||||
margin-left: 0.4rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
/* ── "Edit as JSON" escape hatch ─────────────────────────
|
||||
Quiet secondary text-link rather than a prominent chip, so it doesn't
|
||||
compete with the form title. */
|
||||
.json-escape {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
text-underline-offset: 3px;
|
||||
transition: color 120ms ease;
|
||||
}
|
||||
.json-escape:hover {
|
||||
color: var(--forge-accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.json-escape:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* ── Image source form shell (shared visual vocabulary) ── */
|
||||
.image-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-input);
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
.image-form-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 0.55rem;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.image-form-foot {
|
||||
margin-top: 0.2rem;
|
||||
padding-top: 0.55rem;
|
||||
border-top: 1px dashed var(--border-primary);
|
||||
}
|
||||
/* Conceptual section divider — separates git-discovery from build-step.
|
||||
Same dashed border vocabulary as image-form-foot so it reads as a
|
||||
sibling of the foot hint, not a new pattern. */
|
||||
.df-section-break {
|
||||
margin-top: 0.45rem;
|
||||
padding-top: 0.55rem;
|
||||
border-top: 1px dashed var(--border-primary);
|
||||
}
|
||||
|
||||
.discover-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.28rem 0.55rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
line-height: 1;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.discover-pill-bad {
|
||||
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
|
||||
color: var(--color-danger-dark);
|
||||
border: 1px solid color-mix(in srgb, var(--color-danger) 40%, transparent);
|
||||
}
|
||||
:global([data-theme='dark']) .discover-pill-bad {
|
||||
color: #fca5a5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,741 @@
|
||||
<!--
|
||||
Image source form. Surfaces the most-used image-source fields as proper
|
||||
controls (ref, port, healthcheck, default tag, registry, resource limits).
|
||||
Env + volumes stay on the detail page where they have dedicated panels.
|
||||
|
||||
The parent owns the `ImageFormState` (from `$lib/workload/sourceForms`) and
|
||||
binds it here; serialization to `source_config` is done by the parent via
|
||||
`imageToConfig` so the shape stays byte-identical.
|
||||
|
||||
Two pieces of async UX live in this component but write their results back
|
||||
through bindable props so the parent's submit gate can read them:
|
||||
|
||||
• Inspect — pulls port + healthcheck from the image metadata. Guarded by
|
||||
an AbortController + ref re-check so a late response can't relabel a
|
||||
newer ref. Touch sentinels stop Inspect overwriting fields the operator
|
||||
already edited.
|
||||
• Conflict lookup — debounced /api/discovery/image/conflicts call,
|
||||
guarded by a sequence token so a slow earlier response can't clobber a
|
||||
faster later one. The `conflicts` / `conflictAcknowledged` /
|
||||
`conflictBlocked` triplet is bound to the parent which runs the
|
||||
two-click "submit anyway" gate.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import type { ImageFormState } from '$lib/workload/sourceForms';
|
||||
import * as api from '$lib/api';
|
||||
import { IconSearch, IconLoader } from '$lib/components/icons';
|
||||
import RegistryImagePicker from '$lib/components/RegistryImagePicker.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
interface Props {
|
||||
form: ImageFormState;
|
||||
/** Registry list for the picker; empty falls back to a text input. */
|
||||
registries?: { name: string; url: string }[];
|
||||
/** Bound submitting flag — gates the debounced conflict lookup. */
|
||||
submitting?: boolean;
|
||||
/** Bound conflict triplet — the parent's submit gate reads these. */
|
||||
conflicts?: api.ImageConflict[];
|
||||
conflictAcknowledged?: boolean;
|
||||
conflictBlocked?: boolean;
|
||||
/**
|
||||
* Gate the debounced /api/discovery/image/conflicts lookup + the conflict
|
||||
* warning panel. The create wizard wants it (default `true`) so an
|
||||
* operator about to deploy a duplicate image is warned. The detail-page
|
||||
* EDIT form must turn it OFF — there the workload would flag itself as a
|
||||
* conflict with its own image. When off, the conflict triplet / submitting
|
||||
* / registries props are unused and may be omitted by the parent.
|
||||
*/
|
||||
enableConflicts?: boolean;
|
||||
/** Flip to the raw-JSON editor (owned by the parent). */
|
||||
onAdvanced: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
form = $bindable(),
|
||||
registries = [],
|
||||
submitting = $bindable(false),
|
||||
conflicts = $bindable([]),
|
||||
conflictAcknowledged = $bindable(false),
|
||||
conflictBlocked = $bindable(false),
|
||||
enableConflicts = true,
|
||||
onAdvanced
|
||||
}: Props = $props();
|
||||
|
||||
// ── Inspect state ─────────────────────────────────────────────────
|
||||
type InspectStatus = 'idle' | 'pending' | 'ok' | 'error';
|
||||
let inspectStatus = $state<InspectStatus>('idle');
|
||||
// AbortController + sequence guard so a late inspect response cannot
|
||||
// mislabel the *current* image ref after the user typed a new one.
|
||||
let inspectAbort: AbortController | null = null;
|
||||
// Touch sentinels for fields with a "0 == empty" sentinel value (port,
|
||||
// healthcheck). Once the user interacts, Inspect leaves them alone — even
|
||||
// when still `0` / "" (some images really do listen on port 0 / have no
|
||||
// healthcheck).
|
||||
let portTouched = $state(false);
|
||||
let healthcheckTouched = $state(false);
|
||||
|
||||
// The legacy inline seedImageFromJSON reset the touched sentinels on every
|
||||
// reseed (mount, kind switch, Advanced↔form toggle). The parent reseeds by
|
||||
// REASSIGNING the form object (a fresh seed* result), so the object's
|
||||
// identity changes on reseed but not on in-place field edits. Track that
|
||||
// identity to reset the sentinels exactly when a reseed happens — keeping
|
||||
// Inspect's "only fill untouched fields" behaviour identical to before.
|
||||
let lastSeenForm: ImageFormState | null = null;
|
||||
$effect(() => {
|
||||
if (form !== lastSeenForm) {
|
||||
lastSeenForm = form;
|
||||
portTouched = false;
|
||||
healthcheckTouched = false;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Conflict-lookup state ─────────────────────────────────────────
|
||||
let conflictLoading = $state(false);
|
||||
let conflictDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
// Race token so a slow earlier response cannot overwrite a faster later one.
|
||||
let conflictReqSeq = 0;
|
||||
|
||||
// Query the backend for workloads already using this image. Failures are
|
||||
// silent (the existing list stays) — a transient network blip should never
|
||||
// clear a real warning. The caller guards against empty / too-short refs.
|
||||
async function fetchImageConflicts(ref: string): Promise<void> {
|
||||
const mine = ++conflictReqSeq;
|
||||
conflictLoading = true;
|
||||
try {
|
||||
const result = await api.listImageConflicts(ref);
|
||||
if (mine === conflictReqSeq) {
|
||||
conflicts = result;
|
||||
}
|
||||
} catch (e) {
|
||||
if (mine === conflictReqSeq) {
|
||||
// no-op; intentionally preserve prior conflicts
|
||||
void e;
|
||||
}
|
||||
} finally {
|
||||
if (mine === conflictReqSeq) {
|
||||
conflictLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleImageConflictLookup(ref: string) {
|
||||
if (conflictDebounce) {
|
||||
clearTimeout(conflictDebounce);
|
||||
conflictDebounce = null;
|
||||
}
|
||||
// Conflict detection disabled (edit form) — never probe the backend.
|
||||
if (!enableConflicts) return;
|
||||
const trimmed = ref.trim();
|
||||
if (trimmed.length < 3 || submitting) return;
|
||||
conflictDebounce = setTimeout(() => {
|
||||
conflictDebounce = null;
|
||||
void fetchImageConflicts(trimmed);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function onImageRefInput() {
|
||||
// Typing invalidates any prior acknowledgement and clears the stale
|
||||
// list so the panel doesn't lie about the current ref; the debounced
|
||||
// lookup will repopulate it.
|
||||
conflictAcknowledged = false;
|
||||
conflictBlocked = false;
|
||||
conflicts = [];
|
||||
// Also reset the inspect pill — its OK/error status belongs to the
|
||||
// *previous* ref and would mislead the user otherwise.
|
||||
inspectStatus = 'idle';
|
||||
inspectAbort?.abort();
|
||||
scheduleImageConflictLookup(form.ref);
|
||||
}
|
||||
|
||||
function onImageRefBlur() {
|
||||
if (!enableConflicts) return;
|
||||
const trimmed = form.ref.trim();
|
||||
if (trimmed.length < 3 || submitting) return;
|
||||
if (conflictDebounce) {
|
||||
clearTimeout(conflictDebounce);
|
||||
conflictDebounce = null;
|
||||
}
|
||||
void fetchImageConflicts(trimmed);
|
||||
}
|
||||
|
||||
// Tear down the pending debounce timer + cancel any in-flight inspect
|
||||
// request if the user navigates away mid-window — otherwise the late
|
||||
// resolve mutates dead state.
|
||||
onDestroy(() => {
|
||||
if (conflictDebounce) {
|
||||
clearTimeout(conflictDebounce);
|
||||
conflictDebounce = null;
|
||||
}
|
||||
inspectAbort?.abort();
|
||||
});
|
||||
|
||||
// Pull port + healthcheck from the image's exposed metadata. Only
|
||||
// overwrites untouched fields. A new call aborts any in-flight one, and we
|
||||
// re-check the ref after the await so a late response can't relabel the
|
||||
// *new* image ref the user just typed.
|
||||
async function inspectImageRef() {
|
||||
const ref = form.ref.trim();
|
||||
if (!ref) return;
|
||||
if (inspectStatus === 'pending') return;
|
||||
inspectAbort?.abort();
|
||||
const controller = new AbortController();
|
||||
inspectAbort = controller;
|
||||
inspectStatus = 'pending';
|
||||
try {
|
||||
const result = await api.inspectImage(ref, controller.signal);
|
||||
// Late-arrival guard: if the user edited the ref during the flight,
|
||||
// our success belongs to a stale value — discard.
|
||||
if (form.ref.trim() !== ref) return;
|
||||
// Only fill fields the operator hasn't touched. The sentinel is the
|
||||
// touched flag, not the value — a user who deliberately types `0`
|
||||
// or clears the healthcheck still owns the field.
|
||||
if (!portTouched && typeof result.port === 'number') form.port = result.port;
|
||||
if (!healthcheckTouched && typeof result.healthcheck === 'string') {
|
||||
form.healthcheck = result.healthcheck;
|
||||
}
|
||||
inspectStatus = 'ok';
|
||||
} catch (e) {
|
||||
if (controller.signal.aborted) return;
|
||||
if (form.ref.trim() !== ref) return;
|
||||
// Show a friendly, localized message — never the raw backend
|
||||
// string (the discovery handlers were just hardened to drop
|
||||
// leaky daemon errors, so there is nothing useful to surface).
|
||||
void e;
|
||||
inspectStatus = 'error';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="image-form">
|
||||
<div class="image-form-head">
|
||||
<span class="editor-title">{$t('apps.new.imageHeader')}</span>
|
||||
<button type="button" class="json-escape" onclick={onAdvanced} title={$t('apps.new.switchToJsonTitle')}>
|
||||
{$t('apps.new.advancedJson')}
|
||||
</button>
|
||||
</div>
|
||||
<label class="sub" for="app-image-ref">
|
||||
<span class="sub-label"
|
||||
>{$t('apps.new.imageRefLabel')}<span class="req-star" aria-label={$t('apps.new.fieldRequired')}>*</span></span
|
||||
>
|
||||
<div class="input-with-button">
|
||||
<input
|
||||
id="app-image-ref"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={form.ref}
|
||||
oninput={onImageRefInput}
|
||||
onblur={onImageRefBlur}
|
||||
placeholder={$t('apps.new.imageRefPlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="discover-btn"
|
||||
onclick={inspectImageRef}
|
||||
disabled={!form.ref.trim() || inspectStatus === 'pending'}
|
||||
title={$t('apps.new.imageInspectHint')}
|
||||
>
|
||||
{#if inspectStatus === 'pending'}
|
||||
<IconLoader size={14} />
|
||||
{:else}
|
||||
<IconSearch size={14} />
|
||||
{/if}
|
||||
<span>{$t('apps.new.imageInspect')}</span>
|
||||
</button>
|
||||
<RegistryImagePicker
|
||||
current={form.ref}
|
||||
onpick={(ref, registryName) => {
|
||||
form.ref = ref;
|
||||
// Auto-select the registry the image came from so private
|
||||
// images pull with the right credentials without a second
|
||||
// manual step. Only adopt it when the picker surfaced a
|
||||
// non-empty name (public images carry '') so we never wipe a
|
||||
// registry the operator already chose.
|
||||
if (registryName) form.registryName = registryName;
|
||||
onImageRefInput();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p class="hint">{$t('apps.new.imageRefHint')}</p>
|
||||
{#if inspectStatus === 'ok'}
|
||||
<span class="discover-pill discover-pill-ok inline">{$t('apps.new.imageInspectOk')}</span>
|
||||
{:else if inspectStatus === 'error'}
|
||||
<span class="discover-pill discover-pill-bad inline">{$t('apps.new.errors.inspectFailed')}</span>
|
||||
{/if}
|
||||
<!--
|
||||
Conflict-checking indicator. Reserves no layout when idle and is a
|
||||
quiet inline hint (not the full panel) while a lookup is in flight,
|
||||
so a no-conflict blur no longer flashes the warning panel in then
|
||||
out. The panel itself renders only for REAL conflicts below.
|
||||
-->
|
||||
{#if enableConflicts && conflictLoading}
|
||||
<span class="conflict-checking" role="status" aria-live="polite">
|
||||
<IconLoader size={12} />
|
||||
<span>{$t('apps.new.imageConflictChecking')}</span>
|
||||
</span>
|
||||
{/if}
|
||||
</label>
|
||||
{#if enableConflicts && conflicts.length > 0}
|
||||
<div class="conflict-panel" role="status" aria-live="polite">
|
||||
<div class="conflict-panel-head">
|
||||
<span class="conflict-tag">{$t('apps.new.imageConflictTag')}</span>
|
||||
</div>
|
||||
<p class="conflict-heading">
|
||||
{$t('apps.new.imageConflictHeading', { count: String(conflicts.length) })}
|
||||
<code class="conflict-ref mono">{form.ref.trim()}</code>
|
||||
</p>
|
||||
<ul class="conflict-list">
|
||||
{#each conflicts as conflict (conflict.id)}
|
||||
<li class="conflict-row">
|
||||
<div class="conflict-row-text">
|
||||
<span class="conflict-name">{conflict.name}</span>
|
||||
<span class="conflict-image mono">{conflict.image}</span>
|
||||
</div>
|
||||
<a
|
||||
href={`/apps/${conflict.id}`}
|
||||
class="editor-chip conflict-open"
|
||||
title={$t('apps.new.imageConflictOpenBtn')}
|
||||
>
|
||||
{$t('apps.new.imageConflictOpenBtn')}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="conflict-foot">{$t('apps.new.imageConflictAcknowledgeNote')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="row three">
|
||||
<label class="sub" for="app-image-port">
|
||||
<span class="sub-label">{$t('apps.new.imagePort')}</span>
|
||||
<input
|
||||
id="app-image-port"
|
||||
type="number"
|
||||
min="0"
|
||||
max="65535"
|
||||
class="input"
|
||||
bind:value={form.port}
|
||||
oninput={() => (portTouched = true)}
|
||||
/>
|
||||
</label>
|
||||
<label class="sub" for="app-image-healthcheck">
|
||||
<span class="sub-label">{$t('apps.new.imageHealthcheck')}</span>
|
||||
<input
|
||||
id="app-image-healthcheck"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={form.healthcheck}
|
||||
oninput={() => (healthcheckTouched = true)}
|
||||
placeholder="/healthz"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</label>
|
||||
<label class="sub" for="app-image-default-tag">
|
||||
<span class="sub-label">{$t('apps.new.imageDefaultTag')}</span>
|
||||
<input
|
||||
id="app-image-default-tag"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={form.defaultTag}
|
||||
placeholder="latest"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label class="sub" for="app-image-registry">
|
||||
<span class="sub-label">{$t('apps.new.imageRegistryLabel')}</span>
|
||||
{#if registries.length > 0}
|
||||
<select id="app-image-registry" class="input" bind:value={form.registryName}>
|
||||
<option value="">{$t('apps.new.imageRegistryPublic')}</option>
|
||||
{#each registries as r}
|
||||
<option value={r.name}>{r.name} — {r.url}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else}
|
||||
<input
|
||||
id="app-image-registry"
|
||||
type="text"
|
||||
class="input"
|
||||
bind:value={form.registryName}
|
||||
placeholder={$t('apps.new.imageRegistryPublic')}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{/if}
|
||||
<p class="hint">{$t('apps.new.imageRegistryHint')}</p>
|
||||
</label>
|
||||
<div class="row three">
|
||||
<label class="sub" for="app-image-cpu">
|
||||
<span class="sub-label">{$t('apps.new.imageCpu')}</span>
|
||||
<input id="app-image-cpu" type="number" min="0" step="0.1" class="input" bind:value={form.cpuLimit} />
|
||||
<p class="hint">{$t('apps.new.imageCpuHint')}</p>
|
||||
</label>
|
||||
<label class="sub" for="app-image-memory">
|
||||
<span class="sub-label">{$t('apps.new.imageMemory')}</span>
|
||||
<input id="app-image-memory" type="number" min="0" class="input" bind:value={form.memoryLimit} />
|
||||
<p class="hint">{$t('apps.new.imageMemoryHint')}</p>
|
||||
</label>
|
||||
<label class="sub" for="app-image-max">
|
||||
<span class="sub-label">{$t('apps.new.imageMax')}</span>
|
||||
<input id="app-image-max" type="number" min="1" class="input" bind:value={form.maxInstances} />
|
||||
<p class="hint">{$t('apps.new.imageMaxHint')}</p>
|
||||
</label>
|
||||
</div>
|
||||
<p class="hint image-form-foot">{$t('apps.new.imageFoot')}</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.input {
|
||||
width: 100%;
|
||||
background: var(--surface-input);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 0.6rem 0.8rem;
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
.input:focus {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.input:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
.sub {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.sub-label {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
/* Required-field marker — same danger hue as the page-level `.req`
|
||||
badge, rendered as a compact asterisk so it doesn't bloat the mono
|
||||
sub-label. The aria-label carries the meaning for assistive tech. */
|
||||
.req-star {
|
||||
margin-left: 0.2rem;
|
||||
color: var(--color-danger);
|
||||
font-weight: 700;
|
||||
}
|
||||
.editor-title {
|
||||
margin-left: 0.4rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.editor-chip {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.22rem 0.55rem;
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 120ms ease;
|
||||
}
|
||||
.editor-chip:hover {
|
||||
border-color: var(--color-brand-400);
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-card-hover);
|
||||
}
|
||||
.editor-chip:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ── "Edit as JSON" escape hatch ─────────────────────────
|
||||
A quiet secondary text-link, not a prominent chip — it must not
|
||||
compete with the form title. Mono, muted, underline-on-hover so it
|
||||
reads as the rarely-used power-user door it is. */
|
||||
.json-escape {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
text-underline-offset: 3px;
|
||||
transition: color 120ms ease;
|
||||
}
|
||||
.json-escape:hover {
|
||||
color: var(--forge-accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.json-escape:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* ── Image source form ──────────────────────────────────
|
||||
Same overall shell as the editor box (border + radius) but the
|
||||
contents are a stack of labelled form rows. */
|
||||
.image-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-input);
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
.image-form-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 0.55rem;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.image-form-foot {
|
||||
margin-top: 0.2rem;
|
||||
padding-top: 0.55rem;
|
||||
border-top: 1px dashed var(--border-primary);
|
||||
}
|
||||
.row.three {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.row.three {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.row.three {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Discovery-style input+button row + status pills ─── */
|
||||
.input-with-button {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.input-with-button > .input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.discover-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0 0.7rem;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 120ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.discover-btn:hover:not(:disabled) {
|
||||
border-color: var(--color-brand-400);
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-card-hover);
|
||||
}
|
||||
.discover-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.discover-btn:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.discover-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.28rem 0.55rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
line-height: 1;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.discover-pill.inline {
|
||||
align-self: center;
|
||||
}
|
||||
.discover-pill-ok {
|
||||
background: color-mix(in srgb, var(--color-success) 14%, transparent);
|
||||
color: var(--color-success-dark);
|
||||
border: 1px solid color-mix(in srgb, var(--color-success) 40%, transparent);
|
||||
}
|
||||
.discover-pill-bad {
|
||||
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
|
||||
color: var(--color-danger-dark);
|
||||
border: 1px solid color-mix(in srgb, var(--color-danger) 40%, transparent);
|
||||
}
|
||||
:global([data-theme='dark']) .discover-pill-ok {
|
||||
color: #86efac;
|
||||
}
|
||||
:global([data-theme='dark']) .discover-pill-bad {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/* ── Image-source conflict panel ──────────────────
|
||||
Sibling of .image-form-foot. Reuses the dashed border + soft card
|
||||
surface treatment lifted into a loud amber-leaning warning tag. */
|
||||
.conflict-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
margin-top: 0.2rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
background: var(--surface-card-hover);
|
||||
border: 1px dashed var(--color-warning, var(--forge-accent));
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.conflict-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.conflict-tag {
|
||||
display: inline-flex;
|
||||
padding: 0.18rem 0.5rem;
|
||||
background: var(--color-warning, var(--forge-accent));
|
||||
color: var(--surface-card);
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.58rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 1;
|
||||
}
|
||||
/* Quiet inline "checking…" hint shown near the image-ref input while a
|
||||
conflict lookup is in flight. Deliberately NOT the full panel, so a
|
||||
no-conflict blur doesn't flash a panel in and out. Self-aligned so it
|
||||
sits with the inspect status pills without shifting form layout. */
|
||||
.conflict-checking {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
align-self: flex-start;
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.conflict-checking :global(svg) {
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.conflict-heading {
|
||||
margin: 0;
|
||||
font-size: 0.84rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.conflict-ref {
|
||||
padding: 0.1rem 0.35rem;
|
||||
font-size: 0.78rem;
|
||||
background: var(--surface-input);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.conflict-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.conflict-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0.65rem;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.conflict-row-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.conflict-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.conflict-image {
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
.conflict-open {
|
||||
flex: 0 0 auto;
|
||||
text-decoration: none;
|
||||
}
|
||||
.conflict-foot {
|
||||
margin: 0;
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,122 @@
|
||||
<script lang="ts">
|
||||
// Card-grid selector for the workload Source kind, replacing a bare
|
||||
// <select>. Mirrors the trigger-mode card pattern already used in the
|
||||
// wizard (role=radio buttons, mono tag + name + optional hint) so the
|
||||
// two pickers read as one design language. Kinds come from the backend
|
||||
// plugin registry; descriptions are optional (passed in when i18n keys
|
||||
// exist) so this component never hardcodes copy.
|
||||
interface Props {
|
||||
kinds: string[];
|
||||
value: string;
|
||||
onchange: () => void;
|
||||
descriptions?: Record<string, string>;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
let { kinds, value = $bindable(), onchange, descriptions = {}, ariaLabel }: Props = $props();
|
||||
|
||||
function select(kind: string): void {
|
||||
if (kind === value) return;
|
||||
value = kind;
|
||||
onchange();
|
||||
}
|
||||
|
||||
// Radiogroup keyboard semantics: arrows move selection (and focus) to
|
||||
// the adjacent card, wrapping at the ends.
|
||||
function onKeydown(e: KeyboardEvent, index: number): void {
|
||||
const k = e.key;
|
||||
if (k !== 'ArrowRight' && k !== 'ArrowDown' && k !== 'ArrowLeft' && k !== 'ArrowUp') return;
|
||||
e.preventDefault();
|
||||
const dir = k === 'ArrowRight' || k === 'ArrowDown' ? 1 : -1;
|
||||
const next = (index + dir + kinds.length) % kinds.length;
|
||||
select(kinds[next]);
|
||||
const grid = (e.currentTarget as HTMLElement).closest('.kind-grid');
|
||||
grid?.querySelectorAll<HTMLButtonElement>('button')[next]?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="kind-grid" role="radiogroup" aria-label={ariaLabel}>
|
||||
{#each kinds as kind, i (kind)}
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={value === kind}
|
||||
tabindex={value === kind ? 0 : -1}
|
||||
class="kind-card"
|
||||
class:active={value === kind}
|
||||
onclick={() => select(kind)}
|
||||
onkeydown={(e) => onKeydown(e, i)}
|
||||
>
|
||||
<span class="kind-tag mono">{kind.toUpperCase()}</span>
|
||||
<span class="kind-name">{kind}</span>
|
||||
{#if descriptions[kind]}
|
||||
<span class="kind-hint">{descriptions[kind]}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.kind-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.kind-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-4);
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
background var(--transition-fast),
|
||||
box-shadow var(--transition-fast),
|
||||
transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.kind-card:hover {
|
||||
border-color: var(--border-input);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.kind-card.active {
|
||||
border-color: var(--forge-ember);
|
||||
background: color-mix(in srgb, var(--forge-ember) 6%, var(--surface-card));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--forge-ember) 16%, transparent);
|
||||
}
|
||||
|
||||
.kind-tag {
|
||||
font-size: 0.625rem;
|
||||
letter-spacing: 0.08em;
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-tertiary);
|
||||
padding: 2px var(--space-2);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.kind-card.active .kind-tag {
|
||||
color: var(--forge-ember-deep);
|
||||
border-color: color-mix(in srgb, var(--forge-ember) 40%, transparent);
|
||||
}
|
||||
|
||||
.kind-name {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-primary);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.kind-hint {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
line-height: var(--leading-normal);
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,240 @@
|
||||
<!--
|
||||
Static source form — Gitea Pages-alike with optional Deno runtime mode.
|
||||
Delegates the git-discovery block (provider/repo/branch/token + folder
|
||||
tree) to StaticDiscoveryWizard, then adds the static-only mode radio and
|
||||
the render-markdown toggle.
|
||||
|
||||
The parent owns the `StaticFormState` (from `$lib/workload/sourceForms`)
|
||||
and binds it here; serialization to `source_config` is done by the parent
|
||||
via `staticToConfig` so the shape (incl. preserved storage_* keys) stays
|
||||
byte-identical. `StaticFormState extends GitSourceState`, so the same
|
||||
object is bound straight into the wizard's `git` slice.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { StaticFormState } from '$lib/workload/sourceForms';
|
||||
import type { FolderEntry } from '$lib/api';
|
||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||
import StaticDiscoveryWizard from '$lib/components/workload/StaticDiscoveryWizard.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
interface Props {
|
||||
form: StaticFormState;
|
||||
/** Flip to the raw-JSON editor (owned by the parent). */
|
||||
onAdvanced: () => void;
|
||||
/**
|
||||
* Transient discovery status — OPTIONAL pass-through to
|
||||
* StaticDiscoveryWizard. Each defaults to this component's own
|
||||
* internal `$state`, so a parent that doesn't bind them gets the
|
||||
* original "resets on remount" behaviour (this is what the detail/
|
||||
* edit page `apps/[id]` relies on — it binds none of these). The
|
||||
* create wizard `apps/new` binds them up to the PAGE so the loaded
|
||||
* tree + detect/test pills + mode override survive the form
|
||||
* unmounting under the Advanced-JSON / source-kind toggles.
|
||||
*/
|
||||
modeUserOverride?: boolean;
|
||||
treeLoaded?: boolean;
|
||||
tree?: FolderEntry[];
|
||||
detectStatus?: 'idle' | 'pending' | 'ok' | 'error';
|
||||
detectError?: string;
|
||||
testStatus?: 'idle' | 'pending' | 'ok' | 'error';
|
||||
testError?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
form = $bindable(),
|
||||
onAdvanced,
|
||||
// Sentinel: once the user manually toggles the static/deno radio,
|
||||
// auto-detection stops overwriting their choice on subsequent tree loads.
|
||||
modeUserOverride = $bindable(false),
|
||||
// Reflects whether the discovery wizard has loaded a folder tree — gates
|
||||
// the "auto-detected Deno" hint exactly like the legacy
|
||||
// `staticTree.length > 0` guard did.
|
||||
treeLoaded = $bindable(false),
|
||||
tree = $bindable([]),
|
||||
detectStatus = $bindable('idle'),
|
||||
detectError = $bindable(''),
|
||||
testStatus = $bindable('idle'),
|
||||
testError = $bindable('')
|
||||
}: Props = $props();
|
||||
|
||||
function onModeChange() {
|
||||
modeUserOverride = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="image-form">
|
||||
<div class="image-form-head">
|
||||
<span class="editor-title">{$t('apps.new.staticHeader')}</span>
|
||||
<button type="button" class="json-escape" onclick={onAdvanced} title={$t('apps.new.switchToJsonTitle')}>
|
||||
{$t('apps.new.advancedJson')}
|
||||
</button>
|
||||
</div>
|
||||
<StaticDiscoveryWizard
|
||||
bind:git={form}
|
||||
variant="static"
|
||||
showFolderTree={true}
|
||||
bind:folderPath={form.folderPath}
|
||||
bind:mode={form.mode}
|
||||
bind:modeUserOverride
|
||||
bind:treeLoaded
|
||||
bind:tree
|
||||
bind:detectStatus
|
||||
bind:detectError
|
||||
bind:testStatus
|
||||
bind:testError
|
||||
idPrefix="app-static"
|
||||
/>
|
||||
<fieldset class="static-mode">
|
||||
<legend class="sub-label">{$t('apps.new.staticMode')}</legend>
|
||||
<label class="radio">
|
||||
<input type="radio" name="static-mode" value="static" bind:group={form.mode} onchange={onModeChange} />
|
||||
<span>
|
||||
<strong>static</strong> {$t('apps.new.staticModeStaticDesc')}
|
||||
</span>
|
||||
</label>
|
||||
<label class="radio">
|
||||
<input type="radio" name="static-mode" value="deno" bind:group={form.mode} onchange={onModeChange} />
|
||||
<span>
|
||||
<strong>deno</strong> {$t('apps.new.staticModeDenoDesc')}
|
||||
</span>
|
||||
</label>
|
||||
{#if !modeUserOverride && form.mode === 'deno' && treeLoaded}
|
||||
<p class="hint static-deno-auto">{$t('apps.new.staticDenoAutoDetected')}</p>
|
||||
{/if}
|
||||
</fieldset>
|
||||
<label class="toggle-row">
|
||||
<ToggleSwitch bind:checked={form.renderMarkdown} label={$t('apps.new.staticRenderMarkdown')} />
|
||||
<span>
|
||||
<strong>{$t('apps.new.staticRenderMarkdown')}</strong> {@html $t('apps.new.staticRenderMarkdownDesc')}
|
||||
</span>
|
||||
</label>
|
||||
<p class="hint image-form-foot">{$t('apps.new.staticFoot')}</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.hint {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.sub-label {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.editor-title {
|
||||
margin-left: 0.4rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
/* ── "Edit as JSON" escape hatch ─────────────────────────
|
||||
Quiet secondary text-link rather than a prominent chip, so it doesn't
|
||||
compete with the form title. */
|
||||
.json-escape {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
text-underline-offset: 3px;
|
||||
transition: color 120ms ease;
|
||||
}
|
||||
.json-escape:hover {
|
||||
color: var(--forge-accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.json-escape:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* ── Image source form shell (shared visual vocabulary) ── */
|
||||
.image-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-input);
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
.image-form-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 0.55rem;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.image-form-foot {
|
||||
margin-top: 0.2rem;
|
||||
padding-top: 0.55rem;
|
||||
border-top: 1px dashed var(--border-primary);
|
||||
}
|
||||
|
||||
/* ── Static source extras ────────────────────────────── */
|
||||
.static-mode {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
margin: 0;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-card);
|
||||
}
|
||||
.static-mode legend {
|
||||
padding: 0 0.3rem;
|
||||
}
|
||||
.radio {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.55rem;
|
||||
padding: 0.35rem 0;
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.radio input {
|
||||
margin-top: 0.18rem;
|
||||
accent-color: var(--color-brand-500);
|
||||
}
|
||||
.radio strong,
|
||||
.toggle-row strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.55rem;
|
||||
padding: 0.35rem 0;
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.toggle-row :global(.toggle-switch) {
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
.static-deno-auto {
|
||||
margin-top: 0.35rem;
|
||||
padding-top: 0.35rem;
|
||||
border-top: 1px dashed var(--border-primary);
|
||||
color: var(--color-success-dark);
|
||||
}
|
||||
:global([data-theme='dark']) .static-deno-auto {
|
||||
color: #86efac;
|
||||
}
|
||||
</style>
|
||||
+143
-15
@@ -15,7 +15,7 @@
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"apps": "Apps",
|
||||
"eventTriggers": "Triggers",
|
||||
"eventTriggers": "Event Triggers",
|
||||
"logScanRules": "Log Rules",
|
||||
"triggers": "Triggers",
|
||||
"proxies": "Proxies",
|
||||
@@ -23,7 +23,13 @@
|
||||
"settings": "Settings",
|
||||
"logout": "Log out",
|
||||
"dns": "DNS Records",
|
||||
"containers": "Containers"
|
||||
"containers": "Containers",
|
||||
"sectionObserve": "Observe",
|
||||
"sectionSystem": "System",
|
||||
"closeSidebar": "Close sidebar",
|
||||
"openSidebar": "Open sidebar",
|
||||
"quickNavTitle": "Press 'g' then a letter to jump between sections",
|
||||
"quickNavLabel": "quick-nav"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -42,7 +48,11 @@
|
||||
"systemHealth": "System health",
|
||||
"daemons": "Daemons",
|
||||
"systemResources": "System resources",
|
||||
"systemResourcesSubtitle": "CPU, memory, disk, and top consumers"
|
||||
"systemResourcesSubtitle": "CPU, memory, disk, and top consumers",
|
||||
"statSubWorkloads": "workloads →",
|
||||
"statSubRunning": "running",
|
||||
"statSubNeedAttention": "need attention",
|
||||
"statSubStale": "stale →"
|
||||
},
|
||||
"resources": {
|
||||
"cpuCores": "CPU Cores",
|
||||
@@ -237,6 +247,7 @@
|
||||
"deleteFailed": "Failed to delete registry",
|
||||
"testFailed": "Connection test failed",
|
||||
"loadFailed": "Failed to load registries",
|
||||
"deleteTitle": "Delete registry?",
|
||||
"deleteConfirm": "Delete registry \"{name}\"? This cannot be undone.",
|
||||
"healthChecking": "Checking...",
|
||||
"healthConnected": "Connected",
|
||||
@@ -354,6 +365,7 @@
|
||||
"createFailed": "Failed to create user",
|
||||
"deleteFailed": "Failed to delete user",
|
||||
"deleteConfirm": "Are you sure you want to delete this user?",
|
||||
"deleteConfirmMessage": "Delete user \"{username}\"? This cannot be undone.",
|
||||
"usernameRequired": "Username and password are required",
|
||||
"networkError": "Network error",
|
||||
"password": "Password"
|
||||
@@ -400,6 +412,9 @@
|
||||
"common": {
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"close": "Close",
|
||||
"toggle": "Toggle",
|
||||
"dismissNotification": "Dismiss notification",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"change": "Change",
|
||||
@@ -429,6 +444,7 @@
|
||||
"missing": "Missing"
|
||||
},
|
||||
"containers": {
|
||||
"eyebrowSuffix": "GLOBAL",
|
||||
"errLoad": "Failed to load containers",
|
||||
"searchPlaceholder": "Search workload, role, image, subdomain…",
|
||||
"kindFilterLabel": "Workload kind",
|
||||
@@ -476,6 +492,7 @@
|
||||
},
|
||||
"stale": {
|
||||
"title": "Stale Containers",
|
||||
"eyebrowSuffix": "STALE",
|
||||
"noStale": "No stale containers",
|
||||
"noStaleDesc": "All containers are healthy and running.",
|
||||
"cleanup": "Clean up",
|
||||
@@ -541,13 +558,13 @@
|
||||
"unavailable": "Stats unavailable"
|
||||
},
|
||||
"systemHealth": {
|
||||
"title": "System Health",
|
||||
"containers": "Containers",
|
||||
"proxies": "Proxies",
|
||||
"recentErrors": "Recent Errors"
|
||||
},
|
||||
"daemons": {
|
||||
"title": "Daemons",
|
||||
"notReachable": "{provider} is not reachable.",
|
||||
"refresh": "Refresh",
|
||||
"refreshing": "Refreshing",
|
||||
"docker": "Docker Engine",
|
||||
@@ -1110,6 +1127,10 @@
|
||||
"image": "Image reference",
|
||||
"imagePlaceholder": "registry.example.com/owner/app",
|
||||
"imageHint": "Full image reference without the tag — Tinyforge matches new tags pushed under it.",
|
||||
"browseImages": "Browse",
|
||||
"browseImagesHint": "Pick an image from a configured registry instead of typing the reference.",
|
||||
"browseImagesTitle": "Select an image",
|
||||
"browseImagesSearch": "Search images…",
|
||||
"tagPattern": "Tag pattern",
|
||||
"tagPatternPlaceholder": "*",
|
||||
"tagPatternHint": "path.Match glob (e.g. v*, release-*). Use * to match every tag.",
|
||||
@@ -1122,6 +1143,9 @@
|
||||
"branch": "Branch",
|
||||
"branchPlaceholder": "main",
|
||||
"branchHint": "Only push events advancing this branch fire the trigger.",
|
||||
"branchPattern": "Branch pattern (preview deploys)",
|
||||
"branchPatternPlaceholder": "feat/* or * for any branch",
|
||||
"branchPatternHint": "When set, any push to a matching branch spawns a per-branch preview deploy. Leave empty to disable previews.",
|
||||
"manualNote": "Manual triggers carry no config. They fire only via the workload's Deploy button or POST /workloads/{id}/deploy.",
|
||||
"scheduleNote": "Fires on a fixed interval driven by Tinyforge's internal scheduler. No external webhook is required — enable the webhook ingress below only if a CI also needs to fire it on demand.",
|
||||
"intervalPresets": "Quick presets",
|
||||
@@ -1186,6 +1210,14 @@
|
||||
},
|
||||
"new": {
|
||||
"pageTitle": "New App · Tinyforge",
|
||||
"wizard": {
|
||||
"stepBasics": "Basics",
|
||||
"stepConfigure": "Configure",
|
||||
"stepTrigger": "Trigger",
|
||||
"stepReview": "Review",
|
||||
"next": "Next",
|
||||
"back": "Back"
|
||||
},
|
||||
"backLabel": "Back to apps",
|
||||
"eyebrowSuffix": "NEW APP",
|
||||
"title": "Forge a new app",
|
||||
@@ -1198,6 +1230,7 @@
|
||||
"alertTag": "ERR",
|
||||
"fieldName": "Name",
|
||||
"fieldNameRequired": "REQUIRED",
|
||||
"fieldRequired": "Required",
|
||||
"fieldNamePlaceholder": "my-app",
|
||||
"fieldNameHint": "Lowercase, no spaces. Becomes part of container names and subdomains.",
|
||||
"fieldSourcePlugin": "Source plugin",
|
||||
@@ -1207,7 +1240,7 @@
|
||||
"fieldConfigYaml": "YAML",
|
||||
"fieldConfigForm": "FORM",
|
||||
"fieldConfigJson": "JSON",
|
||||
"advancedJson": "Advanced JSON",
|
||||
"advancedJson": "Edit as JSON",
|
||||
"backToForm": "Back to form",
|
||||
"resetSample": "Reset sample",
|
||||
"switchToJsonTitle": "Switch to the raw JSON editor",
|
||||
@@ -1230,11 +1263,21 @@
|
||||
"imageRegistryLabel": "Registry (for private pulls)",
|
||||
"imageRegistryPublic": "(public — no auth)",
|
||||
"imageRegistryHint": "Match the name from the Registries settings page. Leave empty for public images.",
|
||||
"imageCpu": "CPU limit (cores, 0 = ∞)",
|
||||
"imageMemory": "Memory limit (MB, 0 = ∞)",
|
||||
"imageCpu": "CPU limit",
|
||||
"imageCpuHint": "Cores, 0 = ∞",
|
||||
"imageMemory": "Memory limit",
|
||||
"imageMemoryHint": "MB, 0 = ∞",
|
||||
"imageMax": "Max instances",
|
||||
"imageMaxHint": "1 = strict blue-green.",
|
||||
"imageFoot": "Env vars and volume mounts live in their own panels on the workload detail page after creation.",
|
||||
"dockerfileHeader": "dockerfile source · build from a git repo",
|
||||
"dockerfileBuildEyebrow": "build · dockerfile",
|
||||
"dockerfileContextPath": "Build context",
|
||||
"dockerfileContextPathPlaceholder": "(empty = repo root)",
|
||||
"dockerfilePath": "Dockerfile path",
|
||||
"dockerfilePort": "Container port",
|
||||
"dockerfilePortRequired": "Container port is required — pick the port your app listens on (1–65535).",
|
||||
"dockerfileFoot": "Tinyforge clones the repo, builds the image from the Dockerfile, and runs one container. Env vars and volumes live in the detail page after creation.",
|
||||
"staticHeader": "static source · pages from a repo",
|
||||
"staticProvider": "Provider",
|
||||
"staticBaseUrl": "Base URL",
|
||||
@@ -1262,7 +1305,7 @@
|
||||
"staticTestConnection": "Test connection",
|
||||
"staticConnectionOk": "Connected",
|
||||
"staticConnectionFailed": "Connection failed: {error}",
|
||||
"staticBrowseRepos": "Browse repositories",
|
||||
"staticBrowseRepos": "Browse",
|
||||
"staticBrowseBranches": "Browse branches",
|
||||
"staticBrowseFolders": "Browse folders",
|
||||
"staticPickerRepoTitle": "Select repository",
|
||||
@@ -1275,6 +1318,7 @@
|
||||
"staticTreeEmpty": "No folders found in this branch.",
|
||||
"staticDenoAutoDetected": "Auto-detected an <code>api/</code> folder — switched to Deno mode.",
|
||||
"imageConflictTag": "IMAGE IN USE",
|
||||
"imageConflictChecking": "Checking for conflicts…",
|
||||
"imageConflictHeading": "{count} workload(s) already use this image:",
|
||||
"imageConflictOpenBtn": "Open",
|
||||
"imageConflictAcknowledgeNote": "If this is intentional (for example a separate stage), continue to create a new workload.",
|
||||
@@ -1300,21 +1344,23 @@
|
||||
"submit": "Forge app",
|
||||
"submitting": "Forging…",
|
||||
"submitAnyway": "Forge anyway",
|
||||
"unsavedChanges": "You have unsaved changes to this app. Leave without creating it?",
|
||||
"unsavedChangesTitle": "Unsaved changes",
|
||||
"unsavedChangesConfirm": "Leave",
|
||||
"errors": {
|
||||
"detectionFailed": "Provider detection failed.",
|
||||
"connectionFailed": "Connection failed.",
|
||||
"reposFailed": "Failed to load repositories.",
|
||||
"branchesFailed": "Failed to load branches.",
|
||||
"treeFailed": "Failed to load folder tree.",
|
||||
"detectionFailed": "Couldn't detect a Git provider at that URL. Check the base URL is correct and reachable.",
|
||||
"connectionFailed": "Couldn't reach the repository. Check the provider URL, owner/repo, and access token (for private repos).",
|
||||
"reposFailed": "Couldn't list repositories. Check the base URL and access token.",
|
||||
"branchesFailed": "Couldn't list branches. Check the repository and access token.",
|
||||
"treeFailed": "Couldn't load the folder tree. Check the repository, branch, and access token.",
|
||||
"sourceConfigInvalid": "Source config is not valid JSON.",
|
||||
"triggerBindUnknown": "unknown error",
|
||||
"createFailed": "Workload create failed.",
|
||||
"inspectFailed": "Image inspect failed."
|
||||
"inspectFailed": "Couldn't inspect that image — make sure it's pulled locally and the reference is correct."
|
||||
},
|
||||
"imageInspect": "Inspect",
|
||||
"imageInspectHint": "Pulls port + healthcheck from the image so you don't have to type them.",
|
||||
"imageInspectOk": "Inspected — port + healthcheck filled.",
|
||||
"imageInspectError": "Inspect failed: {error}",
|
||||
"triggers": {
|
||||
"section": "Trigger",
|
||||
"sectionSub": "Optional. Pick how this app gets a redeploy signal — registry watcher, git event, or manual button.",
|
||||
@@ -1334,6 +1380,18 @@
|
||||
"pickWebhookOn": "WEBHOOK ON",
|
||||
"skippedNote": "No trigger will be bound. You can add one from the app's Triggers panel after it's created.",
|
||||
"bindError": "App created, but the trigger binding failed: {error}. Open the app's Triggers panel to retry."
|
||||
},
|
||||
"manifest": {
|
||||
"title": "Manifest",
|
||||
"name": "Name",
|
||||
"source": "Source",
|
||||
"trigger": "Trigger",
|
||||
"publicFace": "Public face",
|
||||
"unnamed": "(unnamed)",
|
||||
"registryPublic": "public registry",
|
||||
"folderRoot": "root",
|
||||
"triggerManual": "Manual only",
|
||||
"internalOnly": "Internal only"
|
||||
}
|
||||
},
|
||||
"detail": {
|
||||
@@ -1365,6 +1423,40 @@
|
||||
"unavailable": "Usage probe unavailable (container may be stopped).",
|
||||
"loading": "Computing usage…"
|
||||
},
|
||||
"buildLog": {
|
||||
"title": "Build log",
|
||||
"sub": "Live tail of the Docker daemon's build output.",
|
||||
"clear": "Clear"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notification routes",
|
||||
"sub": "Multi-destination fan-out for deploy/build events. Falls back to the workload's legacy URL when empty.",
|
||||
"loading": "Loading routes…",
|
||||
"empty": "No per-workload notification routes configured. Add one to get a per-channel destination.",
|
||||
"addFirst": "Add first route",
|
||||
"add": "Add route",
|
||||
"edit": "Edit route",
|
||||
"delete": "Delete route",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "Slack #alerts",
|
||||
"url": "Webhook URL",
|
||||
"secret": "Signing secret",
|
||||
"secretPlaceholder": "Optional — receiver verifies HMAC if set",
|
||||
"secretEditPlaceholder": "Leave empty to keep the existing secret",
|
||||
"secretHint": "HMAC-SHA256 over the request body, sent as X-Hub-Signature-256.",
|
||||
"eventTypes": "Event types",
|
||||
"eventTypesPlaceholder": "deploy_failure,build_failure (empty = all)",
|
||||
"eventTypesHint": "Comma-separated allow-list. Empty means every event fires this route.",
|
||||
"enabled": "Enabled",
|
||||
"save": "Save route",
|
||||
"saving": "Saving…",
|
||||
"cancel": "Cancel",
|
||||
"allEvents": "all events",
|
||||
"signed": "signed",
|
||||
"disabled": "disabled",
|
||||
"confirmDeleteTitle": "Delete notification route?",
|
||||
"confirmDeleteMessage": "This route will stop firing immediately. The workload's legacy notification URL (if set) will resume catching events when no routes match."
|
||||
},
|
||||
"toolbar": {
|
||||
"stop": "Stop",
|
||||
"start": "Start",
|
||||
@@ -1455,6 +1547,19 @@
|
||||
"editStaticModeDenoDesc": "— Deno runtime with dynamic routing.",
|
||||
"editStaticRenderMarkdown": "Render markdown",
|
||||
"editStaticRenderMarkdownDesc": "— auto-render <code>.md</code> as HTML.",
|
||||
"editDockerfileHeader": "dockerfile source · build from a git repo",
|
||||
"editDockerfileBuildEyebrow": "build · dockerfile",
|
||||
"editDockerfileContextPath": "Build context",
|
||||
"editDockerfileContextPathPlaceholder": "(empty = repo root)",
|
||||
"editDockerfilePath": "Dockerfile path",
|
||||
"editDockerfilePort": "Container port",
|
||||
"editTestConnection": "Test connection",
|
||||
"editTestConnectionOk": "Connection OK",
|
||||
"editTestConnectionFailed": "Connection failed: {error}",
|
||||
"editTestConnectionUnknownError": "Unknown error",
|
||||
"overrideKeyUnitSingular": "KEY",
|
||||
"overrideKeyUnitPlural": "KEYS",
|
||||
"editTestConnectionIncomplete": "Fill provider, base URL, owner, and name first.",
|
||||
"editSourceJsonHeader": "source_config.json",
|
||||
"editSourceJsonAria": "Source plugin configuration (JSON)",
|
||||
"editPublicFaces": "Public faces",
|
||||
@@ -1494,6 +1599,29 @@
|
||||
"chainPromoteButton": "Promote from parent",
|
||||
"chainPromoting": "Promoting…",
|
||||
"chainHint": "Set <code>parent_workload_id</code> on a workload to build a chain. Image-source children can promote the parent's currently-running tag with one click.",
|
||||
"previews": {
|
||||
"title": "Preview environments",
|
||||
"subEmpty": "no active previews",
|
||||
"subCountOne": "1 active preview",
|
||||
"subCount": "{count} active previews",
|
||||
"tag": "Preview",
|
||||
"tagTitle": "Per-branch preview deploy of this workload",
|
||||
"armedEmpty": "No active previews — push to a branch matching",
|
||||
"noneEmpty": "No active previews yet.",
|
||||
"open": "Open",
|
||||
"noUrl": "no public URL",
|
||||
"teardown": "Tear down",
|
||||
"teardownTitle": "Tear down preview?",
|
||||
"teardownMessage": "This deletes the preview for branch \"{name}\" and removes its containers and proxy routes. Pushing to the branch again will recreate it.",
|
||||
"teardownConfirm": "Tear down",
|
||||
"teardownPending": "Tearing down…",
|
||||
"teardownFailed": "Teardown failed",
|
||||
"stateRunning": "Running",
|
||||
"statePending": "Pending",
|
||||
"stateStopped": "Stopped",
|
||||
"stateUnknown": "Unknown",
|
||||
"hint": "Previews are created automatically when a push lands on a branch matching a git trigger's <code>branch_pattern</code>, and torn down when the branch is deleted. Each gets its own slug-prefixed subdomain."
|
||||
},
|
||||
"volumesTitle": "Volumes",
|
||||
"volumesEmpty": "No mounts",
|
||||
"volumesCountSingular": "{count} mount",
|
||||
|
||||
@@ -53,7 +53,7 @@ function getNestedValue(obj: Record<string, unknown>, path: string): string {
|
||||
|
||||
/**
|
||||
* Derived store that returns a translation function.
|
||||
* Usage: $t('dashboard.title') or $t('projectDetail.deleteConfirmMessage', { name: 'my-app' })
|
||||
* Usage: $t('dashboard.title') or $t('settingsAuth.deleteConfirmMessage', { username: 'alice' })
|
||||
*/
|
||||
export const t = derived(locale, ($locale) => {
|
||||
const dict = translations[$locale] ?? translations.en;
|
||||
|
||||
+143
-15
@@ -15,7 +15,7 @@
|
||||
"nav": {
|
||||
"dashboard": "Панель",
|
||||
"apps": "Приложения",
|
||||
"eventTriggers": "Триггеры",
|
||||
"eventTriggers": "Триггеры событий",
|
||||
"logScanRules": "Лог-правила",
|
||||
"triggers": "Триггеры",
|
||||
"proxies": "Прокси",
|
||||
@@ -23,7 +23,13 @@
|
||||
"settings": "Настройки",
|
||||
"logout": "Выйти",
|
||||
"dns": "DNS-записи",
|
||||
"containers": "Контейнеры"
|
||||
"containers": "Контейнеры",
|
||||
"sectionObserve": "Наблюдение",
|
||||
"sectionSystem": "Система",
|
||||
"closeSidebar": "Закрыть боковую панель",
|
||||
"openSidebar": "Открыть боковую панель",
|
||||
"quickNavTitle": "Нажмите «g», затем букву для перехода между разделами",
|
||||
"quickNavLabel": "быстрая навигация"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Панель управления",
|
||||
@@ -42,7 +48,11 @@
|
||||
"systemHealth": "Состояние системы",
|
||||
"daemons": "Демоны",
|
||||
"systemResources": "Системные ресурсы",
|
||||
"systemResourcesSubtitle": "CPU, память, диск и топ потребителей"
|
||||
"systemResourcesSubtitle": "CPU, память, диск и топ потребителей",
|
||||
"statSubWorkloads": "нагрузки →",
|
||||
"statSubRunning": "запущено",
|
||||
"statSubNeedAttention": "требует внимания",
|
||||
"statSubStale": "устаревшие →"
|
||||
},
|
||||
"resources": {
|
||||
"cpuCores": "Ядра CPU",
|
||||
@@ -237,6 +247,7 @@
|
||||
"deleteFailed": "Не удалось удалить реестр",
|
||||
"testFailed": "Тест подключения не удался",
|
||||
"loadFailed": "Не удалось загрузить реестры",
|
||||
"deleteTitle": "Удалить реестр?",
|
||||
"deleteConfirm": "Удалить реестр «{name}»? Это действие необратимо.",
|
||||
"healthChecking": "Проверка...",
|
||||
"healthConnected": "Подключено",
|
||||
@@ -354,6 +365,7 @@
|
||||
"createFailed": "Не удалось создать пользователя",
|
||||
"deleteFailed": "Не удалось удалить пользователя",
|
||||
"deleteConfirm": "Вы уверены, что хотите удалить этого пользователя?",
|
||||
"deleteConfirmMessage": "Удалить пользователя «{username}»? Это действие необратимо.",
|
||||
"usernameRequired": "Имя пользователя и пароль обязательны",
|
||||
"networkError": "Ошибка сети",
|
||||
"password": "Пароль"
|
||||
@@ -400,6 +412,9 @@
|
||||
"common": {
|
||||
"cancel": "Отмена",
|
||||
"confirm": "Подтвердить",
|
||||
"close": "Закрыть",
|
||||
"toggle": "Переключить",
|
||||
"dismissNotification": "Закрыть уведомление",
|
||||
"delete": "Удалить",
|
||||
"edit": "Изменить",
|
||||
"change": "Изменить",
|
||||
@@ -429,6 +444,7 @@
|
||||
"missing": "Отсутствует"
|
||||
},
|
||||
"containers": {
|
||||
"eyebrowSuffix": "ГЛОБАЛЬНО",
|
||||
"errLoad": "Не удалось загрузить контейнеры",
|
||||
"searchPlaceholder": "Поиск по нагрузке, роли, образу, поддомену…",
|
||||
"kindFilterLabel": "Тип нагрузки",
|
||||
@@ -476,6 +492,7 @@
|
||||
},
|
||||
"stale": {
|
||||
"title": "Устаревшие контейнеры",
|
||||
"eyebrowSuffix": "УСТАРЕВШИЕ",
|
||||
"noStale": "Нет устаревших контейнеров",
|
||||
"noStaleDesc": "Все контейнеры исправны и работают.",
|
||||
"cleanup": "Очистить",
|
||||
@@ -541,13 +558,13 @@
|
||||
"unavailable": "Статистика недоступна"
|
||||
},
|
||||
"systemHealth": {
|
||||
"title": "Состояние системы",
|
||||
"containers": "Контейнеры",
|
||||
"proxies": "Прокси",
|
||||
"recentErrors": "Недавние ошибки"
|
||||
},
|
||||
"daemons": {
|
||||
"title": "Демоны",
|
||||
"notReachable": "{provider} недоступен.",
|
||||
"refresh": "Обновить",
|
||||
"refreshing": "Обновление",
|
||||
"docker": "Docker Engine",
|
||||
@@ -1110,6 +1127,10 @@
|
||||
"image": "Ссылка на образ",
|
||||
"imagePlaceholder": "registry.example.com/owner/app",
|
||||
"imageHint": "Полная ссылка на образ без тега — Tinyforge ловит новые теги, выкладываемые под этой ссылкой.",
|
||||
"browseImages": "Выбрать",
|
||||
"browseImagesHint": "Выберите образ из настроенного реестра вместо ручного ввода ссылки.",
|
||||
"browseImagesTitle": "Выбор образа",
|
||||
"browseImagesSearch": "Поиск образов…",
|
||||
"tagPattern": "Шаблон тега",
|
||||
"tagPatternPlaceholder": "*",
|
||||
"tagPatternHint": "Glob path.Match (например, v*, release-*). * совпадает с любым тегом.",
|
||||
@@ -1122,6 +1143,9 @@
|
||||
"branch": "Ветка",
|
||||
"branchPlaceholder": "main",
|
||||
"branchHint": "Только push'и, продвигающие эту ветку, дёргают триггер.",
|
||||
"branchPattern": "Шаблон ветки (preview-деплои)",
|
||||
"branchPatternPlaceholder": "feat/* или * для любой ветки",
|
||||
"branchPatternHint": "Если задан, любой push в подходящую ветку создаёт отдельный preview-деплой. Оставьте пустым, чтобы выключить.",
|
||||
"manualNote": "У ручных триггеров нет конфига. Они срабатывают только через кнопку Deploy на странице нагрузки или POST /workloads/{id}/deploy.",
|
||||
"scheduleNote": "Срабатывает по фиксированному интервалу, который ведёт внутренний планировщик Tinyforge. Внешний webhook не нужен — включите его ниже только если CI тоже должен запускать триггер вручную.",
|
||||
"intervalPresets": "Быстрые пресеты",
|
||||
@@ -1186,6 +1210,14 @@
|
||||
},
|
||||
"new": {
|
||||
"pageTitle": "Новое приложение · Tinyforge",
|
||||
"wizard": {
|
||||
"stepBasics": "Основное",
|
||||
"stepConfigure": "Настройка",
|
||||
"stepTrigger": "Триггер",
|
||||
"stepReview": "Обзор",
|
||||
"next": "Далее",
|
||||
"back": "Назад"
|
||||
},
|
||||
"backLabel": "К приложениям",
|
||||
"eyebrowSuffix": "НОВОЕ ПРИЛОЖЕНИЕ",
|
||||
"title": "Создать приложение",
|
||||
@@ -1198,6 +1230,7 @@
|
||||
"alertTag": "ОШ",
|
||||
"fieldName": "Имя",
|
||||
"fieldNameRequired": "ОБЯЗАТЕЛЬНО",
|
||||
"fieldRequired": "Обязательно",
|
||||
"fieldNamePlaceholder": "my-app",
|
||||
"fieldNameHint": "В нижнем регистре, без пробелов. Используется в именах контейнеров и поддоменах.",
|
||||
"fieldSourcePlugin": "Source-плагин",
|
||||
@@ -1207,7 +1240,7 @@
|
||||
"fieldConfigYaml": "YAML",
|
||||
"fieldConfigForm": "ФОРМА",
|
||||
"fieldConfigJson": "JSON",
|
||||
"advancedJson": "Расширенный JSON",
|
||||
"advancedJson": "Редактировать JSON",
|
||||
"backToForm": "К форме",
|
||||
"resetSample": "Сбросить к примеру",
|
||||
"switchToJsonTitle": "Переключиться на сырой JSON-редактор",
|
||||
@@ -1230,11 +1263,21 @@
|
||||
"imageRegistryLabel": "Реестр (для приватных pull-ов)",
|
||||
"imageRegistryPublic": "(публичный — без авторизации)",
|
||||
"imageRegistryHint": "Имя должно совпадать с записью на странице «Реестры». Оставьте пустым для публичных образов.",
|
||||
"imageCpu": "Лимит CPU (ядра, 0 = ∞)",
|
||||
"imageMemory": "Лимит памяти (МБ, 0 = ∞)",
|
||||
"imageCpu": "Лимит CPU",
|
||||
"imageCpuHint": "Ядра, 0 = ∞",
|
||||
"imageMemory": "Лимит памяти",
|
||||
"imageMemoryHint": "МБ, 0 = ∞",
|
||||
"imageMax": "Макс. инстансов",
|
||||
"imageMaxHint": "1 = строгий blue-green.",
|
||||
"imageFoot": "Переменные окружения и тома задаются в отдельных панелях на странице нагрузки после создания.",
|
||||
"dockerfileHeader": "dockerfile-источник · сборка из git-репозитория",
|
||||
"dockerfileBuildEyebrow": "сборка · dockerfile",
|
||||
"dockerfileContextPath": "Контекст сборки",
|
||||
"dockerfileContextPathPlaceholder": "(пусто = корень репо)",
|
||||
"dockerfilePath": "Путь к Dockerfile",
|
||||
"dockerfilePort": "Порт контейнера",
|
||||
"dockerfilePortRequired": "Укажите порт, который слушает приложение (1–65535).",
|
||||
"dockerfileFoot": "Tinyforge склонирует репо, соберёт образ из Dockerfile и запустит контейнер. Переменные окружения и тома — на странице нагрузки после создания.",
|
||||
"staticHeader": "static-источник · страницы из репозитория",
|
||||
"staticProvider": "Провайдер",
|
||||
"staticBaseUrl": "Base URL",
|
||||
@@ -1262,7 +1305,7 @@
|
||||
"staticTestConnection": "Проверить соединение",
|
||||
"staticConnectionOk": "Соединение установлено",
|
||||
"staticConnectionFailed": "Ошибка соединения: {error}",
|
||||
"staticBrowseRepos": "Выбрать репозиторий",
|
||||
"staticBrowseRepos": "Обзор",
|
||||
"staticBrowseBranches": "Выбрать ветку",
|
||||
"staticBrowseFolders": "Выбрать папку",
|
||||
"staticPickerRepoTitle": "Выбор репозитория",
|
||||
@@ -1275,6 +1318,7 @@
|
||||
"staticTreeEmpty": "В этой ветке нет папок.",
|
||||
"staticDenoAutoDetected": "Обнаружена папка <code>api/</code> — режим автоматически переключён на Deno.",
|
||||
"imageConflictTag": "ОБРАЗ УЖЕ ИСПОЛЬЗУЕТСЯ",
|
||||
"imageConflictChecking": "Проверка конфликтов…",
|
||||
"imageConflictHeading": "Этот образ уже используется в {count} нагрузке(ах):",
|
||||
"imageConflictOpenBtn": "Открыть",
|
||||
"imageConflictAcknowledgeNote": "Если это намеренно (например, отдельный этап), нажмите «Создать» ещё раз для продолжения.",
|
||||
@@ -1300,21 +1344,23 @@
|
||||
"submit": "Создать приложение",
|
||||
"submitting": "Создание…",
|
||||
"submitAnyway": "Всё равно создать",
|
||||
"unsavedChanges": "В этом приложении есть несохранённые изменения. Покинуть страницу, не создавая его?",
|
||||
"unsavedChangesTitle": "Несохранённые изменения",
|
||||
"unsavedChangesConfirm": "Покинуть",
|
||||
"errors": {
|
||||
"detectionFailed": "Не удалось определить провайдера.",
|
||||
"connectionFailed": "Ошибка соединения.",
|
||||
"reposFailed": "Не удалось загрузить репозитории.",
|
||||
"branchesFailed": "Не удалось загрузить ветки.",
|
||||
"treeFailed": "Не удалось загрузить дерево папок.",
|
||||
"detectionFailed": "Не удалось определить Git-провайдера по этому URL. Проверьте, что базовый URL верен и доступен.",
|
||||
"connectionFailed": "Не удалось подключиться к репозиторию. Проверьте URL провайдера, владельца/репозиторий и токен доступа (для приватных репозиториев).",
|
||||
"reposFailed": "Не удалось получить список репозиториев. Проверьте базовый URL и токен доступа.",
|
||||
"branchesFailed": "Не удалось получить список веток. Проверьте репозиторий и токен доступа.",
|
||||
"treeFailed": "Не удалось загрузить дерево папок. Проверьте репозиторий, ветку и токен доступа.",
|
||||
"sourceConfigInvalid": "source_config не является корректным JSON.",
|
||||
"triggerBindUnknown": "неизвестная ошибка",
|
||||
"createFailed": "Не удалось создать нагрузку.",
|
||||
"inspectFailed": "Не удалось проинспектировать образ."
|
||||
"inspectFailed": "Не удалось проинспектировать образ — убедитесь, что он скачан локально и ссылка указана верно."
|
||||
},
|
||||
"imageInspect": "Инспектировать",
|
||||
"imageInspectHint": "Подставляет порт и healthcheck из образа, чтобы не вводить вручную.",
|
||||
"imageInspectOk": "Готово — порт и healthcheck подставлены.",
|
||||
"imageInspectError": "Ошибка инспекции: {error}",
|
||||
"triggers": {
|
||||
"section": "Триггер",
|
||||
"sectionSub": "Необязательно. Выберите, откуда придёт сигнал передеплоя — слежение за реестром, git-событие или ручная кнопка.",
|
||||
@@ -1334,6 +1380,18 @@
|
||||
"pickWebhookOn": "ВЕБХУК ВКЛ",
|
||||
"skippedNote": "Триггер не будет привязан. Добавьте его из панели «Триггеры» в карточке приложения после создания.",
|
||||
"bindError": "Приложение создано, но привязка триггера не удалась: {error}. Откройте панель «Триггеры» в карточке, чтобы повторить."
|
||||
},
|
||||
"manifest": {
|
||||
"title": "Манифест",
|
||||
"name": "Имя",
|
||||
"source": "Источник",
|
||||
"trigger": "Триггер",
|
||||
"publicFace": "Публичный фронт",
|
||||
"unnamed": "(без имени)",
|
||||
"registryPublic": "публичный реестр",
|
||||
"folderRoot": "корень",
|
||||
"triggerManual": "Только вручную",
|
||||
"internalOnly": "Только внутренний"
|
||||
}
|
||||
},
|
||||
"detail": {
|
||||
@@ -1365,6 +1423,40 @@
|
||||
"unavailable": "Не удалось получить размер (контейнер мог быть остановлен).",
|
||||
"loading": "Вычисление размера…"
|
||||
},
|
||||
"buildLog": {
|
||||
"title": "Журнал сборки",
|
||||
"sub": "Живой поток вывода сборки Docker.",
|
||||
"clear": "Очистить"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Маршруты уведомлений",
|
||||
"sub": "Множественные точки доставки для событий деплоя/сборки. При пустом списке используется устаревший единственный URL.",
|
||||
"loading": "Загрузка маршрутов…",
|
||||
"empty": "Нет настроенных маршрутов уведомлений. Добавьте, чтобы получать события в отдельный канал.",
|
||||
"addFirst": "Добавить первый маршрут",
|
||||
"add": "Добавить маршрут",
|
||||
"edit": "Изменить",
|
||||
"delete": "Удалить",
|
||||
"name": "Имя",
|
||||
"namePlaceholder": "Slack #alerts",
|
||||
"url": "URL вебхука",
|
||||
"secret": "Секрет подписи",
|
||||
"secretPlaceholder": "Опционально — приёмник проверяет HMAC",
|
||||
"secretEditPlaceholder": "Оставьте пустым, чтобы сохранить текущий секрет",
|
||||
"secretHint": "HMAC-SHA256 от тела запроса, заголовок X-Hub-Signature-256.",
|
||||
"eventTypes": "Типы событий",
|
||||
"eventTypesPlaceholder": "deploy_failure,build_failure (пусто = все)",
|
||||
"eventTypesHint": "Список через запятую. Пусто — маршрут срабатывает на любое событие.",
|
||||
"enabled": "Включён",
|
||||
"save": "Сохранить",
|
||||
"saving": "Сохранение…",
|
||||
"cancel": "Отмена",
|
||||
"allEvents": "все события",
|
||||
"signed": "подписан",
|
||||
"disabled": "выключен",
|
||||
"confirmDeleteTitle": "Удалить маршрут уведомлений?",
|
||||
"confirmDeleteMessage": "Маршрут перестанет срабатывать. Устаревший URL уведомлений на workload (если задан) снова возьмёт события на себя."
|
||||
},
|
||||
"toolbar": {
|
||||
"stop": "Стоп",
|
||||
"start": "Старт",
|
||||
@@ -1455,6 +1547,19 @@
|
||||
"editStaticModeDenoDesc": "— Deno-рантайм с динамической маршрутизацией.",
|
||||
"editStaticRenderMarkdown": "Рендерить markdown",
|
||||
"editStaticRenderMarkdownDesc": "— автоматически отдавать <code>.md</code> как HTML.",
|
||||
"editDockerfileHeader": "dockerfile-источник · сборка из git-репозитория",
|
||||
"editDockerfileBuildEyebrow": "сборка · dockerfile",
|
||||
"editDockerfileContextPath": "Контекст сборки",
|
||||
"editDockerfileContextPathPlaceholder": "(пусто = корень репо)",
|
||||
"editDockerfilePath": "Путь к Dockerfile",
|
||||
"editDockerfilePort": "Порт контейнера",
|
||||
"editTestConnection": "Проверить соединение",
|
||||
"editTestConnectionOk": "Соединение установлено",
|
||||
"editTestConnectionFailed": "Ошибка соединения: {error}",
|
||||
"editTestConnectionUnknownError": "Неизвестная ошибка",
|
||||
"overrideKeyUnitSingular": "КЛЮЧ",
|
||||
"overrideKeyUnitPlural": "КЛЮЧИ",
|
||||
"editTestConnectionIncomplete": "Заполните провайдера, base URL, owner и name.",
|
||||
"editSourceJsonHeader": "source_config.json",
|
||||
"editSourceJsonAria": "Конфигурация source-плагина (JSON)",
|
||||
"editPublicFaces": "Публичные фронты",
|
||||
@@ -1494,6 +1599,29 @@
|
||||
"chainPromoteButton": "Продвинуть от родителя",
|
||||
"chainPromoting": "Продвижение…",
|
||||
"chainHint": "Задайте <code>parent_workload_id</code> у нагрузки, чтобы построить цепочку. Дочерние image-источники могут одним кликом продвинуть текущий запущенный тег родителя.",
|
||||
"previews": {
|
||||
"title": "Превью-окружения",
|
||||
"subEmpty": "нет активных превью",
|
||||
"subCountOne": "1 активное превью",
|
||||
"subCount": "активных превью: {count}",
|
||||
"tag": "Превью",
|
||||
"tagTitle": "Превью-развёртывание этой нагрузки для отдельной ветки",
|
||||
"armedEmpty": "Нет активных превью — отправьте пуш в ветку, соответствующую шаблону",
|
||||
"noneEmpty": "Пока нет активных превью.",
|
||||
"open": "Открыть",
|
||||
"noUrl": "нет публичного URL",
|
||||
"teardown": "Удалить",
|
||||
"teardownTitle": "Удалить превью?",
|
||||
"teardownMessage": "Это удалит превью для ветки «{name}» вместе с его контейнерами и маршрутами прокси. Новый пуш в эту ветку создаст его заново.",
|
||||
"teardownConfirm": "Удалить",
|
||||
"teardownPending": "Удаление…",
|
||||
"teardownFailed": "Не удалось удалить",
|
||||
"stateRunning": "Работает",
|
||||
"statePending": "Запускается",
|
||||
"stateStopped": "Остановлено",
|
||||
"stateUnknown": "Неизвестно",
|
||||
"hint": "Превью создаются автоматически, когда пуш приходит в ветку, соответствующую <code>branch_pattern</code> git-триггера, и удаляются при удалении ветки. Каждое получает собственный поддомен с префиксом-слагом."
|
||||
},
|
||||
"volumesTitle": "Тома",
|
||||
"volumesEmpty": "Нет монтирований",
|
||||
"volumesCountSingular": "{count} монтирование",
|
||||
|
||||
+37
-3
@@ -9,7 +9,7 @@ import { getAuthToken } from './auth';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────
|
||||
|
||||
export type SSEEventType = 'deploy_log' | 'instance_status' | 'deploy_status' | 'event_log';
|
||||
export type SSEEventType = 'deploy_log' | 'instance_status' | 'deploy_status' | 'event_log' | 'build_log';
|
||||
|
||||
export interface SSEEvent<T = unknown> {
|
||||
type: SSEEventType;
|
||||
@@ -47,7 +47,18 @@ export interface EventLogSSEPayload {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
type SSEPayload = DeployLogPayload | InstanceStatusPayload | DeployStatusPayload | EventLogSSEPayload;
|
||||
export interface BuildLogPayload {
|
||||
workload_id: string;
|
||||
line: string;
|
||||
stream?: string;
|
||||
}
|
||||
|
||||
type SSEPayload =
|
||||
| DeployLogPayload
|
||||
| InstanceStatusPayload
|
||||
| DeployStatusPayload
|
||||
| EventLogSSEPayload
|
||||
| BuildLogPayload;
|
||||
|
||||
export interface SSEOptions {
|
||||
/** Called for each SSE event received. */
|
||||
@@ -123,6 +134,16 @@ export function connectSSE(url: string, options: SSEOptions): SSEConnection {
|
||||
|
||||
if (closed) return;
|
||||
|
||||
// Defensive clear: onerror can fire multiple times in quick
|
||||
// succession during a network flap, each call would otherwise
|
||||
// queue an additional reconnect and abandon the prior
|
||||
// EventSource without closing it. Cancel any pending retry
|
||||
// before scheduling a fresh one.
|
||||
if (retryTimeout !== null) {
|
||||
clearTimeout(retryTimeout);
|
||||
retryTimeout = null;
|
||||
}
|
||||
|
||||
retryCount++;
|
||||
onError?.(retryCount);
|
||||
|
||||
@@ -168,10 +189,21 @@ export function connectGlobalEvents(callbacks: {
|
||||
onInstanceStatus?: (payload: InstanceStatusPayload) => void;
|
||||
onDeployStatus?: (payload: DeployStatusPayload) => void;
|
||||
onEventLog?: (payload: EventLogSSEPayload) => void;
|
||||
onBuildLog?: (payload: BuildLogPayload) => void;
|
||||
onOpen?: () => void;
|
||||
onError?: (attempt: number) => void;
|
||||
/**
|
||||
* Opt in to build-log frames for a single workload. Build logs are
|
||||
* high-volume; the server only streams them to connections that pass
|
||||
* this, so a verbose build can't flood every dashboard connection.
|
||||
* Omit it on connections that don't render build output.
|
||||
*/
|
||||
buildLogWorkloadId?: string;
|
||||
}): SSEConnection {
|
||||
return connectSSE('/api/events', {
|
||||
const url = callbacks.buildLogWorkloadId
|
||||
? `/api/events?workload_id=${encodeURIComponent(callbacks.buildLogWorkloadId)}`
|
||||
: '/api/events';
|
||||
return connectSSE(url, {
|
||||
onEvent(event) {
|
||||
if (event.type === 'instance_status') {
|
||||
callbacks.onInstanceStatus?.(event.payload as InstanceStatusPayload);
|
||||
@@ -179,6 +211,8 @@ export function connectGlobalEvents(callbacks: {
|
||||
callbacks.onDeployStatus?.(event.payload as DeployStatusPayload);
|
||||
} else if (event.type === 'event_log') {
|
||||
callbacks.onEventLog?.(event.payload as EventLogSSEPayload);
|
||||
} else if (event.type === 'build_log') {
|
||||
callbacks.onBuildLog?.(event.payload as BuildLogPayload);
|
||||
}
|
||||
},
|
||||
onOpen: callbacks.onOpen,
|
||||
|
||||
@@ -5,17 +5,32 @@
|
||||
───────────────────────────────────────────────────────────────────── */
|
||||
|
||||
:root {
|
||||
/* ── Brand Colors ───────────────────────────────────── */
|
||||
--color-brand-50: #eef2ff;
|
||||
--color-brand-100: #e0e7ff;
|
||||
--color-brand-200: #c7d2fe;
|
||||
--color-brand-300: #a5b4fc;
|
||||
--color-brand-400: #818cf8;
|
||||
--color-brand-500: #6366f1;
|
||||
--color-brand-600: #4f46e5;
|
||||
--color-brand-700: #4338ca;
|
||||
--color-brand-800: #3730a3;
|
||||
--color-brand-900: #312e81;
|
||||
/* ── Brand Colors ─────────────────────────────────────
|
||||
"Forge" identity: amber / ember hues matching the
|
||||
industrial control-room aesthetic. Indigo was a poor
|
||||
fit for a product called Tinyforge — these tokens are
|
||||
amber 50-900 with a slight orange shift on the 500-700
|
||||
range so the active accent reads as molten metal rather
|
||||
than playful pastel. */
|
||||
--color-brand-50: #fff8eb;
|
||||
--color-brand-100: #fdebcb;
|
||||
--color-brand-200: #fbd591;
|
||||
--color-brand-300: #f8b955;
|
||||
--color-brand-400: #f59e0b;
|
||||
--color-brand-500: #d97706;
|
||||
--color-brand-600: #b45309;
|
||||
--color-brand-700: #92400e;
|
||||
--color-brand-800: #78350f;
|
||||
--color-brand-900: #451a03;
|
||||
|
||||
/* Forge ember accent — used directly by forge-ember CSS
|
||||
class and the highlight ring on hover states. Distinct
|
||||
token so a future rebrand can shift the accent without
|
||||
re-touching every consumer of --color-brand-*. */
|
||||
--forge-ember: #ea580c;
|
||||
--forge-ember-deep: #c2410c;
|
||||
--forge-anvil: #1c1917;
|
||||
--forge-spark: #fed7aa;
|
||||
|
||||
/* ── Semantic Colors ────────────────────────────────── */
|
||||
--color-success: #16a34a;
|
||||
@@ -45,10 +60,15 @@
|
||||
--border-focus: var(--color-brand-500);
|
||||
--border-input: #cbd5e1;
|
||||
|
||||
/* ── Text Colors ────────────────────────────────────── */
|
||||
/* ── Text Colors ──────────────────────────────────────
|
||||
Tertiary darkened from #94a3b8 (3.4:1 on #f8fafc — fails
|
||||
WCAG AA) to #64748b (4.6:1 — AA-compliant). The old hue
|
||||
is kept as --text-tertiary-soft for non-text decorations
|
||||
(rule dots, separators) where contrast is not a concern. */
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #475569;
|
||||
--text-tertiary: #94a3b8;
|
||||
--text-tertiary: #64748b;
|
||||
--text-tertiary-soft: #94a3b8;
|
||||
--text-inverse: #ffffff;
|
||||
--text-link: var(--color-brand-600);
|
||||
--text-link-hover: var(--color-brand-700);
|
||||
@@ -66,9 +86,15 @@
|
||||
--space-12: 3rem; /* 48px */
|
||||
--space-16: 4rem; /* 64px */
|
||||
|
||||
/* ── Typography Scale ───────────────────────────────── */
|
||||
--font-family-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
--font-family-mono: 'JetBrains Mono', ui-monospace, 'Cascadia Code', monospace;
|
||||
/* ── Typography Scale ─────────────────────────────────
|
||||
System UI stack as the default: matches the OS, costs
|
||||
zero bytes, and reads as a tool rather than a marketing
|
||||
site. Inter remains as a fallback hint for installs that
|
||||
have it (downloaded for an earlier theme) but is no
|
||||
longer first-class. Monospace stays JetBrains Mono for
|
||||
the code feel — operators read a lot of SHAs. */
|
||||
--font-family-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Inter', 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-family-mono: ui-monospace, 'JetBrains Mono', SFMono-Regular, 'Cascadia Code', Menlo, Consolas, monospace;
|
||||
|
||||
--text-xs: 0.75rem; /* 12px */
|
||||
--text-sm: 0.875rem; /* 14px */
|
||||
@@ -128,8 +154,12 @@
|
||||
--border-input: #475569;
|
||||
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-tertiary: #64748b;
|
||||
/* Dark mode: secondary darkened to #94a3b8 (4.7:1 on #1e293b),
|
||||
tertiary held at #94a3b8 too — same hue but used on darker
|
||||
surfaces. The legacy #64748b on #1e293b was 3.2:1, failing AA. */
|
||||
--text-secondary: #cbd5e1;
|
||||
--text-tertiary: #94a3b8;
|
||||
--text-tertiary-soft: #64748b;
|
||||
--text-inverse: #0f172a;
|
||||
--text-link: var(--color-brand-400);
|
||||
--text-link-hover: var(--color-brand-300);
|
||||
|
||||
+14
-4
@@ -111,19 +111,29 @@ export interface ApiEnvelope<T> {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Response shape for POST /api/deploy/inspect */
|
||||
/** Response shape for POST /api/discovery/image/inspect */
|
||||
export interface InspectResult {
|
||||
image: string;
|
||||
port: number;
|
||||
healthcheck: string;
|
||||
}
|
||||
|
||||
/** Item for the EntityPicker command-palette component. */
|
||||
/**
|
||||
* Item for the EntityPicker command-palette component.
|
||||
*
|
||||
* `icon` was historically typed as `string` and rendered via `{@html}` —
|
||||
* which made it a potential stored-XSS sink the moment a caller built the
|
||||
* value from any non-literal data. Narrowed to a controlled token union
|
||||
* so every supported glyph is rendered through a known SVG path. Adding a
|
||||
* new glyph requires a code change here AND a render-branch in
|
||||
* EntityPicker.svelte — keep them in sync.
|
||||
*/
|
||||
export type EntityPickerIcon = 'lock' | 'box' | 'folder' | 'branch';
|
||||
|
||||
export interface EntityPickerItem {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
icon?: EntityPickerIcon;
|
||||
group?: string;
|
||||
disabled?: boolean;
|
||||
disabledHint?: string;
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
emptyImageState,
|
||||
emptyComposeState,
|
||||
emptyStaticState,
|
||||
emptyDockerfileState,
|
||||
seedImageState,
|
||||
seedComposeState,
|
||||
seedStaticState,
|
||||
seedDockerfileState,
|
||||
imageToConfig,
|
||||
composeToConfig,
|
||||
staticToConfig,
|
||||
dockerfileToConfig,
|
||||
stringifyConfig,
|
||||
isImageValid,
|
||||
isComposeValid,
|
||||
isStaticValid,
|
||||
isDockerfileValid
|
||||
} from './sourceForms';
|
||||
|
||||
describe('image source', () => {
|
||||
it('seeds defaults from empty/malformed JSON', () => {
|
||||
expect(seedImageState('{}')).toEqual(emptyImageState());
|
||||
expect(seedImageState('not json')).toEqual(emptyImageState());
|
||||
expect(seedImageState('[]')).toEqual(emptyImageState());
|
||||
expect(seedImageState('42')).toEqual(emptyImageState());
|
||||
});
|
||||
|
||||
it('seeds populated fields', () => {
|
||||
const json = JSON.stringify({
|
||||
image: 'nginx',
|
||||
port: 8080,
|
||||
healthcheck: '/healthz',
|
||||
default_tag: 'stable',
|
||||
registry_name: 'docker.io',
|
||||
cpu_limit: 2,
|
||||
memory_limit: 512,
|
||||
max_instances: 3
|
||||
});
|
||||
expect(seedImageState(json)).toEqual({
|
||||
ref: 'nginx',
|
||||
port: 8080,
|
||||
healthcheck: '/healthz',
|
||||
defaultTag: 'stable',
|
||||
registryName: 'docker.io',
|
||||
cpuLimit: 2,
|
||||
memoryLimit: 512,
|
||||
maxInstances: 3
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes to the exact source_config shape and key order', () => {
|
||||
const config = imageToConfig(emptyImageState(), '{}');
|
||||
expect(Object.keys(config)).toEqual([
|
||||
'image',
|
||||
'registry_name',
|
||||
'port',
|
||||
'healthcheck',
|
||||
'env',
|
||||
'volumes',
|
||||
'cpu_limit',
|
||||
'memory_limit',
|
||||
'default_tag',
|
||||
'max_instances'
|
||||
]);
|
||||
expect(config).toEqual({
|
||||
image: '',
|
||||
registry_name: '',
|
||||
port: 0,
|
||||
healthcheck: '',
|
||||
env: {},
|
||||
volumes: [],
|
||||
cpu_limit: 0,
|
||||
memory_limit: 0,
|
||||
default_tag: 'latest',
|
||||
max_instances: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves env and volumes from the existing config', () => {
|
||||
const existing = JSON.stringify({
|
||||
image: 'old',
|
||||
env: { FOO: 'bar' },
|
||||
volumes: [{ source: 'data', scope: 'named' }]
|
||||
});
|
||||
const config = imageToConfig({ ...emptyImageState(), ref: 'new' }, existing);
|
||||
expect(config.env).toEqual({ FOO: 'bar' });
|
||||
expect(config.volumes).toEqual([{ source: 'data', scope: 'named' }]);
|
||||
expect(config.image).toBe('new');
|
||||
});
|
||||
|
||||
it('round-trips state -> config -> state', () => {
|
||||
const state = seedImageState(
|
||||
JSON.stringify({ image: 'app', port: 3000, default_tag: 'v1', max_instances: 2 })
|
||||
);
|
||||
expect(seedImageState(stringifyConfig(imageToConfig(state, '{}')))).toEqual(state);
|
||||
});
|
||||
|
||||
it('validity requires a non-empty image ref', () => {
|
||||
expect(isImageValid(emptyImageState())).toBe(false);
|
||||
expect(isImageValid({ ...emptyImageState(), ref: ' ' })).toBe(false);
|
||||
expect(isImageValid({ ...emptyImageState(), ref: 'nginx' })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compose source', () => {
|
||||
it('seeds defaults and populated fields', () => {
|
||||
expect(seedComposeState('{}')).toEqual(emptyComposeState());
|
||||
expect(
|
||||
seedComposeState(JSON.stringify({ compose_yaml: 'services: {}', compose_project_name: 'app' }))
|
||||
).toEqual({ yaml: 'services: {}', projectName: 'app' });
|
||||
});
|
||||
|
||||
it('serializes to the exact shape', () => {
|
||||
const config = composeToConfig({ yaml: 'x', projectName: 'p' });
|
||||
expect(Object.keys(config)).toEqual(['compose_yaml', 'compose_project_name']);
|
||||
expect(config).toEqual({ compose_yaml: 'x', compose_project_name: 'p' });
|
||||
});
|
||||
|
||||
it('validity requires non-empty yaml', () => {
|
||||
expect(isComposeValid(emptyComposeState())).toBe(false);
|
||||
expect(isComposeValid({ yaml: 'services: {}', projectName: '' })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('static source', () => {
|
||||
it('seeds defaults, normalizing provider and branch', () => {
|
||||
expect(seedStaticState('{}')).toEqual(emptyStaticState());
|
||||
// unknown provider -> gitea; empty branch -> main
|
||||
expect(seedStaticState(JSON.stringify({ provider: 'bogus', branch: '' }))).toEqual(
|
||||
emptyStaticState()
|
||||
);
|
||||
expect(seedStaticState(JSON.stringify({ provider: 'github' })).provider).toBe('github');
|
||||
expect(seedStaticState(JSON.stringify({ mode: 'deno' })).mode).toBe('deno');
|
||||
});
|
||||
|
||||
it('serializes to the exact shape and key order', () => {
|
||||
const config = staticToConfig(emptyStaticState(), '{}');
|
||||
expect(Object.keys(config)).toEqual([
|
||||
'provider',
|
||||
'base_url',
|
||||
'repo_owner',
|
||||
'repo_name',
|
||||
'branch',
|
||||
'folder_path',
|
||||
'access_token',
|
||||
'mode',
|
||||
'render_markdown'
|
||||
]);
|
||||
expect(config.branch).toBe('main');
|
||||
});
|
||||
|
||||
it('preserves storage_* keys only when present', () => {
|
||||
const withStorage = staticToConfig(
|
||||
emptyStaticState(),
|
||||
JSON.stringify({ storage_enabled: true, storage_limit_mb: 100 })
|
||||
);
|
||||
expect(withStorage.storage_enabled).toBe(true);
|
||||
expect(withStorage.storage_limit_mb).toBe(100);
|
||||
|
||||
const without = staticToConfig(emptyStaticState(), '{}');
|
||||
expect('storage_enabled' in without).toBe(false);
|
||||
expect('storage_limit_mb' in without).toBe(false);
|
||||
});
|
||||
|
||||
it('round-trips a populated state', () => {
|
||||
const state = seedStaticState(
|
||||
JSON.stringify({
|
||||
provider: 'gitlab',
|
||||
base_url: 'https://gl.example',
|
||||
repo_owner: 'me',
|
||||
repo_name: 'site',
|
||||
branch: 'dev',
|
||||
folder_path: 'public',
|
||||
access_token: 'secret',
|
||||
mode: 'deno',
|
||||
render_markdown: true
|
||||
})
|
||||
);
|
||||
expect(seedStaticState(stringifyConfig(staticToConfig(state, '{}')))).toEqual(state);
|
||||
});
|
||||
|
||||
it('validity requires base_url + owner + repo', () => {
|
||||
expect(isStaticValid(emptyStaticState())).toBe(false);
|
||||
expect(
|
||||
isStaticValid({
|
||||
...emptyStaticState(),
|
||||
baseURL: 'https://x',
|
||||
repoOwner: 'o',
|
||||
repoName: 'r'
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dockerfile source', () => {
|
||||
it('seeds defaults, defaulting dockerfile_path to Dockerfile', () => {
|
||||
expect(seedDockerfileState('{}')).toEqual(emptyDockerfileState());
|
||||
expect(seedDockerfileState(JSON.stringify({ dockerfile_path: '' })).dockerfilePath).toBe(
|
||||
'Dockerfile'
|
||||
);
|
||||
expect(seedDockerfileState(JSON.stringify({ dockerfile_path: 'docker/Dockerfile' })).dockerfilePath).toBe(
|
||||
'docker/Dockerfile'
|
||||
);
|
||||
});
|
||||
|
||||
it('serializes to the exact shape and key order', () => {
|
||||
const config = dockerfileToConfig(emptyDockerfileState(), '{}');
|
||||
expect(Object.keys(config)).toEqual([
|
||||
'provider',
|
||||
'base_url',
|
||||
'repo_owner',
|
||||
'repo_name',
|
||||
'branch',
|
||||
'access_token',
|
||||
'context_path',
|
||||
'dockerfile_path',
|
||||
'port'
|
||||
]);
|
||||
expect(config.dockerfile_path).toBe('Dockerfile');
|
||||
expect(config.branch).toBe('main');
|
||||
expect(config.port).toBe(0);
|
||||
});
|
||||
|
||||
it('preserves unknown keys but scrubs static-only keys', () => {
|
||||
const existing = JSON.stringify({
|
||||
// unknown key the operator added via raw JSON -> preserved
|
||||
healthcheck: '/up',
|
||||
cpu_limit: 1,
|
||||
// static-only leftovers from a static->dockerfile switch -> scrubbed
|
||||
folder_path: 'public',
|
||||
mode: 'deno',
|
||||
render_markdown: true,
|
||||
storage_enabled: true,
|
||||
storage_limit_mb: 50
|
||||
});
|
||||
const config = dockerfileToConfig(emptyDockerfileState(), existing);
|
||||
expect(config.healthcheck).toBe('/up');
|
||||
expect(config.cpu_limit).toBe(1);
|
||||
expect('folder_path' in config).toBe(false);
|
||||
expect('mode' in config).toBe(false);
|
||||
expect('render_markdown' in config).toBe(false);
|
||||
expect('storage_enabled' in config).toBe(false);
|
||||
expect('storage_limit_mb' in config).toBe(false);
|
||||
});
|
||||
|
||||
it('round-trips a populated state', () => {
|
||||
const state = seedDockerfileState(
|
||||
JSON.stringify({
|
||||
provider: 'github',
|
||||
base_url: 'https://gh.example',
|
||||
repo_owner: 'me',
|
||||
repo_name: 'svc',
|
||||
branch: 'main',
|
||||
access_token: 't',
|
||||
context_path: 'backend',
|
||||
dockerfile_path: 'backend/Dockerfile',
|
||||
port: 8000
|
||||
})
|
||||
);
|
||||
expect(seedDockerfileState(stringifyConfig(dockerfileToConfig(state, '{}')))).toEqual(state);
|
||||
});
|
||||
|
||||
it('validity requires git fields + a positive port', () => {
|
||||
const base = {
|
||||
...emptyDockerfileState(),
|
||||
baseURL: 'https://x',
|
||||
repoOwner: 'o',
|
||||
repoName: 'r'
|
||||
};
|
||||
expect(isDockerfileValid(base)).toBe(false); // port 0
|
||||
expect(isDockerfileValid({ ...base, port: 8080 })).toBe(true);
|
||||
expect(isDockerfileValid({ ...base, port: -1 })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* Shared source-config form model for the four workload Source kinds
|
||||
* (image / compose / static / dockerfile).
|
||||
*
|
||||
* Before this module the seed (JSON -> form fields) and serialize
|
||||
* (form fields -> source_config JSON) logic lived inline and DUPLICATED
|
||||
* verbatim in both `routes/apps/new/+page.svelte` and
|
||||
* `routes/apps/[id]/+page.svelte`. A drift between the two silently
|
||||
* changes the `source_config` shape the backend stores, which breaks
|
||||
* deploys. This module is the single source of truth so the create
|
||||
* wizard and the detail-page edit form serialize identically.
|
||||
*
|
||||
* The functions are pure (no Svelte runes, no DOM) so they unit-test in
|
||||
* a plain node environment. Components hold the state objects as `$state`
|
||||
* and call these to seed / serialize.
|
||||
*
|
||||
* Fidelity contract: output key order, defaults, and the preserve/scrub
|
||||
* behaviour below MUST match the legacy inline helpers exactly. Tests in
|
||||
* `sourceForms.test.ts` lock the shapes.
|
||||
*/
|
||||
|
||||
export type GitProvider = 'gitea' | 'github' | 'gitlab';
|
||||
|
||||
/** Image source: deploy a pre-built image from a registry. */
|
||||
export interface ImageFormState {
|
||||
ref: string;
|
||||
port: number;
|
||||
healthcheck: string;
|
||||
defaultTag: string;
|
||||
registryName: string;
|
||||
cpuLimit: number;
|
||||
memoryLimit: number;
|
||||
maxInstances: number;
|
||||
}
|
||||
|
||||
/** Compose source: a docker-compose stack. */
|
||||
export interface ComposeFormState {
|
||||
yaml: string;
|
||||
projectName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Git-discovery fields shared by the static and dockerfile sources —
|
||||
* both clone a repo via the same provider/owner/repo/branch/token path.
|
||||
* Extracted so a single discovery component can bind this slice of
|
||||
* either form.
|
||||
*/
|
||||
export interface GitSourceState {
|
||||
provider: GitProvider;
|
||||
baseURL: string;
|
||||
repoOwner: string;
|
||||
repoName: string;
|
||||
branch: string;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
/** Static source: serve files (optionally Deno) from a repo folder. */
|
||||
export interface StaticFormState extends GitSourceState {
|
||||
folderPath: string;
|
||||
mode: 'static' | 'deno';
|
||||
renderMarkdown: boolean;
|
||||
}
|
||||
|
||||
/** Dockerfile source: build an image from a Dockerfile in a repo. */
|
||||
export interface DockerfileFormState extends GitSourceState {
|
||||
contextPath: string;
|
||||
dockerfilePath: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
// ── Defaults ────────────────────────────────────────────────────────
|
||||
|
||||
export function emptyImageState(): ImageFormState {
|
||||
return {
|
||||
ref: '',
|
||||
port: 0,
|
||||
healthcheck: '',
|
||||
defaultTag: 'latest',
|
||||
registryName: '',
|
||||
cpuLimit: 0,
|
||||
memoryLimit: 0,
|
||||
maxInstances: 1
|
||||
};
|
||||
}
|
||||
|
||||
export function emptyComposeState(): ComposeFormState {
|
||||
return { yaml: '', projectName: '' };
|
||||
}
|
||||
|
||||
function emptyGitSourceState(): GitSourceState {
|
||||
return {
|
||||
provider: 'gitea',
|
||||
baseURL: '',
|
||||
repoOwner: '',
|
||||
repoName: '',
|
||||
branch: 'main',
|
||||
accessToken: ''
|
||||
};
|
||||
}
|
||||
|
||||
export function emptyStaticState(): StaticFormState {
|
||||
return { ...emptyGitSourceState(), folderPath: '', mode: 'static', renderMarkdown: false };
|
||||
}
|
||||
|
||||
export function emptyDockerfileState(): DockerfileFormState {
|
||||
return { ...emptyGitSourceState(), contextPath: '', dockerfilePath: 'Dockerfile', port: 0 };
|
||||
}
|
||||
|
||||
// ── Parse helpers ───────────────────────────────────────────────────
|
||||
|
||||
/** Parse to an object for seeding; malformed / non-object JSON -> {}. */
|
||||
function parseObject(jsonText: string): Record<string, unknown> {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(jsonText);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/** Parse for preserve helpers; malformed JSON -> null (caller guards). */
|
||||
function tryParse(jsonText: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(jsonText);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function strOr(value: unknown, fallback: string): string {
|
||||
return typeof value === 'string' ? value : fallback;
|
||||
}
|
||||
|
||||
/** Non-empty string or fallback (matches legacy `typeof x === 'string' && x ? x : d`). */
|
||||
function strOrTruthy(value: unknown, fallback: string): string {
|
||||
return typeof value === 'string' && value ? value : fallback;
|
||||
}
|
||||
|
||||
function numOr(value: unknown, fallback: number): number {
|
||||
return typeof value === 'number' ? value : fallback;
|
||||
}
|
||||
|
||||
function normProvider(value: unknown): GitProvider {
|
||||
return value === 'github' || value === 'gitlab' ? value : 'gitea';
|
||||
}
|
||||
|
||||
// ── Seed: source_config JSON -> form state ──────────────────────────
|
||||
|
||||
export function seedImageState(jsonText: string): ImageFormState {
|
||||
const o = parseObject(jsonText);
|
||||
return {
|
||||
ref: strOr(o.image, ''),
|
||||
port: numOr(o.port, 0),
|
||||
healthcheck: strOr(o.healthcheck, ''),
|
||||
defaultTag: strOr(o.default_tag, 'latest'),
|
||||
registryName: strOr(o.registry_name, ''),
|
||||
cpuLimit: numOr(o.cpu_limit, 0),
|
||||
memoryLimit: numOr(o.memory_limit, 0),
|
||||
maxInstances: numOr(o.max_instances, 1)
|
||||
};
|
||||
}
|
||||
|
||||
export function seedComposeState(jsonText: string): ComposeFormState {
|
||||
const o = parseObject(jsonText);
|
||||
return {
|
||||
yaml: strOr(o.compose_yaml, ''),
|
||||
projectName: strOr(o.compose_project_name, '')
|
||||
};
|
||||
}
|
||||
|
||||
export function seedStaticState(jsonText: string): StaticFormState {
|
||||
const o = parseObject(jsonText);
|
||||
return {
|
||||
provider: normProvider(o.provider),
|
||||
baseURL: strOr(o.base_url, ''),
|
||||
repoOwner: strOr(o.repo_owner, ''),
|
||||
repoName: strOr(o.repo_name, ''),
|
||||
branch: strOrTruthy(o.branch, 'main'),
|
||||
accessToken: strOr(o.access_token, ''),
|
||||
folderPath: strOr(o.folder_path, ''),
|
||||
mode: o.mode === 'deno' ? 'deno' : 'static',
|
||||
renderMarkdown: typeof o.render_markdown === 'boolean' ? o.render_markdown : false
|
||||
};
|
||||
}
|
||||
|
||||
export function seedDockerfileState(jsonText: string): DockerfileFormState {
|
||||
const o = parseObject(jsonText);
|
||||
return {
|
||||
provider: normProvider(o.provider),
|
||||
baseURL: strOr(o.base_url, ''),
|
||||
repoOwner: strOr(o.repo_owner, ''),
|
||||
repoName: strOr(o.repo_name, ''),
|
||||
branch: strOrTruthy(o.branch, 'main'),
|
||||
accessToken: strOr(o.access_token, ''),
|
||||
contextPath: strOr(o.context_path, ''),
|
||||
dockerfilePath: strOrTruthy(o.dockerfile_path, 'Dockerfile'),
|
||||
port: numOr(o.port, 0)
|
||||
};
|
||||
}
|
||||
|
||||
// ── Serialize: form state -> source_config object ───────────────────
|
||||
|
||||
/**
|
||||
* Preserve `env` (object) and `volumes` (array) from an existing config
|
||||
* — they're edited in dedicated detail-page panels, not the source form,
|
||||
* and must survive a form round-trip.
|
||||
*/
|
||||
function preserveEnvVolumes(existingJson: string): {
|
||||
env: Record<string, string>;
|
||||
volumes: unknown[];
|
||||
} {
|
||||
const existing = tryParse(existingJson);
|
||||
let env: Record<string, string> = {};
|
||||
let volumes: unknown[] = [];
|
||||
if (existing) {
|
||||
if (existing.env && typeof existing.env === 'object') {
|
||||
env = existing.env as Record<string, string>;
|
||||
}
|
||||
if (Array.isArray(existing.volumes)) {
|
||||
volumes = existing.volumes;
|
||||
}
|
||||
}
|
||||
return { env, volumes };
|
||||
}
|
||||
|
||||
export function imageToConfig(s: ImageFormState, existingJson: string): Record<string, unknown> {
|
||||
const { env, volumes } = preserveEnvVolumes(existingJson);
|
||||
return {
|
||||
image: s.ref,
|
||||
registry_name: s.registryName,
|
||||
port: s.port,
|
||||
healthcheck: s.healthcheck,
|
||||
env,
|
||||
volumes,
|
||||
cpu_limit: s.cpuLimit,
|
||||
memory_limit: s.memoryLimit,
|
||||
default_tag: s.defaultTag,
|
||||
max_instances: s.maxInstances
|
||||
};
|
||||
}
|
||||
|
||||
export function composeToConfig(s: ComposeFormState): Record<string, unknown> {
|
||||
return { compose_yaml: s.yaml, compose_project_name: s.projectName };
|
||||
}
|
||||
|
||||
export function staticToConfig(s: StaticFormState, existingJson: string): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {
|
||||
provider: s.provider,
|
||||
base_url: s.baseURL,
|
||||
repo_owner: s.repoOwner,
|
||||
repo_name: s.repoName,
|
||||
branch: s.branch || 'main',
|
||||
folder_path: s.folderPath,
|
||||
access_token: s.accessToken,
|
||||
mode: s.mode,
|
||||
render_markdown: s.renderMarkdown
|
||||
};
|
||||
// Preserve storage_* keys set via the raw JSON editor (not yet surfaced
|
||||
// as form controls) so a form round-trip doesn't silently drop them.
|
||||
const existing = tryParse(existingJson);
|
||||
if (existing) {
|
||||
if (typeof existing.storage_enabled === 'boolean') out.storage_enabled = existing.storage_enabled;
|
||||
if (typeof existing.storage_limit_mb === 'number') out.storage_limit_mb = existing.storage_limit_mb;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys the dockerfile form owns. Everything else in an existing config is
|
||||
* preserved on round-trip EXCEPT the static-only keys (folder_path / mode
|
||||
* / render_markdown / storage_*) which are deliberately scrubbed: after a
|
||||
* static -> dockerfile switch they'd otherwise linger as dead keys and
|
||||
* make the backend log "unknown field" noise on every save.
|
||||
*/
|
||||
const DOCKERFILE_OWNED_KEYS: ReadonlySet<string> = new Set([
|
||||
'provider',
|
||||
'base_url',
|
||||
'repo_owner',
|
||||
'repo_name',
|
||||
'branch',
|
||||
'access_token',
|
||||
'context_path',
|
||||
'dockerfile_path',
|
||||
'port',
|
||||
'folder_path',
|
||||
'mode',
|
||||
'render_markdown',
|
||||
'storage_enabled',
|
||||
'storage_limit_mb'
|
||||
]);
|
||||
|
||||
export function dockerfileToConfig(
|
||||
s: DockerfileFormState,
|
||||
existingJson: string
|
||||
): Record<string, unknown> {
|
||||
const preserved: Record<string, unknown> = {};
|
||||
const existing = tryParse(existingJson);
|
||||
if (existing) {
|
||||
for (const [k, v] of Object.entries(existing)) {
|
||||
if (!DOCKERFILE_OWNED_KEYS.has(k)) preserved[k] = v;
|
||||
}
|
||||
}
|
||||
return {
|
||||
provider: s.provider,
|
||||
base_url: s.baseURL,
|
||||
repo_owner: s.repoOwner,
|
||||
repo_name: s.repoName,
|
||||
branch: s.branch || 'main',
|
||||
access_token: s.accessToken,
|
||||
context_path: s.contextPath,
|
||||
dockerfile_path: s.dockerfilePath || 'Dockerfile',
|
||||
port: s.port || 0,
|
||||
...preserved
|
||||
};
|
||||
}
|
||||
|
||||
/** Pretty-print a config object for the Advanced-JSON editor view. */
|
||||
export function stringifyConfig(config: Record<string, unknown>): string {
|
||||
return JSON.stringify(config, null, 2);
|
||||
}
|
||||
|
||||
// ── Per-kind validity ───────────────────────────────────────────────
|
||||
// Encodes the required fields per source kind. These back the wizard's
|
||||
// step gating (replacing the prior opaque ~250-char boolean). Optional
|
||||
// fields (folder_path, context_path, healthcheck, resource limits, ...)
|
||||
// are intentionally not required here.
|
||||
|
||||
export function isImageValid(s: ImageFormState): boolean {
|
||||
return s.ref.trim() !== '';
|
||||
}
|
||||
|
||||
export function isComposeValid(s: ComposeFormState): boolean {
|
||||
return s.yaml.trim() !== '';
|
||||
}
|
||||
|
||||
function isGitSourceValid(s: GitSourceState): boolean {
|
||||
return s.baseURL.trim() !== '' && s.repoOwner.trim() !== '' && s.repoName.trim() !== '';
|
||||
}
|
||||
|
||||
export function isStaticValid(s: StaticFormState): boolean {
|
||||
return isGitSourceValid(s);
|
||||
}
|
||||
|
||||
export function isDockerfileValid(s: DockerfileFormState): boolean {
|
||||
return isGitSourceValid(s) && typeof s.port === 'number' && Number.isFinite(s.port) && s.port > 0;
|
||||
}
|
||||
@@ -25,27 +25,51 @@
|
||||
|
||||
type NavCountKey = 'apps' | 'workloads' | 'proxies' | 'containers' | 'eventsErrors';
|
||||
|
||||
// Navigation entries are now grouped into named sections. The
|
||||
// renderer treats a `section:` marker entry as a visual divider with
|
||||
// an uppercase eyebrow label, but otherwise renders items as before.
|
||||
// Grouping the flat list (Events / Event Triggers / Log Rules sat
|
||||
// next to Apps / Containers without any visual separation) was the
|
||||
// biggest readability complaint from the earlier UI review.
|
||||
type NavSection = 'build' | 'observe' | 'system';
|
||||
const navItems: ReadonlyArray<{
|
||||
href: string;
|
||||
labelKey: string;
|
||||
icon: string;
|
||||
section: NavSection;
|
||||
countKey?: NavCountKey;
|
||||
/** When true the badge uses a danger style (red). */
|
||||
alert?: boolean;
|
||||
/** Static label override when the i18n catalogue does not yet carry the key. */
|
||||
labelOverride?: string;
|
||||
}> = [
|
||||
{ href: '/', labelKey: 'nav.dashboard', icon: 'dashboard' },
|
||||
{ href: '/apps', labelKey: 'nav.apps', icon: 'box', countKey: 'apps' },
|
||||
{ href: '/containers', labelKey: 'nav.containers', icon: 'containers', countKey: 'containers' },
|
||||
{ href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies', countKey: 'proxies' },
|
||||
{ href: '/events', labelKey: 'nav.events', icon: 'events', countKey: 'eventsErrors', alert: true },
|
||||
{ href: '/triggers', labelKey: 'nav.triggers', icon: 'deploy' },
|
||||
{ href: '/event-triggers', labelKey: 'nav.eventTriggers', icon: 'events', labelOverride: 'Event Triggers' },
|
||||
{ href: '/log-scan-rules', labelKey: 'nav.logScanRules', icon: 'events', labelOverride: 'Log Rules' },
|
||||
{ href: '/settings', labelKey: 'nav.settings', icon: 'settings' }
|
||||
{ href: '/', labelKey: 'nav.dashboard', icon: 'dashboard', section: 'build' },
|
||||
{ href: '/apps', labelKey: 'nav.apps', icon: 'box', section: 'build', countKey: 'apps' },
|
||||
{ href: '/containers', labelKey: 'nav.containers', icon: 'containers', section: 'build', countKey: 'containers' },
|
||||
{ href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies', section: 'build', countKey: 'proxies' },
|
||||
{ href: '/triggers', labelKey: 'nav.triggers', icon: 'deploy', section: 'build' },
|
||||
{ href: '/events', labelKey: 'nav.events', icon: 'events', section: 'observe', countKey: 'eventsErrors', alert: true },
|
||||
{ href: '/event-triggers', labelKey: 'nav.eventTriggers', icon: 'events', section: 'observe' },
|
||||
{ href: '/log-scan-rules', labelKey: 'nav.logScanRules', icon: 'events', section: 'observe' },
|
||||
{ href: '/settings', labelKey: 'nav.settings', icon: 'settings', section: 'system' }
|
||||
];
|
||||
|
||||
// sectionLabels: eyebrow text rendered above the first item of each
|
||||
// section. `build` is left unlabelled — it's the default and adding
|
||||
// an eyebrow above Dashboard would feel redundant.
|
||||
// Localized via $t — $derived so a language switch re-renders the
|
||||
// eyebrows. `build` stays unlabelled (see above).
|
||||
const sectionLabels: Record<NavSection, string> = $derived({
|
||||
build: '',
|
||||
observe: $t('nav.sectionObserve'),
|
||||
system: $t('nav.sectionSystem')
|
||||
});
|
||||
|
||||
function sectionStart(idx: number): NavSection | null {
|
||||
const cur = navItems[idx].section;
|
||||
if (idx === 0) return cur;
|
||||
const prev = navItems[idx - 1].section;
|
||||
return cur !== prev ? cur : null;
|
||||
}
|
||||
|
||||
function isActive(href: string, pathname: string): boolean {
|
||||
if (href === '/') return pathname === '/';
|
||||
return pathname.startsWith(href);
|
||||
@@ -194,7 +218,7 @@
|
||||
<button
|
||||
class="ml-auto rounded-md p-1 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] lg:hidden"
|
||||
onclick={() => { sidebarOpen = false; }}
|
||||
aria-label="Close sidebar"
|
||||
aria-label={$t('nav.closeSidebar')}
|
||||
>
|
||||
<IconX size={20} />
|
||||
</button>
|
||||
@@ -269,8 +293,12 @@
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 space-y-0.5 px-3 py-3">
|
||||
{#each navItems as item}
|
||||
{#each navItems as item, idx}
|
||||
{@const active = isActive(item.href, $page.url.pathname)}
|
||||
{@const newSection = sectionStart(idx)}
|
||||
{#if newSection && sectionLabels[newSection]}
|
||||
<div class="nav-section-eyebrow" aria-hidden="true">{sectionLabels[newSection]}</div>
|
||||
{/if}
|
||||
<a
|
||||
href={item.href}
|
||||
class="nav-item group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
|
||||
@@ -291,7 +319,7 @@
|
||||
{:else if item.icon === 'settings'}
|
||||
<IconSettings size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||
{/if}
|
||||
<span class="nav-label">{item.labelOverride ?? $t(item.labelKey)}</span>
|
||||
<span class="nav-label">{$t(item.labelKey)}</span>
|
||||
|
||||
{#if item.countKey}
|
||||
{@const count = $navCounts[item.countKey]}
|
||||
@@ -338,9 +366,9 @@
|
||||
<span class="clock-suffix">{clockOffset}</span>
|
||||
</span>
|
||||
</div>
|
||||
<p class="forge-nav-hint" title="Press 'g' then a letter to jump between sections">
|
||||
<p class="forge-nav-hint" title={$t('nav.quickNavTitle')}>
|
||||
<kbd>g</kbd><span class="arr">→</span><kbd>d</kbd><kbd>a</kbd><kbd>n</kbd><kbd>t</kbd>
|
||||
<span class="hint-label">quick-nav</span>
|
||||
<span class="hint-label">{$t('nav.quickNavLabel')}</span>
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -352,7 +380,7 @@
|
||||
<button
|
||||
class="rounded-md p-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)] transition-colors"
|
||||
onclick={() => { sidebarOpen = true; }}
|
||||
aria-label="Open sidebar"
|
||||
aria-label={$t('nav.openSidebar')}
|
||||
>
|
||||
<IconMenu size={22} />
|
||||
</button>
|
||||
@@ -550,6 +578,15 @@
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.nav-section-eyebrow {
|
||||
margin: 0.85rem 0.25rem 0.25rem;
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.nav-active {
|
||||
background: var(--surface-card-hover);
|
||||
color: var(--text-primary) !important;
|
||||
|
||||
@@ -70,7 +70,12 @@
|
||||
return () => { loadController?.abort(); };
|
||||
});
|
||||
|
||||
const totalWorkloads = $derived(workloads.length);
|
||||
// Plugin-native workloads only. Legacy pre-cutover rows (kind project/
|
||||
// stack/site) carry an empty source_kind and have no UI home post-cutover,
|
||||
// so they must not inflate the headline count or the recent strip — this
|
||||
// matches the /apps list, which shows the same set.
|
||||
const pluginWorkloads = $derived(workloads.filter((w) => w.source_kind !== ''));
|
||||
const totalWorkloads = $derived(pluginWorkloads.length);
|
||||
const totalRunning = $derived(containers.filter((c) => c.state === 'running').length);
|
||||
const totalFailed = $derived(containers.filter((c) => c.state === 'failed').length);
|
||||
const totalStale = $derived(staleContainers.length);
|
||||
@@ -78,7 +83,7 @@
|
||||
// Latest 6 workloads by updated_at desc — enough for an at-a-glance
|
||||
// recent-activity strip without paging the entire list.
|
||||
const recentWorkloads = $derived(
|
||||
[...workloads]
|
||||
[...pluginWorkloads]
|
||||
.sort((a, b) => (b.updated_at ?? '').localeCompare(a.updated_at ?? ''))
|
||||
.slice(0, 6)
|
||||
);
|
||||
@@ -113,7 +118,7 @@
|
||||
{/snippet}
|
||||
<ForgeHero
|
||||
eyebrow="THE FORGE"
|
||||
eyebrowSuffix="DASHBOARD"
|
||||
eyebrowSuffix={$t('nav.dashboard').toUpperCase()}
|
||||
title={$t('dashboard.title')}
|
||||
accent="."
|
||||
size="lg"
|
||||
@@ -125,22 +130,22 @@
|
||||
<a href="/apps" class="forge-stat stat-link">
|
||||
<span class="forge-stat-label">{$t('dashboard.totalWorkloads')}</span>
|
||||
<span class="forge-stat-value">{String(totalWorkloads).padStart(2, '0')}</span>
|
||||
<span class="forge-stat-sub">workloads →</span>
|
||||
<span class="forge-stat-sub">{$t('dashboard.statSubWorkloads')}</span>
|
||||
</a>
|
||||
<a href="/containers" class="forge-stat stat-link">
|
||||
<span class="forge-stat-label">{$t('dashboard.runningContainers')}</span>
|
||||
<span class="forge-stat-value accent">{String(totalRunning).padStart(2, '0')}</span>
|
||||
<span class="forge-stat-sub">running</span>
|
||||
<span class="forge-stat-sub">{$t('dashboard.statSubRunning')}</span>
|
||||
</a>
|
||||
<a href="/containers?state=failed" class="forge-stat stat-link">
|
||||
<span class="forge-stat-label">{$t('dashboard.failedContainers')}</span>
|
||||
<span class="forge-stat-value" class:fail={totalFailed > 0}>{String(totalFailed).padStart(2, '0')}</span>
|
||||
<span class="forge-stat-sub">need attention</span>
|
||||
<span class="forge-stat-sub">{$t('dashboard.statSubNeedAttention')}</span>
|
||||
</a>
|
||||
<a href="/containers/stale" class="forge-stat stat-link">
|
||||
<span class="forge-stat-label">{$t('dashboard.staleContainers')}</span>
|
||||
<span class="forge-stat-value" class:warn={totalStale > 0}>{String(totalStale).padStart(2, '0')}</span>
|
||||
<span class="forge-stat-sub">stale →</span>
|
||||
<span class="forge-stat-sub">{$t('dashboard.statSubStale')}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
let error = $state('');
|
||||
let filter = $state<'all' | string>('all');
|
||||
|
||||
// Plugin-native rows are the ones with both source_kind and trigger_kind
|
||||
// populated. Legacy project/stack/site rows still appear in
|
||||
// /api/workloads — those are surfaced under their own sections.
|
||||
const pluginRows = $derived(
|
||||
workloads.filter((w) => w.source_kind !== '' && w.trigger_kind !== '')
|
||||
);
|
||||
// Plugin-native rows are those with a source_kind. trigger_kind is no
|
||||
// longer on the workload row (triggers are first-class bindings now), so a
|
||||
// manual/binding-trigger app legitimately has an empty trigger_kind and
|
||||
// must NOT be filtered out. Legacy pre-cutover project/stack/site rows
|
||||
// carry an empty source_kind and are excluded — they have no UI home.
|
||||
const pluginRows = $derived(workloads.filter((w) => w.source_kind !== ''));
|
||||
const filtered = $derived(
|
||||
filter === 'all' ? pluginRows : pluginRows.filter((w) => w.source_kind === filter)
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user