diff --git a/.github/workflows/test-build-release.yml b/.github/workflows/test-build-release.yml
index bd48786d69..0c274d45fa 100644
--- a/.github/workflows/test-build-release.yml
+++ b/.github/workflows/test-build-release.yml
@@ -370,6 +370,13 @@ jobs:
- name: Lockfile sync guard (root)
run: npm ci --ignore-scripts
+ # UI lockfile guard: `npm ci --dry-run` validates ui/package-lock.json
+ # against ui/package.json without installing anything (same
+ # @ctxo/plugin-api drift class; --legacy-peer-deps mirrors test-ui).
+ - name: Lockfile sync guard (UI)
+ working-directory: ui
+ run: npm ci --dry-run --ignore-scripts --legacy-peer-deps
+
# Tests already ran in test-sqlite + test-postgres (both in this job's
# needs), so verify does NOT re-run the suite — it runs vet + gosec only.
- name: Go security scan (vet, gosec)
diff --git a/.gosec.json b/.gosec.json
index 0967ef424b..05ca742f18 100644
--- a/.gosec.json
+++ b/.gosec.json
@@ -1 +1,25 @@
-{}
+{
+ "_comment": "Single source of truth for gosec. package.json preflight:go and preflight:secscan both pass -conf .gosec.json. Do NOT re-add -exclude on the command line: it overrides this list.",
+ "global": {
+ "exclude": "G104,G115,G404,G702,G703,G704,G112,G114,G118,G120,G124,G204,G301,G304,G306,G705,G710"
+ },
+ "_rationale": {
+ "G104": "Unhandled errors — reviewed by go vet and explicit error handling; errcheck noise.",
+ "G115": "Integer conversion overflow — conversions are bounded by validated input.",
+ "G404": "Weak random (math/rand) — used for non-cryptographic jitter/IDs only; secrets use crypto/rand.",
+ "G702": "Tainted command injection — deploy component executes user-declared build commands by design.",
+ "G703": "Tainted path — deploy/storage paths are validated against their configured roots.",
+ "G704": "SSRF — outbound targets come from operator-configured deploy settings, not request input.",
+ "G112": "Slowloris — timeouts are configured at the proxy/http.Server layer.",
+ "G114": "net/http server without timeouts — server construction sets Read/Write/Idle timeouts.",
+ "G118": "Goroutine context/cancel — long-lived workers intentionally use a background context.",
+ "G120": "Decompression bomb — upload/archive size limits are enforced before decompression.",
+ "G124": "Insecure TLS options — self-signed/insecure mode is opt-in for local development only.",
+ "G204": "Subprocess with variable — deploy runner executes declared commands; args are passed as a slice, not a shell string.",
+ "G301": "File permission > 0750 — storage directories are intentionally group-readable.",
+ "G304": "File path from variable — storage paths are validated and confined to the data root.",
+ "G306": "File permission > 0600 — generated config/docs are intentionally world-readable (0644).",
+ "G705": "XSS via tainted data — React escapes by default; no dangerouslySetInnerHTML on tainted input.",
+ "G710": "Open redirect — redirect targets are validated/allowlisted."
+ }
+}
diff --git a/README.md b/README.md
index 87fef00017..bcd051e723 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
[](https://bigbase.click)

-
+[](https://github.com/danielvm-git/bigbase/releases)

> Single-binary, component-based BaaS platform using Entity-Component-Construct (ECC) architecture.
diff --git a/components/cici/cici.go b/components/cici/cici.go
index fca2598342..079a609bce 100644
--- a/components/cici/cici.go
+++ b/components/cici/cici.go
@@ -8,6 +8,7 @@ import (
"net/http"
"regexp"
"strings"
+ "time"
"gopkg.in/yaml.v3"
@@ -90,9 +91,9 @@ func New(opts Options) *CICI {
return &CICI{db: opts.DB, logger: logger}
}
-func (c *CICI) Name() string { return "cici" }
-func (c *CICI) Version() string { return version }
-func (c *CICI) Dependencies() []string { return []string{"db"} }
+func (c *CICI) Name() string { return "cici" }
+func (c *CICI) Version() string { return version }
+func (c *CICI) Dependencies() []string { return []string{"db"} }
func (c *CICI) Init(ctx *kernel.Context, config json.RawMessage) error {
return nil
@@ -105,8 +106,8 @@ func (c *CICI) Start(ctx *kernel.Context) error {
yaml TEXT NOT NULL, created_at TEXT NOT NULL)`,
`CREATE TABLE IF NOT EXISTS cici_runs (
id TEXT PRIMARY KEY, workflow_id TEXT NOT NULL, event TEXT NOT NULL,
- status TEXT NOT NULL DEFAULT 'pending', started_at TEXT NOT NULL,
- finished_at TEXT)`,
+ status TEXT NOT NULL DEFAULT 'queued', started_at TEXT NOT NULL,
+ finished_at TEXT, deadline_at TEXT)`,
`CREATE TABLE IF NOT EXISTS cici_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT, run_id TEXT NOT NULL,
step TEXT NOT NULL, output TEXT NOT NULL,
@@ -116,10 +117,53 @@ func (c *CICI) Start(ctx *kernel.Context) error {
return fmt.Errorf("migrate: %w", err)
}
}
+ if err := c.migrateRunColumns(); err != nil {
+ return err
+ }
+
+ // No CICI execution goroutine survives a process restart, so every row left
+ // non-terminal belongs to a dead process. Fail them instead of leaving them
+ // "running" forever; a future run is a new row.
+ reconcileCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ reconciled, err := c.reconcileOrphanedRuns(reconcileCtx)
+ if err != nil {
+ return err
+ }
+ if reconciled > 0 {
+ c.logger.Warn("reconciled orphaned cici runs", "count", reconciled, "status", string(RunStatusFailure))
+ }
+
c.logger.Info("cici component ready")
return nil
}
+// migrateRunColumns brings older cici_runs tables up to the current schema.
+// ALTER TABLE ADD COLUMN is not idempotent, so a duplicate-column error is
+// treated as "already migrated".
+func (c *CICI) migrateRunColumns() error {
+ if err := c.db.Migrate(`ALTER TABLE cici_runs ADD COLUMN deadline_at TEXT`); err != nil {
+ if !isDuplicateColumnError(err) {
+ return fmt.Errorf("add cici_runs.deadline_at: %w", err)
+ }
+ }
+ if err := c.db.Migrate(
+ `UPDATE cici_runs SET status = 'queued' WHERE status = 'pending'`); err != nil {
+ return fmt.Errorf("normalize legacy pending runs: %w", err)
+ }
+ return nil
+}
+
+// isDuplicateColumnError matches SQLite ("duplicate column name") and
+// PostgreSQL ("column ... already exists") migration noise.
+func isDuplicateColumnError(err error) bool {
+ msg := strings.ToLower(err.Error())
+ if strings.Contains(msg, "duplicate column") {
+ return true
+ }
+ return strings.Contains(msg, "column") && strings.Contains(msg, "already exists")
+}
+
func (c *CICI) Stop(ctx *kernel.Context) error {
return nil
}
diff --git a/components/cici/reconcile_test.go b/components/cici/reconcile_test.go
new file mode 100644
index 0000000000..c95e7c7b9e
--- /dev/null
+++ b/components/cici/reconcile_test.go
@@ -0,0 +1,145 @@
+package cici_test
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/danielvm/bigbase/components/cici"
+ "github.com/danielvm/bigbase/kernel"
+)
+
+func TestReconcileOrphanedRunsOnStart(t *testing.T) {
+ c, d := setupCICI(t)
+
+ now := time.Now().UTC().Format(time.RFC3339)
+ rows := []struct {
+ id string
+ status string
+ }{
+ {"stale-running", "running"},
+ {"stale-queued", "queued"},
+ {"stale-pending", "pending"},
+ {"finished-success", "success"},
+ {"finished-failure", "failure"},
+ }
+ for _, row := range rows {
+ if _, err := d.ExecContext(context.Background(),
+ "INSERT INTO cici_runs (id, workflow_id, event, status, started_at) VALUES (?, ?, ?, ?, ?)",
+ row.id, "wf-1", "manual", row.status, now); err != nil {
+ t.Fatalf("seed run %s: %v", row.id, err)
+ }
+ }
+
+ // Start is idempotent and performs boot reconciliation.
+ if err := c.Start(&kernel.Context{}); err != nil {
+ t.Fatalf("start: %v", err)
+ }
+
+ wantStatus := map[string]struct {
+ status string
+ stale bool
+ }{
+ "stale-running": {"failure", true},
+ "stale-queued": {"failure", true},
+ "stale-pending": {"failure", true},
+ "finished-success": {"success", false},
+ "finished-failure": {"failure", false},
+ }
+ for id, want := range wantStatus {
+ var status string
+ var finishedAt *string
+ if err := d.QueryRowContext(context.Background(),
+ "SELECT status, finished_at FROM cici_runs WHERE id = ?", id).
+ Scan(&status, &finishedAt); err != nil {
+ t.Fatalf("query run %s: %v", id, err)
+ }
+ if status != want.status {
+ t.Errorf("run %s status = %q, want %q", id, status, want.status)
+ }
+ if want.stale && finishedAt == nil {
+ t.Errorf("run %s finished_at = nil, want a timestamp", id)
+ }
+ }
+}
+
+func TestListRunsNormalizesLegacyPending(t *testing.T) {
+ c, d := setupCICI(t)
+ h := c.Handler()
+
+ now := time.Now().UTC().Format(time.RFC3339)
+ if _, err := d.ExecContext(context.Background(),
+ "INSERT INTO cici_runs (id, workflow_id, event, status, started_at) VALUES (?, ?, ?, ?, ?)",
+ "legacy-pending", "wf-1", "manual", "pending", now); err != nil {
+ t.Fatalf("seed legacy run: %v", err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/cici/runs", nil)
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ var resp map[string][]map[string]any
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatal(err)
+ }
+ for _, r := range resp["data"] {
+ if r["id"] == "legacy-pending" {
+ if r["status"] != string(cici.RunStatusQueued) {
+ t.Fatalf("legacy pending status = %v, want %q", r["status"], cici.RunStatusQueued)
+ }
+ return
+ }
+ }
+ t.Fatal("legacy-pending run not returned by GET /api/cici/runs")
+}
+
+func TestTriggerRunPersistsDeadline(t *testing.T) {
+ c, d := setupCICI(t)
+ h := c.Handler()
+
+ seedRepo(t, d, "r", "my-app", 100)
+ wfID := saveWorkflow(t, h, "r", "CI", `name: CI
+on: [push]
+jobs:
+ build:
+ steps:
+ - run: echo "ok"
+`, 100)
+
+ runBody, _ := json.Marshal(map[string]string{"event": "manual"})
+ runReq := httptest.NewRequest("POST", "/api/cici/r/workflows/"+wfID+"/run", bytes.NewReader(runBody))
+ runReq.Header.Set("Content-Type", "application/json")
+ runReq = withOrgID(runReq, 100)
+ runW := httptest.NewRecorder()
+ h.ServeHTTP(runW, runReq)
+
+ if runW.Code != http.StatusAccepted {
+ t.Fatalf("expected 202, got %d: %s", runW.Code, runW.Body.String())
+ }
+ var run map[string]any
+ if err := json.NewDecoder(runW.Body).Decode(&run); err != nil {
+ t.Fatal(err)
+ }
+ runID, _ := run["id"].(string)
+ if run["status"] != string(cici.RunStatusRunning) {
+ t.Fatalf("response status = %v, want %q", run["status"], cici.RunStatusRunning)
+ }
+
+ var deadlineAt *string
+ if err := d.QueryRowContext(context.Background(),
+ "SELECT deadline_at FROM cici_runs WHERE id = ?", runID).Scan(&deadlineAt); err != nil {
+ t.Fatalf("query deadline: %v", err)
+ }
+ if deadlineAt == nil || *deadlineAt == "" {
+ t.Fatal("deadline_at was not persisted")
+ }
+
+ pollForStatus(t, h, runID, "success")
+}
diff --git a/components/cici/runs.go b/components/cici/runs.go
index d47c1d2b16..1c4ae84582 100644
--- a/components/cici/runs.go
+++ b/components/cici/runs.go
@@ -52,34 +52,71 @@ func (c *CICI) triggerRun(w http.ResponseWriter, r *http.Request) {
return
}
- now := time.Now().UTC().Format(time.RFC3339)
+ nowTime := time.Now().UTC()
+ now := nowTime.Format(time.RFC3339)
+ deadline := nowTime.Add(runTimeout).Format(time.RFC3339)
if _, err := c.db.ExecContext(r.Context(),
- "INSERT INTO cici_runs (id, workflow_id, event, status, started_at) VALUES (?, ?, ?, 'running', ?)",
- runID, workflowID, req.Event, now); err != nil {
+ `INSERT INTO cici_runs (id, workflow_id, event, status, started_at, deadline_at)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ runID, workflowID, req.Event, string(RunStatusQueued), now, deadline); err != nil {
c.logger.Error("insert run", "error", err)
kernel.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
+ // Execution starts immediately, so move queued -> running before the
+ // worker goroutine is spawned. If the transition fails the run is dead;
+ // mark it terminal rather than leaving it queued.
+ if err := c.transitionRun(r.Context(), runID, RunStatusQueued, RunStatusRunning); err != nil {
+ c.logger.Error("start run", "run_id", runID, "error", err)
+ if finishErr := c.finishRun(context.Background(), runID, RunStatusFailure); finishErr != nil {
+ c.logger.Error("finish unstarted run", "run_id", runID, "error", finishErr)
+ }
+ kernel.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
+ return
+ }
+
go c.executeRun(runID, wf)
kernel.WriteJSON(w, http.StatusAccepted, PipelineRun{
ID: runID, WorkflowID: workflowID, Event: req.Event,
- Status: "running", StartedAt: now,
+ Status: string(RunStatusRunning), StartedAt: now,
})
}
+// transitionRun applies one validated state change atomically, refusing to
+// overwrite a row that is no longer in the expected "from" state.
+func (c *CICI) transitionRun(ctx context.Context, runID string, from, to RunStatus) error {
+ if err := ValidateTransition(from, to); err != nil {
+ return err
+ }
+ res, err := c.db.ExecContext(ctx,
+ "UPDATE cici_runs SET status = ? WHERE id = ? AND status = ?",
+ string(to), runID, string(from))
+ if err != nil {
+ return fmt.Errorf("transition run %s %s->%s: %w", runID, from, to, err)
+ }
+ affected, err := res.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("transition run %s %s->%s count: %w", runID, from, to, err)
+ }
+ if affected == 0 {
+ return &TransitionError{From: from, To: to, Reason: "run is not in the expected source state"}
+ }
+ return nil
+}
+
func (c *CICI) executeRun(runID string, wf *WorkflowDef) {
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
+ ctx, cancel := c.runContext(runID)
defer cancel()
var wfYAML workflowYAML
if err := yaml.Unmarshal([]byte(wf.YAML), &wfYAML); err != nil {
- c.finishRun(ctx, runID, "failure")
+ c.finishRunAndLog(ctx, runID, RunStatusFailure)
return
}
- status := "success"
+ status := RunStatusSuccess
for jobName, jobDef := range wfYAML.Jobs {
for i, stepDef := range jobDef.Steps {
if stepDef.Run == "" {
@@ -89,16 +126,37 @@ func (c *CICI) executeRun(runID string, wf *WorkflowDef) {
if err := c.executeStep(ctx, runID, stepName, stepDef.Run); err != nil {
c.logger.Error("step failed", "run_id", runID, "step", stepName, "error", err)
c.appendLog(ctx, runID, stepName, "FAIL: "+err.Error())
- status = "failure"
+ status = RunStatusFailure
break
}
}
- if status == "failure" {
+ if status == RunStatusFailure {
break
}
}
- c.finishRun(ctx, runID, status)
+ c.finishRunAndLog(ctx, runID, status)
+}
+
+// runContext derives the execution deadline from the persisted deadline_at so
+// a run is still bounded after an in-memory restart (and so tests can observe
+// the same value that is stored).
+func (c *CICI) runContext(runID string) (context.Context, context.CancelFunc) {
+ deadline := time.Now().Add(runTimeout)
+ var raw *string
+ if err := c.db.QueryRowContext(context.Background(),
+ "SELECT deadline_at FROM cici_runs WHERE id = ?", runID).Scan(&raw); err == nil &&
+ raw != nil && *raw != "" {
+ if parsed, err := time.Parse(time.RFC3339, *raw); err == nil {
+ deadline = parsed
+ }
+ }
+ return context.WithDeadline(context.Background(), deadline)
+}
+
+// finishRunAndLog records a terminal state; finishRun already logs failures.
+func (c *CICI) finishRunAndLog(ctx context.Context, runID string, status RunStatus) {
+ _ = c.finishRun(ctx, runID, status)
}
func (c *CICI) executeStep(ctx context.Context, runID, name, command string) error {
@@ -134,14 +192,64 @@ func (c *CICI) appendLog(ctx context.Context, runID, step, output string) {
}
}
-func (c *CICI) finishRun(ctx context.Context, runID, status string) {
+// finishRun records a terminal state. Non-terminal statuses are rejected with a
+// typed error, and database failures are logged and returned instead of being
+// swallowed.
+func (c *CICI) finishRun(ctx context.Context, runID string, status RunStatus) error {
+ if err := ValidateStatus(status); err != nil {
+ c.logger.Error("finish run rejected", "run_id", runID, "status", status, "error", err)
+ return err
+ }
+ if !IsTerminal(status) {
+ err := &TransitionError{To: status, Reason: "finishRun requires a terminal status"}
+ c.logger.Error("finish run rejected", "run_id", runID, "status", status, "error", err)
+ return err
+ }
+
now := time.Now().UTC().Format(time.RFC3339)
- _, err := c.db.ExecContext(ctx,
- "UPDATE cici_runs SET status = ?, finished_at = ? WHERE id = ?",
- status, now, runID)
+ // The execution context may already be deadline-exceeded; detach the
+ // terminal write so a timeout still records the failure.
+ writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
+ defer cancel()
+ res, err := c.db.ExecContext(writeCtx,
+ `UPDATE cici_runs SET status = ?, finished_at = ?
+ WHERE id = ? AND status IN (?, ?, ?)`,
+ string(status), now, runID,
+ string(RunStatusQueued), string(RunStatusRunning), legacyPendingStatus)
+ if err != nil {
+ wrapped := fmt.Errorf("finish run %s: %w", runID, err)
+ c.logger.Error("finish run", "run_id", runID, "status", status, "error", wrapped)
+ return wrapped
+ }
+ if rows, _ := res.RowsAffected(); rows == 0 {
+ var currentStatus string
+ if qErr := c.db.QueryRowContext(writeCtx, "SELECT status FROM cici_runs WHERE id = ?", runID).Scan(¤tStatus); qErr == nil {
+ c.logger.Warn("finish run ignored: run is already terminal", "run_id", runID, "current_status", currentStatus, "attempted_status", status)
+ }
+ }
+ return nil
+}
+
+// reconcileOrphanedRuns fails every run left in a non-terminal state by a
+// previous process. CICI executes runs in-process, so no run survives a
+// restart; any queued/running (or legacy pending) row is necessarily orphaned.
+// Failing them at boot keeps GET /api/cici/runs truthful instead of reporting
+// runs that will never finish.
+func (c *CICI) reconcileOrphanedRuns(ctx context.Context) (int64, error) {
+ now := time.Now().UTC().Format(time.RFC3339)
+ res, err := c.db.ExecContext(ctx,
+ `UPDATE cici_runs SET status = ?, finished_at = ?
+ WHERE status IN (?, ?, ?)`,
+ string(RunStatusFailure), now,
+ string(RunStatusQueued), string(RunStatusRunning), legacyPendingStatus)
+ if err != nil {
+ return 0, fmt.Errorf("reconcile orphaned runs: %w", err)
+ }
+ affected, err := res.RowsAffected()
if err != nil {
- c.logger.Error("finish run", "run_id", runID, "error", err)
+ return 0, fmt.Errorf("reconcile orphaned runs count: %w", err)
}
+ return affected, nil
}
func (c *CICI) handleRuns(w http.ResponseWriter, r *http.Request) {
@@ -201,6 +309,7 @@ func (c *CICI) handleRuns(w http.ResponseWriter, r *http.Request) {
c.logger.Error("scan run", "error", err)
continue
}
+ r.Status = NormalizeRunStatus(r.Status)
runs = append(runs, r)
}
if err := rows.Err(); err != nil {
diff --git a/components/cici/runs_internal_test.go b/components/cici/runs_internal_test.go
new file mode 100644
index 0000000000..8d6f36a2a3
--- /dev/null
+++ b/components/cici/runs_internal_test.go
@@ -0,0 +1,104 @@
+package cici
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/danielvm/bigbase/components/db"
+ "github.com/danielvm/bigbase/kernel"
+)
+
+func newTestCICI(t *testing.T) (*CICI, *db.DB) {
+ t.Helper()
+ logger := kernel.NoopLogger{}
+ d := db.New(db.Options{Path: ":memory:", Logger: logger})
+ k := kernel.New(logger)
+ k.Register(d)
+ if err := k.Start(); err != nil {
+ t.Fatalf("kernel start: %v", err)
+ }
+ t.Cleanup(func() { _ = k.Stop() })
+
+ c := New(Options{DB: d, Logger: logger})
+ if err := c.Start(&kernel.Context{}); err != nil {
+ t.Fatalf("cici start: %v", err)
+ }
+ return c, d
+}
+
+func TestFinishRunRejectsNonTerminalStatus(t *testing.T) {
+ c, d := newTestCICI(t)
+
+ now := time.Now().UTC().Format(time.RFC3339)
+ if _, err := d.ExecContext(context.Background(),
+ "INSERT INTO cici_runs (id, workflow_id, event, status, started_at) VALUES (?, ?, ?, ?, ?)",
+ "r1", "wf-1", "manual", "running", now); err != nil {
+ t.Fatalf("seed run: %v", err)
+ }
+
+ err := c.finishRun(context.Background(), "r1", RunStatusRunning)
+ if !errors.Is(err, ErrInvalidTransition) {
+ t.Fatalf("finishRun(running) = %v, want ErrInvalidTransition", err)
+ }
+
+ var status string
+ var finishedAt *string
+ if err := d.QueryRowContext(context.Background(),
+ "SELECT status, finished_at FROM cici_runs WHERE id = ?", "r1").
+ Scan(&status, &finishedAt); err != nil {
+ t.Fatalf("query run: %v", err)
+ }
+ if status != string(RunStatusRunning) || finishedAt != nil {
+ t.Fatalf("rejected finish mutated the row: status=%q finished_at=%v", status, finishedAt)
+ }
+
+ if err := c.finishRun(context.Background(), "r1", RunStatusSuccess); err != nil {
+ t.Fatalf("finishRun(success) = %v, want nil", err)
+ }
+ if err := d.QueryRowContext(context.Background(),
+ "SELECT status, finished_at FROM cici_runs WHERE id = ?", "r1").
+ Scan(&status, &finishedAt); err != nil {
+ t.Fatalf("query run: %v", err)
+ }
+ if status != string(RunStatusSuccess) || finishedAt == nil {
+ t.Fatalf("finishRun(success) did not persist: status=%q finished_at=%v", status, finishedAt)
+ }
+}
+
+func TestFinishRunDoesNotOverwriteTerminalStatus(t *testing.T) {
+ c, d := newTestCICI(t)
+
+ now := time.Now().UTC().Format(time.RFC3339)
+ if _, err := d.ExecContext(context.Background(),
+ "INSERT INTO cici_runs (id, workflow_id, event, status, started_at, finished_at) VALUES (?, ?, ?, ?, ?, ?)",
+ "r-terminal", "wf-1", "manual", "failure", now, now); err != nil {
+ t.Fatalf("seed terminal run: %v", err)
+ }
+
+ // finishRun attempting to mark success on an already-terminal failed run
+ if err := c.finishRun(context.Background(), "r-terminal", RunStatusSuccess); err != nil {
+ t.Fatalf("finishRun on terminal run returned error: %v", err)
+ }
+
+ var status string
+ if err := d.QueryRowContext(context.Background(),
+ "SELECT status FROM cici_runs WHERE id = ?", "r-terminal").Scan(&status); err != nil {
+ t.Fatalf("query run: %v", err)
+ }
+ if status != string(RunStatusFailure) {
+ t.Fatalf("status = %q, want %q (must not overwrite terminal status)", status, RunStatusFailure)
+ }
+}
+
+func TestFinishRunSurfacesDBErrors(t *testing.T) {
+ c, d := newTestCICI(t)
+
+ if err := d.Migrate("DROP TABLE cici_runs"); err != nil {
+ t.Fatalf("drop table: %v", err)
+ }
+ if err := c.finishRun(context.Background(), "r1", RunStatusFailure); err == nil {
+ t.Fatal("finishRun() = nil, want a surfaced database error")
+ }
+}
diff --git a/components/cici/state.go b/components/cici/state.go
new file mode 100644
index 0000000000..18926bb5e7
--- /dev/null
+++ b/components/cici/state.go
@@ -0,0 +1,156 @@
+package cici
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+)
+
+// RunStatus is the lifecycle state of a CICI pipeline run.
+//
+// queued -> running -> success | failure | cancelled
+//
+// A queued run may also fail or be cancelled before execution starts.
+// Terminal statuses never transition again.
+type RunStatus string
+
+const (
+ // RunStatusQueued means the run was accepted but execution has not begun.
+ RunStatusQueued RunStatus = "queued"
+ // RunStatusRunning means a process is executing the run's steps.
+ RunStatusRunning RunStatus = "running"
+ // RunStatusSuccess means every step completed successfully.
+ RunStatusSuccess RunStatus = "success"
+ // RunStatusFailure means a step failed, the deadline expired, or the run
+ // was orphaned by a process restart.
+ RunStatusFailure RunStatus = "failure"
+ // RunStatusCancelled means the run was cancelled before completion.
+ RunStatusCancelled RunStatus = "cancelled"
+)
+
+// legacyPendingStatus is the status default used by older cici_runs schemas.
+// It is treated as an alias for RunStatusQueued for backward compatibility.
+const legacyPendingStatus = "pending"
+
+// runTimeout bounds a single run's execution. It is persisted in
+// cici_runs.deadline_at and re-applied from the database when execution starts.
+const runTimeout = 10 * time.Minute
+
+var (
+ // ErrInvalidTransition is returned when a run attempts an illegal state change.
+ ErrInvalidTransition = errors.New("invalid run status transition")
+ // ErrInvalidRunStatus is returned when a status string is not recognized.
+ ErrInvalidRunStatus = errors.New("invalid run status")
+)
+
+// TransitionError describes a rejected state change.
+type TransitionError struct {
+ From RunStatus
+ To RunStatus
+ Reason string
+}
+
+func (e *TransitionError) Error() string {
+ if e.Reason != "" {
+ return fmt.Sprintf("invalid run status transition %q -> %q: %s", e.From, e.To, e.Reason)
+ }
+ return fmt.Sprintf("invalid run status transition %q -> %q", e.From, e.To)
+}
+
+// Unwrap makes errors.Is(err, ErrInvalidTransition) work for callers.
+func (e *TransitionError) Unwrap() error { return ErrInvalidTransition }
+
+// StatusError describes an unrecognized run status string.
+type StatusError struct {
+ Status string
+}
+
+func (e *StatusError) Error() string { return fmt.Sprintf("invalid run status %q", e.Status) }
+
+// Unwrap makes errors.Is(err, ErrInvalidRunStatus) work for callers.
+func (e *StatusError) Unwrap() error { return ErrInvalidRunStatus }
+
+var runTransitions = map[RunStatus][]RunStatus{
+ RunStatusQueued: {RunStatusRunning, RunStatusFailure, RunStatusCancelled},
+ RunStatusRunning: {RunStatusSuccess, RunStatusFailure, RunStatusCancelled},
+ RunStatusSuccess: nil,
+ RunStatusFailure: nil,
+ RunStatusCancelled: nil,
+}
+
+// NormalizeRunStatus maps the legacy "pending" status to "queued" and
+// lowercases/trims the value. Unknown values are returned normalized but are
+// not validated; use ParseRunStatus or ValidateStatus for that.
+func NormalizeRunStatus(status string) string {
+ normalized := strings.ToLower(strings.TrimSpace(status))
+ if normalized == legacyPendingStatus {
+ return string(RunStatusQueued)
+ }
+ return normalized
+}
+
+// IsTerminal reports whether a status is final and can no longer transition.
+func IsTerminal(status RunStatus) bool {
+ switch status {
+ case RunStatusSuccess, RunStatusFailure, RunStatusCancelled:
+ return true
+ default:
+ return false
+ }
+}
+
+func isKnownRunStatus(status RunStatus) bool {
+ _, ok := runTransitions[status]
+ return ok
+}
+
+// ValidateStatus reports whether status is a recognized run status.
+func ValidateStatus(status RunStatus) error {
+ if !isKnownRunStatus(status) {
+ return &StatusError{Status: string(status)}
+ }
+ return nil
+}
+
+// ParseRunStatus validates a raw status string, treating legacy "pending" as
+// "queued".
+func ParseRunStatus(status string) (RunStatus, error) {
+ parsed := RunStatus(NormalizeRunStatus(status))
+ if err := ValidateStatus(parsed); err != nil {
+ return "", err
+ }
+ return parsed, nil
+}
+
+// CanTransition reports whether moving from one run status to another is valid.
+// Self-transitions are rejected.
+func CanTransition(from, to RunStatus) bool {
+ if from == to {
+ return false
+ }
+ for _, next := range runTransitions[from] {
+ if next == to {
+ return true
+ }
+ }
+ return false
+}
+
+// ValidateTransition returns a typed TransitionError when the transition is not
+// allowed by the run state machine.
+func ValidateTransition(from, to RunStatus) error {
+ if !isKnownRunStatus(from) {
+ return &TransitionError{From: from, To: to, Reason: "unknown source status"}
+ }
+ if !isKnownRunStatus(to) {
+ return &TransitionError{From: from, To: to, Reason: "unknown target status"}
+ }
+ if from == to {
+ return &TransitionError{From: from, To: to, Reason: "already in target status"}
+ }
+ if !CanTransition(from, to) {
+ return &TransitionError{From: from, To: to}
+ }
+ return nil
+}
diff --git a/components/cici/state_test.go b/components/cici/state_test.go
new file mode 100644
index 0000000000..581fad837b
--- /dev/null
+++ b/components/cici/state_test.go
@@ -0,0 +1,108 @@
+package cici_test
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/danielvm/bigbase/components/cici"
+)
+
+func TestStateMachineValidTransitions(t *testing.T) {
+ valid := []struct {
+ from cici.RunStatus
+ to cici.RunStatus
+ }{
+ {cici.RunStatusQueued, cici.RunStatusRunning},
+ {cici.RunStatusQueued, cici.RunStatusCancelled},
+ {cici.RunStatusQueued, cici.RunStatusFailure},
+ {cici.RunStatusRunning, cici.RunStatusSuccess},
+ {cici.RunStatusRunning, cici.RunStatusFailure},
+ {cici.RunStatusRunning, cici.RunStatusCancelled},
+ }
+ for _, tc := range valid {
+ if !cici.CanTransition(tc.from, tc.to) {
+ t.Errorf("CanTransition(%s, %s) = false, want true", tc.from, tc.to)
+ }
+ if err := cici.ValidateTransition(tc.from, tc.to); err != nil {
+ t.Errorf("ValidateTransition(%s, %s) = %v, want nil", tc.from, tc.to, err)
+ }
+ }
+}
+
+func TestStateMachineInvalidTransitions(t *testing.T) {
+ invalid := []struct {
+ from cici.RunStatus
+ to cici.RunStatus
+ }{
+ {cici.RunStatusQueued, cici.RunStatusSuccess},
+ {cici.RunStatusRunning, cici.RunStatusQueued},
+ {cici.RunStatusSuccess, cici.RunStatusRunning},
+ {cici.RunStatusFailure, cici.RunStatusRunning},
+ {cici.RunStatusCancelled, cici.RunStatusSuccess},
+ {cici.RunStatusQueued, cici.RunStatus("bogus")},
+ {cici.RunStatus("bogus"), cici.RunStatusRunning},
+ }
+ for _, tc := range invalid {
+ if cici.CanTransition(tc.from, tc.to) {
+ t.Errorf("CanTransition(%s, %s) = true, want false", tc.from, tc.to)
+ }
+ err := cici.ValidateTransition(tc.from, tc.to)
+ if !errors.Is(err, cici.ErrInvalidTransition) {
+ t.Errorf("ValidateTransition(%s, %s) = %v, want ErrInvalidTransition", tc.from, tc.to, err)
+ }
+ }
+}
+
+func TestStateMachineSelfTransitionRejected(t *testing.T) {
+ for _, s := range []cici.RunStatus{
+ cici.RunStatusQueued,
+ cici.RunStatusRunning,
+ cici.RunStatusSuccess,
+ cici.RunStatusFailure,
+ cici.RunStatusCancelled,
+ } {
+ if cici.CanTransition(s, s) {
+ t.Errorf("CanTransition(%s, %s) = true, want false", s, s)
+ }
+ }
+}
+
+func TestRunStatusNormalizesLegacyPending(t *testing.T) {
+ got, err := cici.ParseRunStatus("pending")
+ if err != nil {
+ t.Fatalf("ParseRunStatus(pending) error: %v", err)
+ }
+ if got != cici.RunStatusQueued {
+ t.Fatalf("ParseRunStatus(pending) = %q, want %q", got, cici.RunStatusQueued)
+ }
+ if got := cici.NormalizeRunStatus("pending"); got != string(cici.RunStatusQueued) {
+ t.Fatalf("NormalizeRunStatus(pending) = %q, want %q", got, cici.RunStatusQueued)
+ }
+ if got := cici.NormalizeRunStatus("running"); got != string(cici.RunStatusRunning) {
+ t.Fatalf("NormalizeRunStatus(running) = %q, want %q", got, cici.RunStatusRunning)
+ }
+}
+
+func TestParseRunStatusRejectsUnknown(t *testing.T) {
+ if _, err := cici.ParseRunStatus("exploded"); !errors.Is(err, cici.ErrInvalidRunStatus) {
+ t.Fatalf("ParseRunStatus(exploded) = %v, want ErrInvalidRunStatus", err)
+ }
+}
+
+func TestTerminalRunStatuses(t *testing.T) {
+ terminal := []cici.RunStatus{
+ cici.RunStatusSuccess,
+ cici.RunStatusFailure,
+ cici.RunStatusCancelled,
+ }
+ for _, s := range terminal {
+ if !cici.IsTerminal(s) {
+ t.Errorf("IsTerminal(%s) = false, want true", s)
+ }
+ }
+ for _, s := range []cici.RunStatus{cici.RunStatusQueued, cici.RunStatusRunning} {
+ if cici.IsTerminal(s) {
+ t.Errorf("IsTerminal(%s) = true, want false", s)
+ }
+ }
+}
diff --git a/components/deploy/deploy.go b/components/deploy/deploy.go
index 9166a5c667..af92e7a7b1 100644
--- a/components/deploy/deploy.go
+++ b/components/deploy/deploy.go
@@ -38,6 +38,9 @@ const (
AppGo AppType = "go"
AppPython AppType = "python"
AppPHP AppType = "php"
+ AppRust AppType = "rust"
+ AppJava AppType = "java"
+ AppRuby AppType = "ruby"
AppStatic AppType = "static"
AppStaticSidecar AppType = "static-sidecar"
)
@@ -45,7 +48,7 @@ const (
// IsValid reports whether the AppType is one of the known application types.
func (a AppType) IsValid() bool {
switch a {
- case AppNode, AppGo, AppPython, AppPHP, AppStatic, AppStaticSidecar:
+ case AppNode, AppGo, AppPython, AppPHP, AppRust, AppJava, AppRuby, AppStatic, AppStaticSidecar:
return true
default:
return false
@@ -55,7 +58,7 @@ func (a AppType) IsValid() bool {
// AllAppTypes returns all known application types. New types should be added
// to the const block above and this function to maintain OCP compliance.
func AllAppTypes() []AppType {
- return []AppType{AppNode, AppGo, AppPython, AppPHP, AppStatic, AppStaticSidecar}
+ return []AppType{AppNode, AppGo, AppPython, AppPHP, AppRust, AppJava, AppRuby, AppStatic, AppStaticSidecar}
}
type Deployment struct {
@@ -121,6 +124,7 @@ type Deploy struct {
diagnosisReader DeployDiagnosisReader
relatedEventsReader DeployRelatedEventsReader
envResolver *EnvResolver
+ reconcileStaleAfter time.Duration
}
var _ kernel.Component = (*Deploy)(nil)
@@ -157,6 +161,9 @@ type Options struct {
DBDriver string
// DBDSN is the platform database connection string passed to deployed apps.
DBDSN string
+ // ReconcileStaleAfter is how old an in-flight deployment (pending/building/
+ // deploying) must be before boot reconciliation marks it failed. Default: 30m.
+ ReconcileStaleAfter time.Duration
}
func New(opts Options) *Deploy {
@@ -197,6 +204,10 @@ func New(opts Options) *Deploy {
if drainTimeout <= 0 {
drainTimeout = 30 * time.Second
}
+ reconcileStaleAfter := opts.ReconcileStaleAfter
+ if reconcileStaleAfter <= 0 {
+ reconcileStaleAfter = defaultReconcileStaleAfter
+ }
d := &Deploy{
db: opts.DB,
logger: logger,
@@ -217,6 +228,7 @@ func New(opts Options) *Deploy {
dbDriver: opts.DBDriver,
dbDSN: opts.DBDSN,
}
+ d.reconcileStaleAfter = reconcileStaleAfter
if len(envKey) > 0 {
d.envKey = append([]byte(nil), envKey...)
}
@@ -345,6 +357,10 @@ func (d *Deploy) Start(ctx *kernel.Context) error {
return fmt.Errorf("migrate deploy_cache_config table: %w", err)
}
d.loadCacheConfig()
+ // Heal rows orphaned by a previous process before restoring routes or
+ // resuming candidates. Boot is the safe point: every in-flight row belongs
+ // to a process that no longer exists.
+ d.reconcileOrphanedDeployments(context.Background())
d.restoreRunningDeploymentHosts()
candidates := d.loadResumeCandidates()
d.logger.Info("deploy component ready", "dir", d.buildsDir, "resume", len(candidates))
diff --git a/components/deploy/deploy_runner.go b/components/deploy/deploy_runner.go
index aa2703bc19..a40497d974 100644
--- a/components/deploy/deploy_runner.go
+++ b/components/deploy/deploy_runner.go
@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"net/http"
- "os"
"os/exec"
"path/filepath"
"strings"
@@ -89,12 +88,13 @@ func (r *deployRunner) buildCmd(ctx context.Context, spec Spec) *exec.Cmd {
name, args := NodeStartCommand(spec.Dir)
return exec.CommandContext(ctx, name, args...)
case AppPHP:
- // Prefer public/ front controller when present (Laravel/Symfony-style).
- docRoot := spec.Dir
- if st, err := os.Stat(filepath.Join(spec.Dir, "public")); err == nil && st.IsDir() {
- docRoot = filepath.Join(spec.Dir, "public")
- }
- return exec.CommandContext(ctx, "php", "-S", fmt.Sprintf("0.0.0.0:%d", spec.Port), "-t", docRoot)
+ return PHPStartCommand(ctx, spec.Dir, spec.Port)
+ case AppRust:
+ return RustStartCommand(ctx, spec.Dir)
+ case AppJava:
+ return JavaStartCommand(ctx, spec.Dir)
+ case AppRuby:
+ return RubyStartCommand(ctx, spec.Dir, spec.Port)
}
return nil
}
diff --git a/components/deploy/deploy_test.go b/components/deploy/deploy_test.go
index 9e4fc0b9ef..9592ffdf67 100644
--- a/components/deploy/deploy_test.go
+++ b/components/deploy/deploy_test.go
@@ -1508,6 +1508,26 @@ func TestDeleteDeployment(t *testing.T) {
}
})
+ t.Run("409 on deploying deployment", func(t *testing.T) {
+ insertDeployment(t, database, "dep-deploying", "deploying", buildsDir)
+ req := httptest.NewRequest("DELETE", "/api/deploy/dep-deploying", nil)
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+ if w.Code != http.StatusConflict {
+ t.Fatalf("expected 409, got %d: %s", w.Code, w.Body.String())
+ }
+ })
+
+ t.Run("409 on draining deployment", func(t *testing.T) {
+ insertDeployment(t, database, "dep-draining", "draining", buildsDir)
+ req := httptest.NewRequest("DELETE", "/api/deploy/dep-draining", nil)
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+ if w.Code != http.StatusConflict {
+ t.Fatalf("expected 409, got %d: %s", w.Code, w.Body.String())
+ }
+ })
+
t.Run("204 deletes failed deployment", func(t *testing.T) {
insertDeployment(t, database, "dep-failed", "failed", buildsDir)
req := httptest.NewRequest("DELETE", "/api/deploy/dep-failed", nil)
diff --git a/components/deploy/engine.go b/components/deploy/engine.go
index f98d3669af..30c615d912 100644
--- a/components/deploy/engine.go
+++ b/components/deploy/engine.go
@@ -458,6 +458,13 @@ func (d *Deploy) buildApp(ctx context.Context, deployID, siteID, repoID, branch,
"Install Go on the deploy host (matching go.mod) before deploying AppType=go.")
}
return d.runBuildCommand(ctx, deployID, buildDir, resolved, "go", "build", "-o", "app", ".")
+ case AppRust:
+ if _, err := exec.LookPath("cargo"); err != nil {
+ return codedErr("tool_missing", "cargo not found on PATH",
+ "Install Rust toolchain (cargo) on the deploy host before deploying AppType=rust.")
+ }
+ name, args := RustBuildCommand(buildDir)
+ return d.runBuildCommand(ctx, deployID, buildDir, resolved, name, args...)
case AppPHP:
if _, err := exec.LookPath("composer"); err != nil {
return codedErr("tool_missing", "composer not found on PATH",
@@ -467,7 +474,31 @@ func (d *Deploy) buildApp(ctx context.Context, deployID, siteID, repoID, branch,
return codedErr("tool_missing", "php not found on PATH",
"Install PHP on the deploy host before deploying AppType=php.")
}
- return d.runBuildCommand(ctx, deployID, buildDir, resolved, "composer", "install", "--no-dev", "--optimize-autoloader")
+ name, args := PHPBuildCommand(buildDir)
+ return d.runBuildCommand(ctx, deployID, buildDir, resolved, name, args...)
+ case AppJava:
+ if fileExists(filepath.Join(buildDir, "pom.xml")) {
+ if _, err := exec.LookPath("mvn"); err != nil {
+ return codedErr("tool_missing", "mvn not found on PATH",
+ "Install Maven (mvn) on the deploy host before deploying AppType=java.")
+ }
+ } else if fileExists(filepath.Join(buildDir, "build.gradle")) || fileExists(filepath.Join(buildDir, "build.gradle.kts")) {
+ if !fileExists(filepath.Join(buildDir, "gradlew")) {
+ if _, err := exec.LookPath("gradle"); err != nil {
+ return codedErr("tool_missing", "gradle not found on PATH",
+ "Install Gradle on the deploy host before deploying AppType=java.")
+ }
+ }
+ }
+ name, args := JavaBuildCommand(buildDir)
+ return d.runBuildCommand(ctx, deployID, buildDir, resolved, name, args...)
+ case AppRuby:
+ if _, err := exec.LookPath("bundle"); err != nil {
+ return codedErr("tool_missing", "bundle not found on PATH",
+ "Install Bundler (bundle) on the deploy host before deploying AppType=ruby.")
+ }
+ name, args := RubyBuildCommand(buildDir)
+ return d.runBuildCommand(ctx, deployID, buildDir, resolved, name, args...)
case AppPython:
if _, err := exec.LookPath("python3"); err != nil {
if _, err2 := exec.LookPath("python"); err2 != nil {
@@ -606,12 +637,13 @@ func (d *Deploy) startApp(ctx context.Context, buildDir string, deploy *Deployme
case AppPython:
cmd = pythonStartCommand(ctx, buildDir, deploy.Port, manifest)
case AppPHP:
- docRoot := buildDir
- if st, err := os.Stat(filepath.Join(buildDir, "public")); err == nil && st.IsDir() {
- docRoot = filepath.Join(buildDir, "public")
- }
- cmd = exec.CommandContext(ctx, "php", "-S", fmt.Sprintf("0.0.0.0:%d", deploy.Port), "-t", docRoot)
- cmd.Dir = buildDir
+ cmd = PHPStartCommand(ctx, buildDir, deploy.Port)
+ case AppRust:
+ cmd = RustStartCommand(ctx, buildDir)
+ case AppJava:
+ cmd = JavaStartCommand(ctx, buildDir)
+ case AppRuby:
+ cmd = RubyStartCommand(ctx, buildDir, deploy.Port)
}
}
@@ -626,10 +658,14 @@ func (d *Deploy) startApp(ctx context.Context, buildDir string, deploy *Deployme
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
// Reserved runtime values are owned by the platform and always win: the
- // allocated port, the writable runtime directory, and the native DB
- // connection. They are applied as the final resolution layer so neither a
- // manifest value nor a secret can hijack them.
- reserved := []string{fmt.Sprintf("PORT=%d", deploy.Port)}
+ // allocated port (injected as both PORT and SERVER_PORT for Spring Boot),
+ // the writable runtime directory, and the native DB connection.
+ // They are applied as the final resolution layer so neither a manifest
+ // value nor a secret can hijack them.
+ reserved := []string{
+ fmt.Sprintf("PORT=%d", deploy.Port),
+ fmt.Sprintf("SERVER_PORT=%d", deploy.Port),
+ }
// Create and inject writable persistent directory for runtime data.
writableDir := filepath.Join(d.buildsDir, "..", "writable", deploy.ID)
diff --git a/components/deploy/framework_mode.go b/components/deploy/framework_mode.go
index f2980c3825..49cbf43d73 100644
--- a/components/deploy/framework_mode.go
+++ b/components/deploy/framework_mode.go
@@ -177,7 +177,7 @@ func ResolveDeployAppType(dir string, explicit AppType) (AppType, string, error)
if !explicit.IsValid() {
return "", "", codedErr("app_type_mismatch",
"invalid app_type",
- "Use one of: static, node, go, python, php, static-sidecar")
+ "Use one of: static, node, go, python, php, rust, java, ruby, static-sidecar")
}
if explicit == AppStaticSidecar {
return explicit, "", nil
diff --git a/components/deploy/gateway.go b/components/deploy/gateway.go
index fc7d574e87..75076044fd 100644
--- a/components/deploy/gateway.go
+++ b/components/deploy/gateway.go
@@ -286,12 +286,12 @@ func (d *Deploy) handleDeleteDeployment(w http.ResponseWriter, r *http.Request,
kernel.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "deployment not found"})
return
}
- if status == "pending" || status == "building" {
+ if !d.sm.CanDelete(status) {
kernel.WriteJSON(w, http.StatusConflict, map[string]string{"error": "deployment is in progress — wait or trigger a new deploy"})
return
}
- d.stopDeployment(id, "replaced")
+ d.stopDeployment(id, string(StateStopped))
_ = os.RemoveAll(filepath.Join(d.buildsDir, cleanID))
d.deleteDeployLogs(id)
diff --git a/components/deploy/manifest.go b/components/deploy/manifest.go
index 02d6a6aecd..1514e32f2d 100644
--- a/components/deploy/manifest.go
+++ b/components/deploy/manifest.go
@@ -86,6 +86,10 @@ var validFrameworks = map[string]bool{
"go": true,
"python": true,
"node": true,
+ "php": true,
+ "rust": true,
+ "java": true,
+ "ruby": true,
}
// manifestToAppType converts a manifest framework string to an AppType.
@@ -97,6 +101,14 @@ func manifestToAppType(m *Manifest) AppType {
return AppGo
case "python":
return AppPython
+ case "php":
+ return AppPHP
+ case "rust":
+ return AppRust
+ case "java":
+ return AppJava
+ case "ruby":
+ return AppRuby
case "static":
return AppStatic
case "static-sidecar":
diff --git a/components/deploy/reconcile.go b/components/deploy/reconcile.go
new file mode 100644
index 0000000000..bccf66f4cc
--- /dev/null
+++ b/components/deploy/reconcile.go
@@ -0,0 +1,126 @@
+package deploy
+
+import (
+ "context"
+ "strings"
+ "time"
+)
+
+// defaultReconcileStaleAfter is how old an in-flight deployment must be before
+// boot reconciliation treats it as orphaned. Kept conservative so a deployment
+// started immediately before a restart is not killed by a slow boot.
+const defaultReconcileStaleAfter = 30 * time.Minute
+
+const (
+ reconcileInFlightMessage = "reconciliation: orphaned by process restart"
+ reconcileRunningMessage = "reconciliation: running deployment has no port"
+)
+
+// reconcileOrphanedDeployments marks deployments left in flight by a previous
+// process as failed. It runs once at boot, before new goroutines start, so any
+// pending/building/deploying row older than the configured threshold is
+// unrecoverable. A running row without a port is always unrecoverable,
+// regardless of age.
+//
+// Every transition goes through TransitionState, so status_history and
+// deploy.state_changed events are recorded. The error_message is persisted
+// before the transition, mirroring failDeployment, so a concurrent reader never
+// observes status=failed with an empty reason.
+//
+// The function is idempotent: once a row is failed it no longer matches the
+// candidate query. It returns the number of rows reconciled.
+func (d *Deploy) reconcileOrphanedDeployments(ctx context.Context) int {
+ threshold := d.reconcileStaleAfter
+ if threshold <= 0 {
+ threshold = defaultReconcileStaleAfter
+ }
+ cutoff := time.Now().UTC().Add(-threshold)
+
+ rows, err := d.db.QueryContext(ctx,
+ `SELECT id, status, COALESCE(created_at, ''), COALESCE(port, 0)
+ FROM deployments
+ WHERE status IN ('pending', 'building', 'deploying', 'draining')
+ OR (status = 'running' AND COALESCE(port, 0) <= 0)`)
+ if err != nil {
+ d.logger.Warn("reconcile orphaned deployments: query", "error", err)
+ return 0
+ }
+
+ type candidate struct {
+ id string
+ reason string
+ }
+ var candidates []candidate
+ for rows.Next() {
+ var id, status, createdAt string
+ var port int
+ if err := rows.Scan(&id, &status, &createdAt, &port); err != nil {
+ continue
+ }
+ if status == string(StateRunning) {
+ // port <= 0 is a broken running row whether or not it is fresh.
+ candidates = append(candidates, candidate{id: id, reason: reconcileRunningMessage})
+ continue
+ }
+ if isStaleDeploymentTime(createdAt, cutoff) {
+ candidates = append(candidates, candidate{id: id, reason: reconcileInFlightMessage})
+ }
+ }
+ _ = rows.Close()
+ if err := rows.Err(); err != nil {
+ d.logger.Warn("reconcile orphaned deployments: iterate", "error", err)
+ return 0
+ }
+
+ reconciled := 0
+ for _, c := range candidates {
+ d.mu.Lock()
+ _, err := d.db.ExecContext(ctx,
+ "UPDATE deployments SET error_message = ? WHERE id = ?", c.reason, c.id)
+ d.mu.Unlock()
+ if err != nil {
+ d.logger.Warn("reconcile orphaned deployments: set error_message", "id", c.id, "error", err)
+ continue
+ }
+ if err := d.TransitionState(ctx, c.id, string(StateFailed)); err != nil {
+ d.logger.Warn("reconcile orphaned deployments: transition failed", "id", c.id, "error", err)
+ continue
+ }
+ reconciled++
+ }
+
+ d.logger.Info("reconciled orphaned deployments", "count", reconciled)
+ return reconciled
+}
+
+// isStaleDeploymentTime reports whether a persisted created_at is older than
+// cutoff. Unknown or unparsable timestamps are treated as stale so a corrupt
+// row cannot pin a site in a non-terminal state forever.
+func isStaleDeploymentTime(createdAt string, cutoff time.Time) bool {
+ ts, ok := parseDeploymentTime(createdAt)
+ if !ok {
+ return true
+ }
+ return ts.Before(cutoff)
+}
+
+// parseDeploymentTime accepts both timestamp formats found in the deployments
+// table: RFC3339 (written by the engine) and SQLite's datetime() default
+// (written by the initial migration default).
+func parseDeploymentTime(raw string) (time.Time, bool) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return time.Time{}, false
+ }
+ for _, layout := range []string{
+ time.RFC3339Nano,
+ time.RFC3339,
+ "2006-01-02 15:04:05.999999999-07:00",
+ "2006-01-02 15:04:05",
+ } {
+ if ts, err := time.Parse(layout, raw); err == nil {
+ return ts.UTC(), true
+ }
+ }
+ return time.Time{}, false
+}
diff --git a/components/deploy/reconcile_test.go b/components/deploy/reconcile_test.go
new file mode 100644
index 0000000000..a4184411dc
--- /dev/null
+++ b/components/deploy/reconcile_test.go
@@ -0,0 +1,203 @@
+package deploy
+
+import (
+ "context"
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/danielvm/bigbase/components/db"
+ "github.com/danielvm/bigbase/kernel"
+)
+
+type reconcileTestLogger struct{}
+
+func (reconcileTestLogger) Info(string, ...any) {}
+func (reconcileTestLogger) Warn(string, ...any) {}
+func (reconcileTestLogger) Error(string, ...any) {}
+func (reconcileTestLogger) Debug(string, ...any) {}
+
+// newReconcileTestDeploy creates a Deploy over an in-memory DB and runs Start
+// once so all migrations (including status_history/error_message) are applied.
+func newReconcileTestDeploy(t *testing.T, staleAfter time.Duration) (*Deploy, *db.DB) {
+ t.Helper()
+ logger := reconcileTestLogger{}
+ database := db.New(db.Options{Path: ":memory:", Logger: logger})
+ if err := database.Start(&kernel.Context{}); err != nil {
+ t.Fatalf("db start: %v", err)
+ }
+ t.Cleanup(func() { _ = database.Stop(&kernel.Context{}) })
+
+ d := New(Options{
+ DB: database,
+ Logger: logger,
+ BuildsDir: t.TempDir(),
+ GitDir: t.TempDir(),
+ ReconcileStaleAfter: staleAfter,
+ })
+ if err := d.Start(&kernel.Context{}); err != nil {
+ t.Fatalf("deploy start: %v", err)
+ }
+ return d, database
+}
+
+func seedDeployment(t *testing.T, database *db.DB, id, status string, port int, createdAt string) {
+ t.Helper()
+ _, err := database.ExecContext(context.Background(),
+ `INSERT INTO deployments (id, repo_id, site_id, branch, status, url, port, app_type, created_at)
+ VALUES (?, 'repo-1', 'site-1', 'main', ?, '', ?, '', ?)`,
+ id, status, port, createdAt)
+ if err != nil {
+ t.Fatalf("seed deployment %s: %v", id, err)
+ }
+}
+
+func seedDeploymentNullPort(t *testing.T, database *db.DB, id, status, createdAt string) {
+ t.Helper()
+ _, err := database.ExecContext(context.Background(),
+ `INSERT INTO deployments (id, repo_id, site_id, branch, status, url, port, app_type, created_at)
+ VALUES (?, 'repo-1', 'site-1', 'main', ?, '', NULL, '', ?)`,
+ id, status, createdAt)
+ if err != nil {
+ t.Fatalf("seed deployment %s: %v", id, err)
+ }
+}
+
+type deploymentSnapshot struct {
+ status string
+ errMsg string
+ history []StatusTransition
+}
+
+func readDeployment(t *testing.T, database *db.DB, id string) deploymentSnapshot {
+ t.Helper()
+ var snap deploymentSnapshot
+ var historyJSON string
+ if err := database.QueryRowContext(context.Background(),
+ `SELECT status, COALESCE(error_message, ''), COALESCE(status_history, '[]')
+ FROM deployments WHERE id = ?`, id).Scan(&snap.status, &snap.errMsg, &historyJSON); err != nil {
+ t.Fatalf("read deployment %s: %v", id, err)
+ }
+ if err := json.Unmarshal([]byte(historyJSON), &snap.history); err != nil {
+ t.Fatalf("parse status_history for %s: %v (raw: %s)", id, err, historyJSON)
+ }
+ return snap
+}
+
+func assertFailedByReconciliation(t *testing.T, snap deploymentSnapshot, id, wantFrom string) {
+ t.Helper()
+ if snap.status != string(StateFailed) {
+ t.Errorf("%s status = %q, want failed", id, snap.status)
+ }
+ if !strings.Contains(snap.errMsg, "reconciliation") {
+ t.Errorf("%s error_message = %q, want a reconciliation reason", id, snap.errMsg)
+ }
+ if len(snap.history) != 1 {
+ t.Fatalf("%s history = %+v, want exactly 1 entry", id, snap.history)
+ }
+ if snap.history[0].From != wantFrom || snap.history[0].To != string(StateFailed) {
+ t.Errorf("%s history[0] = %+v, want %s→failed", id, snap.history[0], wantFrom)
+ }
+}
+
+func TestReconcileOrphanedDeployments(t *testing.T) {
+ d, database := newReconcileTestDeploy(t, 30*time.Minute)
+
+ old := time.Now().UTC().Add(-2 * time.Hour)
+ oldRFC3339 := old.Format(time.RFC3339)
+ oldSQLite := old.Format("2006-01-02 15:04:05")
+ fresh := time.Now().UTC().Format(time.RFC3339)
+
+ seedDeployment(t, database, "stale-pending", "pending", 0, oldRFC3339)
+ seedDeployment(t, database, "stale-building", "building", 0, oldRFC3339)
+ seedDeployment(t, database, "stale-draining", "draining", 0, oldRFC3339)
+ // Mixed timestamp format — the legacy migration default uses SQLite's format.
+ seedDeployment(t, database, "stale-deploying", "deploying", 0, oldSQLite)
+ seedDeployment(t, database, "running-noport", "running", 0, oldRFC3339)
+ seedDeploymentNullPort(t, database, "running-nullport", "running", oldRFC3339)
+ seedDeployment(t, database, "fresh-building", "building", 0, fresh)
+ seedDeployment(t, database, "terminal-failed", "failed", 0, oldRFC3339)
+
+ got := d.reconcileOrphanedDeployments(context.Background())
+ if got != 6 {
+ t.Fatalf("reconciled = %d, want 6", got)
+ }
+
+ assertFailedByReconciliation(t, readDeployment(t, database, "stale-pending"), "stale-pending", "pending")
+ assertFailedByReconciliation(t, readDeployment(t, database, "stale-building"), "stale-building", "building")
+ assertFailedByReconciliation(t, readDeployment(t, database, "stale-draining"), "stale-draining", "draining")
+ assertFailedByReconciliation(t, readDeployment(t, database, "stale-deploying"), "stale-deploying", "deploying")
+ assertFailedByReconciliation(t, readDeployment(t, database, "running-noport"), "running-noport", "running")
+ assertFailedByReconciliation(t, readDeployment(t, database, "running-nullport"), "running-nullport", "running")
+
+ freshSnap := readDeployment(t, database, "fresh-building")
+ if freshSnap.status != string(StateBuilding) {
+ t.Errorf("fresh-building status = %q, want building (fresh rows must be untouched)", freshSnap.status)
+ }
+ if freshSnap.errMsg != "" {
+ t.Errorf("fresh-building error_message = %q, want empty", freshSnap.errMsg)
+ }
+ if len(freshSnap.history) != 0 {
+ t.Errorf("fresh-building history = %+v, want none", freshSnap.history)
+ }
+
+ terminalSnap := readDeployment(t, database, "terminal-failed")
+ if terminalSnap.status != string(StateFailed) {
+ t.Errorf("terminal-failed status = %q, want failed", terminalSnap.status)
+ }
+ if terminalSnap.errMsg != "" {
+ t.Errorf("terminal-failed error_message = %q, want untouched empty", terminalSnap.errMsg)
+ }
+ if len(terminalSnap.history) != 0 {
+ t.Errorf("terminal-failed history = %+v, want untouched", terminalSnap.history)
+ }
+}
+
+func TestReconcileIsIdempotent(t *testing.T) {
+ d, database := newReconcileTestDeploy(t, 30*time.Minute)
+
+ old := time.Now().UTC().Add(-2 * time.Hour).Format(time.RFC3339)
+ seedDeployment(t, database, "stale-building", "building", 0, old)
+
+ if got := d.reconcileOrphanedDeployments(context.Background()); got != 1 {
+ t.Fatalf("first reconcile = %d, want 1", got)
+ }
+ first := readDeployment(t, database, "stale-building")
+ assertFailedByReconciliation(t, first, "stale-building", "building")
+
+ if got := d.reconcileOrphanedDeployments(context.Background()); got != 0 {
+ t.Fatalf("second reconcile = %d, want 0 (idempotent)", got)
+ }
+ second := readDeployment(t, database, "stale-building")
+ if second.status != first.status || second.errMsg != first.errMsg {
+ t.Errorf("second reconcile changed the row: first=%+v second=%+v", first, second)
+ }
+ if len(second.history) != 1 {
+ t.Fatalf("history after second reconcile = %+v, want no duplicate entry", second.history)
+ }
+}
+
+// TestReconcileOnStart proves the reconciliation is wired into Deploy.Start,
+// not just callable directly: stale rows seeded behind a running Deploy are
+// healed when a fresh Deploy starts over the same database.
+func TestReconcileOnStart(t *testing.T) {
+ _, database := newReconcileTestDeploy(t, 30*time.Minute)
+
+ old := time.Now().UTC().Add(-2 * time.Hour).Format(time.RFC3339)
+ seedDeployment(t, database, "stale-building", "building", 0, old)
+
+ restarted := New(Options{
+ DB: database,
+ Logger: reconcileTestLogger{},
+ BuildsDir: t.TempDir(),
+ GitDir: t.TempDir(),
+ ReconcileStaleAfter: 30 * time.Minute,
+ })
+ if err := restarted.Start(&kernel.Context{}); err != nil {
+ t.Fatalf("restarted deploy start: %v", err)
+ }
+ t.Cleanup(func() { _ = restarted.Stop(&kernel.Context{}) })
+
+ assertFailedByReconciliation(t, readDeployment(t, database, "stale-building"), "stale-building", "building")
+}
diff --git a/components/deploy/rollback.go b/components/deploy/rollback.go
index 48db3ec304..a71fc0fc20 100644
--- a/components/deploy/rollback.go
+++ b/components/deploy/rollback.go
@@ -2,7 +2,6 @@ package deploy
import (
"context"
- "encoding/json"
"errors"
"fmt"
"net/http"
@@ -54,19 +53,27 @@ func (d *Deploy) Rollback(ctx context.Context, currentID string) (*RollbackEvent
return nil, fmt.Errorf("can only rollback a running deployment (current status: %s)", currentStatus)
}
- // Fetch previous successful deployment for the same site
- // Exclude current, failed, pending, and building deployments.
+ // Fetch the most recent successful deployment for the same site.
+ // "Successful" excludes failed builds, previously rolled-back deployments, and every
+ // in-flight state (pending, building, deploying, draining) — the exclusion list
+ // comes from the state machine so it cannot drift (#304).
type prevRow struct {
id, status, appType, url, passthroughJSON, commitSHA string
port int
}
- var prev prevRow
- err = d.db.QueryRowContext(ctx,
- `SELECT id, status, port, app_type, COALESCE(url,''), COALESCE(passthrough_paths,''), COALESCE(commit_sha,'')
+ excluded := d.sm.InFlightStatuses()
+ placeholders := strings.TrimSuffix(strings.Repeat("?,", len(excluded)), ",")
+ query := `SELECT id, status, port, app_type, COALESCE(url,''), COALESCE(passthrough_paths,''), COALESCE(commit_sha,'')
FROM deployments
- WHERE site_id = ? AND id != ? AND status NOT IN ('failed', 'pending', 'building')
- ORDER BY created_at DESC LIMIT 1`,
- currentSiteID, currentID).Scan(&prev.id, &prev.status, &prev.port, &prev.appType, &prev.url, &prev.passthroughJSON, &prev.commitSHA)
+ WHERE site_id = ? AND id != ? AND status NOT IN (?, ?, ` + placeholders + `)
+ ORDER BY created_at DESC LIMIT 1`
+ args := make([]any, 0, 4+len(excluded))
+ args = append(args, currentSiteID, currentID, string(StateFailed), string(StateRolledBack))
+ for _, status := range excluded {
+ args = append(args, status)
+ }
+ var prev prevRow
+ err = d.db.QueryRowContext(ctx, query, args...).Scan(&prev.id, &prev.status, &prev.port, &prev.appType, &prev.url, &prev.passthroughJSON, &prev.commitSHA)
if err != nil {
return nil, fmt.Errorf("no previous successful deployment found for site %s", currentSiteID)
}
@@ -81,11 +88,10 @@ func (d *Deploy) Rollback(ctx context.Context, currentID string) (*RollbackEvent
d.stopDeploymentWithTransition(ctx, currentID, string(StateRolledBack))
d.appendDeployLog(currentID, fmt.Sprintf("→ Rolled back — replacing with deployment %s", prev.id[:min(8, len(prev.id))]))
- // Update the old deployment's status to running (it was in "replaced" state)
- // Use updateStatus which calls TransitionState — this works because
- // TransitionState has lenient mode for unknown statuses like "replaced".
+ // Restore the old deployment through the state machine. A drained
+ // deployment is stopped; stopped → running is the documented rollback
+ // transition (see StateMachine), so no lenient/unknown-state path is used.
d.updateStatus(prev.id, string(StateRunning))
- d.updateStatusHistory(ctx, prev.id, prev.status, string(StateRunning))
d.appendDeployLog(prev.id, "→ Rolled back — deployment restored as live")
// Re-register the host with the old deployment's port
@@ -206,36 +212,6 @@ func (d *Deploy) stopDeploymentWithTransition(ctx context.Context, id, newStatus
}
}
-// updateStatusHistory appends a transition record to the deployment's
-// status_history JSON without going through TransitionState validation.
-// Used for rollback where the status change crosses states not in the
-// state machine (e.g., "replaced" → "running").
-func (d *Deploy) updateStatusHistory(ctx context.Context, id, from, to string) {
- var current string
- if err := d.db.QueryRowContext(ctx,
- "SELECT status_history FROM deployments WHERE id = ?", id).Scan(¤t); err != nil {
- return
- }
- if current == "" {
- current = "[]"
- }
- var history []StatusTransition
- if err := json.Unmarshal([]byte(current), &history); err != nil {
- history = nil
- }
- history = append(history, StatusTransition{
- From: from,
- To: to,
- Timestamp: time.Now().UTC().Format(time.RFC3339),
- })
- data, err := json.Marshal(history)
- if err != nil {
- return
- }
- _, _ = d.db.ExecContext(ctx,
- "UPDATE deployments SET status_history = ? WHERE id = ?", string(data), id)
-}
-
// handleRollbackEvents returns all rollback events for a site.
// GET /api/deploy/{siteID}/rollback-events
func (d *Deploy) handleRollbackEvents(w http.ResponseWriter, r *http.Request, siteID string) {
diff --git a/components/deploy/rollback_test.go b/components/deploy/rollback_test.go
index 8cf22467b1..aa7bd4f637 100644
--- a/components/deploy/rollback_test.go
+++ b/components/deploy/rollback_test.go
@@ -2,11 +2,19 @@ package deploy_test
import (
"bytes"
+ "context"
"encoding/json"
"net/http"
"net/http/httptest"
+ "os"
+ "path/filepath"
"testing"
"time"
+
+ "github.com/danielvm/bigbase/components/db"
+ "github.com/danielvm/bigbase/components/deploy"
+ "github.com/danielvm/bigbase/components/git"
+ "github.com/danielvm/bigbase/kernel"
)
func TestRollbackStaticSite(t *testing.T) {
@@ -157,6 +165,84 @@ func TestRollbackNotFound(t *testing.T) {
}
}
+// TestRollbackSkipsInFlightDeployments is the #304 regression: an in-flight
+// deployment (deploying / draining) must never be chosen as the "previous
+// successful" rollback target, even when it is the most recent row.
+func TestRollbackSkipsInFlightDeployments(t *testing.T) {
+ logger := testLogger{}
+ k := kernel.New(logger)
+ database := db.New(db.Options{Path: ":memory:", Logger: logger})
+ buildsDir := t.TempDir()
+
+ dep := deploy.New(deploy.Options{
+ DB: database,
+ Logger: logger,
+ BuildsDir: buildsDir,
+ GitDir: t.TempDir(),
+ })
+ gitDir := t.TempDir()
+ gitComp := git.New(git.Options{DB: database, Logger: logger, Dir: gitDir})
+ k.Register(database)
+ k.Register(gitComp)
+ k.Register(dep)
+ if err := k.Start(); err != nil {
+ t.Fatalf("kernel start: %v", err)
+ }
+ t.Cleanup(func() { _ = k.Stop() })
+
+ const siteID = "site-rollback-filter"
+ insert := func(id, status, createdAt string) {
+ t.Helper()
+ _, err := database.ExecContext(context.Background(),
+ `INSERT INTO deployments (id, repo_id, site_id, status, port, app_type, created_at)
+ VALUES (?, 'repo-rf', ?, ?, 0, 'static', ?)`,
+ id, siteID, status, createdAt)
+ if err != nil {
+ t.Fatalf("insert %s: %v", id, err)
+ }
+ }
+ insert("dep-current", string(deploy.StateRunning), "2026-01-01T00:00:06Z")
+ insert("dep-deploying", string(deploy.StateDeploying), "2026-01-01T00:00:05Z")
+ insert("dep-draining", string(deploy.StateDraining), "2026-01-01T00:00:04Z")
+ insert("dep-rolled-back", string(deploy.StateRolledBack), "2026-01-01T00:00:03Z")
+ insert("dep-stopped", string(deploy.StateStopped), "2026-01-01T00:00:02Z")
+ insert("dep-failed", string(deploy.StateFailed), "2026-01-01T00:00:01Z")
+
+ // The stopped deployment is the only successful candidate and needs
+ // on-disk artifacts for rollback to restore it.
+ distDir := filepath.Join(buildsDir, "dep-stopped", "dist")
+ if err := os.MkdirAll(distDir, 0755); err != nil {
+ t.Fatalf("create dist dir: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(distDir, "index.html"), []byte("
old
"), 0644); err != nil {
+ t.Fatalf("write index.html: %v", err)
+ }
+
+ event, err := dep.Rollback(context.Background(), "dep-current")
+ if err != nil {
+ t.Fatalf("rollback: %v", err)
+ }
+ if event.RolledBackFrom != "dep-stopped" {
+ t.Fatalf("rollback target = %q, want dep-stopped (in-flight rows must be skipped)", event.RolledBackFrom)
+ }
+ verifyDeploymentStatusRow(t, database, "dep-stopped", "running")
+ verifyDeploymentStatusRow(t, database, "dep-rolled-back", "rolled_back")
+ verifyDeploymentStatusRow(t, database, "dep-deploying", "deploying")
+ verifyDeploymentStatusRow(t, database, "dep-draining", "draining")
+}
+
+func verifyDeploymentStatusRow(t *testing.T, database *db.DB, id, want string) {
+ t.Helper()
+ var status string
+ if err := database.QueryRowContext(context.Background(),
+ "SELECT status FROM deployments WHERE id = ?", id).Scan(&status); err != nil {
+ t.Fatalf("query status for %s: %v", id, err)
+ }
+ if status != want {
+ t.Fatalf("deployment %s status = %q, want %q", id, status, want)
+ }
+}
+
// verifyDeployStatus checks that a deployment has the expected status.
func verifyDeployStatus(t *testing.T, handler http.Handler, depID, expected string) {
t.Helper()
diff --git a/components/deploy/runtimes.go b/components/deploy/runtimes.go
new file mode 100644
index 0000000000..8c35ffe85e
--- /dev/null
+++ b/components/deploy/runtimes.go
@@ -0,0 +1,194 @@
+package deploy
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+)
+
+// ManifestProfile defines the standard build and run configuration for a runtime.
+type ManifestProfile struct {
+ AppType AppType
+ BuildCmd string
+ RunCmd string
+ Port int
+}
+
+// DefaultProfile returns the standard bigbase.toml profile defaults for a given runtime.
+func DefaultProfile(appType AppType) ManifestProfile {
+ switch appType {
+ case AppRust:
+ return ManifestProfile{
+ AppType: AppRust,
+ BuildCmd: "cargo build --release",
+ RunCmd: "./target/release/app",
+ Port: 8080,
+ }
+ case AppPHP:
+ return ManifestProfile{
+ AppType: AppPHP,
+ BuildCmd: "composer install --no-dev",
+ RunCmd: "php -S 0.0.0.0:$PORT",
+ Port: 8080,
+ }
+ case AppJava:
+ return ManifestProfile{
+ AppType: AppJava,
+ BuildCmd: "mvn package -DskipTests",
+ RunCmd: "java -jar target/app.jar",
+ Port: 8080,
+ }
+ case AppRuby:
+ return ManifestProfile{
+ AppType: AppRuby,
+ BuildCmd: "bundle install --deployment",
+ RunCmd: "bundle exec ruby app.rb",
+ Port: 8080,
+ }
+ case AppGo:
+ return ManifestProfile{
+ AppType: AppGo,
+ BuildCmd: "go build -o app .",
+ RunCmd: "./app",
+ Port: 8080,
+ }
+ case AppNode:
+ return ManifestProfile{
+ AppType: AppNode,
+ BuildCmd: "npm install && npm run build",
+ RunCmd: "npm start",
+ Port: 3000,
+ }
+ case AppPython:
+ return ManifestProfile{
+ AppType: AppPython,
+ BuildCmd: "pip install -r requirements.txt",
+ RunCmd: "python main.py",
+ Port: 8000,
+ }
+ default:
+ return ManifestProfile{
+ AppType: AppStatic,
+ BuildCmd: "",
+ RunCmd: "",
+ Port: 80,
+ }
+ }
+}
+
+// RustBuildCommand returns the command and arguments for building a Rust application.
+func RustBuildCommand(dir string) (string, []string) {
+ return "cargo", []string{"build", "--release"}
+}
+
+// RustStartCommand returns the exec.Cmd for starting a compiled Rust binary.
+func RustStartCommand(ctx context.Context, dir string) *exec.Cmd {
+ binPath := rustBinaryPath(dir)
+ cmd := exec.CommandContext(ctx, binPath)
+ cmd.Dir = dir
+ return cmd
+}
+
+func rustBinaryPath(dir string) string {
+ releaseDir := filepath.Join(dir, "target", "release")
+ if entries, err := os.ReadDir(releaseDir); err == nil {
+ for _, e := range entries {
+ if e.IsDir() {
+ continue
+ }
+ name := e.Name()
+ if strings.HasSuffix(name, ".d") || strings.HasSuffix(name, ".rlib") || strings.HasPrefix(name, ".") {
+ continue
+ }
+ info, err := e.Info()
+ if err == nil && (info.Mode()&0111 != 0 || !strings.Contains(name, ".")) {
+ return filepath.Join(releaseDir, name)
+ }
+ }
+ }
+ return filepath.Join(releaseDir, "app")
+}
+
+// PHPBuildCommand returns the command and arguments for installing PHP dependencies.
+func PHPBuildCommand(dir string) (string, []string) {
+ return "composer", []string{"install", "--no-dev", "--optimize-autoloader", "--no-interaction"}
+}
+
+// PHPStartCommand returns the exec.Cmd for running PHP with the built-in web server.
+func PHPStartCommand(ctx context.Context, dir string, port int) *exec.Cmd {
+ docRoot := dir
+ if st, err := os.Stat(filepath.Join(dir, "public")); err == nil && st.IsDir() {
+ docRoot = filepath.Join(dir, "public")
+ }
+ cmd := exec.CommandContext(ctx, "php", "-S", fmt.Sprintf("0.0.0.0:%d", port), "-t", docRoot)
+ cmd.Dir = dir
+ return cmd
+}
+
+// JavaBuildCommand returns the command and arguments for building a Java application.
+func JavaBuildCommand(dir string) (string, []string) {
+ if fileExists(filepath.Join(dir, "pom.xml")) {
+ return "mvn", []string{"package", "-DskipTests"}
+ }
+ if fileExists(filepath.Join(dir, "gradlew")) {
+ return "./gradlew", []string{"build", "-x", "test"}
+ }
+ return "gradle", []string{"build", "-x", "test"}
+}
+
+// JavaStartCommand returns the exec.Cmd for running a compiled Java JAR.
+func JavaStartCommand(ctx context.Context, dir string) *exec.Cmd {
+ jarPath := javaJarPath(dir)
+ cmd := exec.CommandContext(ctx, "java", "-jar", jarPath)
+ cmd.Dir = dir
+ return cmd
+}
+
+func javaJarPath(dir string) string {
+ if fileExists(filepath.Join(dir, "app.jar")) {
+ return filepath.Join(dir, "app.jar")
+ }
+ targetDir := filepath.Join(dir, "target")
+ if entries, err := os.ReadDir(targetDir); err == nil {
+ for _, e := range entries {
+ if !e.IsDir() && strings.HasSuffix(e.Name(), ".jar") &&
+ !strings.HasSuffix(e.Name(), "-sources.jar") &&
+ !strings.HasSuffix(e.Name(), "-javadoc.jar") &&
+ !strings.HasSuffix(e.Name(), "-plain.jar") {
+ return filepath.Join(targetDir, e.Name())
+ }
+ }
+ }
+ buildLibsDir := filepath.Join(dir, "build", "libs")
+ if entries, err := os.ReadDir(buildLibsDir); err == nil {
+ for _, e := range entries {
+ if !e.IsDir() && strings.HasSuffix(e.Name(), ".jar") &&
+ !strings.HasSuffix(e.Name(), "-sources.jar") &&
+ !strings.HasSuffix(e.Name(), "-javadoc.jar") &&
+ !strings.HasSuffix(e.Name(), "-plain.jar") {
+ return filepath.Join(buildLibsDir, e.Name())
+ }
+ }
+ }
+ return filepath.Join(targetDir, "app.jar")
+}
+
+// RubyBuildCommand returns the command and arguments for installing Ruby dependencies.
+func RubyBuildCommand(dir string) (string, []string) {
+ return "bundle", []string{"install", "--deployment"}
+}
+
+// RubyStartCommand returns the exec.Cmd for running a Ruby application.
+func RubyStartCommand(ctx context.Context, dir string, port int) *exec.Cmd {
+ var cmd *exec.Cmd
+ if fileExists(filepath.Join(dir, "config.ru")) {
+ cmd = exec.CommandContext(ctx, "bundle", "exec", "rackup", "-p", fmt.Sprintf("%d", port), "-o", "0.0.0.0")
+ } else {
+ cmd = exec.CommandContext(ctx, "bundle", "exec", "ruby", "app.rb")
+ }
+ cmd.Dir = dir
+ return cmd
+}
diff --git a/components/deploy/runtimes_test.go b/components/deploy/runtimes_test.go
new file mode 100644
index 0000000000..bb483a370a
--- /dev/null
+++ b/components/deploy/runtimes_test.go
@@ -0,0 +1,432 @@
+package deploy
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestDetectRust(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "Cargo.toml"), []byte("[package]\nname = \"foo\"\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if got := DetectAppType(dir); got != AppRust {
+ t.Fatalf("expected AppRust, got %s", got)
+ }
+}
+
+func TestBuildRust(t *testing.T) {
+ dir := t.TempDir()
+ cmd, args := RustBuildCommand(dir)
+ if cmd != "cargo" {
+ t.Errorf("RustBuildCommand cmd = %q, want cargo", cmd)
+ }
+ wantArgs := []string{"build", "--release"}
+ if strings.Join(args, " ") != strings.Join(wantArgs, " ") {
+ t.Errorf("RustBuildCommand args = %v, want %v", args, wantArgs)
+ }
+}
+
+func TestRunRust(t *testing.T) {
+ dir := t.TempDir()
+ releaseDir := filepath.Join(dir, "target", "release")
+ if err := os.MkdirAll(releaseDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ binFile := filepath.Join(releaseDir, "myservice")
+ if err := os.WriteFile(binFile, []byte("#!/bin/sh\nexit 0\n"), 0755); err != nil {
+ t.Fatal(err)
+ }
+
+ cmd := RustStartCommand(context.Background(), dir)
+ if cmd == nil {
+ t.Fatal("expected non-nil cmd from RustStartCommand")
+ }
+ if cmd.Path != binFile && !strings.HasSuffix(cmd.Path, "myservice") {
+ t.Errorf("RustStartCommand path = %q, want %q", cmd.Path, binFile)
+ }
+}
+
+func TestDetectPHP(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(`{"name":"app/php"}`), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if got := DetectAppType(dir); got != AppPHP {
+ t.Fatalf("expected AppPHP, got %s", got)
+ }
+}
+
+func TestBuildPHP(t *testing.T) {
+ dir := t.TempDir()
+ cmd, args := PHPBuildCommand(dir)
+ if cmd != "composer" {
+ t.Errorf("PHPBuildCommand cmd = %q, want composer", cmd)
+ }
+ wantArgs := []string{"install", "--no-dev", "--optimize-autoloader", "--no-interaction"}
+ if strings.Join(args, " ") != strings.Join(wantArgs, " ") {
+ t.Errorf("PHPBuildCommand args = %v, want %v", args, wantArgs)
+ }
+}
+
+func TestRunPHP(t *testing.T) {
+ t.Run("with public docroot", func(t *testing.T) {
+ dir := t.TempDir()
+ publicDir := filepath.Join(dir, "public")
+ if err := os.MkdirAll(publicDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ cmd := PHPStartCommand(context.Background(), dir, 8080)
+ if cmd == nil {
+ t.Fatal("expected non-nil cmd from PHPStartCommand")
+ }
+ foundPort := false
+ foundDocRoot := false
+ for _, arg := range cmd.Args {
+ if arg == "0.0.0.0:8080" {
+ foundPort = true
+ }
+ if arg == publicDir {
+ foundDocRoot = true
+ }
+ }
+ if !foundPort {
+ t.Errorf("expected 0.0.0.0:8080 in PHP args: %v", cmd.Args)
+ }
+ if !foundDocRoot {
+ t.Errorf("expected public dir in PHP args: %v", cmd.Args)
+ }
+ })
+
+ t.Run("without public docroot", func(t *testing.T) {
+ dir := t.TempDir()
+ cmd := PHPStartCommand(context.Background(), dir, 8080)
+ if cmd == nil {
+ t.Fatal("expected non-nil cmd from PHPStartCommand")
+ }
+ foundDocRoot := false
+ for _, arg := range cmd.Args {
+ if arg == dir {
+ foundDocRoot = true
+ }
+ }
+ if !foundDocRoot {
+ t.Errorf("expected base dir in PHP args: %v", cmd.Args)
+ }
+ })
+}
+
+func TestDetectJava(t *testing.T) {
+ t.Run("pom.xml", func(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "pom.xml"), []byte(""), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if got := DetectAppType(dir); got != AppJava {
+ t.Fatalf("expected AppJava for pom.xml, got %s", got)
+ }
+ })
+
+ t.Run("build.gradle", func(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "build.gradle"), []byte("plugins {}"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if got := DetectAppType(dir); got != AppJava {
+ t.Fatalf("expected AppJava for build.gradle, got %s", got)
+ }
+ })
+
+ t.Run("build.gradle.kts", func(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "build.gradle.kts"), []byte("plugins {}"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if got := DetectAppType(dir); got != AppJava {
+ t.Fatalf("expected AppJava for build.gradle.kts, got %s", got)
+ }
+ })
+}
+
+func TestBuildJava(t *testing.T) {
+ t.Run("maven", func(t *testing.T) {
+ dir := t.TempDir()
+ _ = os.WriteFile(filepath.Join(dir, "pom.xml"), []byte(""), 0644)
+ cmd, args := JavaBuildCommand(dir)
+ if cmd != "mvn" {
+ t.Errorf("JavaBuildCommand maven cmd = %q, want mvn", cmd)
+ }
+ want := "package -DskipTests"
+ if strings.Join(args, " ") != want {
+ t.Errorf("JavaBuildCommand maven args = %v, want %v", args, want)
+ }
+ })
+
+ t.Run("gradlew", func(t *testing.T) {
+ dir := t.TempDir()
+ _ = os.WriteFile(filepath.Join(dir, "build.gradle"), []byte(""), 0644)
+ _ = os.WriteFile(filepath.Join(dir, "gradlew"), []byte("#!/bin/sh\n"), 0755)
+ cmd, args := JavaBuildCommand(dir)
+ if cmd != "./gradlew" {
+ t.Errorf("JavaBuildCommand gradlew cmd = %q, want ./gradlew", cmd)
+ }
+ want := "build -x test"
+ if strings.Join(args, " ") != want {
+ t.Errorf("JavaBuildCommand gradlew args = %v, want %v", args, want)
+ }
+ })
+
+ t.Run("gradle", func(t *testing.T) {
+ dir := t.TempDir()
+ _ = os.WriteFile(filepath.Join(dir, "build.gradle"), []byte(""), 0644)
+ cmd, args := JavaBuildCommand(dir)
+ if cmd != "gradle" {
+ t.Errorf("JavaBuildCommand gradle cmd = %q, want gradle", cmd)
+ }
+ want := "build -x test"
+ if strings.Join(args, " ") != want {
+ t.Errorf("JavaBuildCommand gradle args = %v, want %v", args, want)
+ }
+ })
+}
+
+func TestRunJava(t *testing.T) {
+ t.Run("target jar", func(t *testing.T) {
+ dir := t.TempDir()
+ targetDir := filepath.Join(dir, "target")
+ _ = os.MkdirAll(targetDir, 0755)
+ jarFile := filepath.Join(targetDir, "myservice.jar")
+ _ = os.WriteFile(jarFile, []byte("PK"), 0644)
+
+ cmd := JavaStartCommand(context.Background(), dir)
+ if cmd == nil {
+ t.Fatal("expected non-nil cmd from JavaStartCommand")
+ }
+ foundJar := false
+ for _, arg := range cmd.Args {
+ if arg == jarFile {
+ foundJar = true
+ }
+ }
+ if !foundJar {
+ t.Errorf("expected jar path in java args: %v", cmd.Args)
+ }
+ })
+
+ t.Run("build/libs jar", func(t *testing.T) {
+ dir := t.TempDir()
+ libsDir := filepath.Join(dir, "build", "libs")
+ _ = os.MkdirAll(libsDir, 0755)
+ jarFile := filepath.Join(libsDir, "myservice.jar")
+ _ = os.WriteFile(jarFile, []byte("PK"), 0644)
+
+ cmd := JavaStartCommand(context.Background(), dir)
+ if cmd == nil {
+ t.Fatal("expected non-nil cmd from JavaStartCommand")
+ }
+ foundJar := false
+ for _, arg := range cmd.Args {
+ if arg == jarFile {
+ foundJar = true
+ }
+ }
+ if !foundJar {
+ t.Errorf("expected jar path in java args: %v", cmd.Args)
+ }
+ })
+
+ t.Run("skips plain jar", func(t *testing.T) {
+ dir := t.TempDir()
+ libsDir := filepath.Join(dir, "build", "libs")
+ _ = os.MkdirAll(libsDir, 0755)
+ _ = os.WriteFile(filepath.Join(libsDir, "app-0.0.1-plain.jar"), []byte("PK"), 0644)
+ _ = os.WriteFile(filepath.Join(libsDir, "app-0.0.1.jar"), []byte("PK"), 0644)
+
+ cmd := JavaStartCommand(context.Background(), dir)
+ if cmd == nil {
+ t.Fatal("expected non-nil cmd from JavaStartCommand")
+ }
+ wantJar := filepath.Join(libsDir, "app-0.0.1.jar")
+ if cmd.Args[2] != wantJar {
+ t.Errorf("got %s, want %s (must skip -plain.jar)", cmd.Args[2], wantJar)
+ }
+ })
+}
+
+func TestDetectRuby(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "Gemfile"), []byte("source 'https://rubygems.org'\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if got := DetectAppType(dir); got != AppRuby {
+ t.Fatalf("expected AppRuby, got %s", got)
+ }
+}
+
+func TestBuildRuby(t *testing.T) {
+ dir := t.TempDir()
+ cmd, args := RubyBuildCommand(dir)
+ if cmd != "bundle" {
+ t.Errorf("RubyBuildCommand cmd = %q, want bundle", cmd)
+ }
+ want := "install --deployment"
+ if strings.Join(args, " ") != want {
+ t.Errorf("RubyBuildCommand args = %v, want %v", args, want)
+ }
+}
+
+func TestRunRuby(t *testing.T) {
+ t.Run("with config.ru", func(t *testing.T) {
+ dir := t.TempDir()
+ _ = os.WriteFile(filepath.Join(dir, "config.ru"), []byte("run App\n"), 0644)
+ cmd := RubyStartCommand(context.Background(), dir, 8080)
+ if cmd == nil {
+ t.Fatal("expected non-nil cmd from RubyStartCommand")
+ }
+ foundRackup := false
+ for _, arg := range cmd.Args {
+ if arg == "rackup" {
+ foundRackup = true
+ }
+ }
+ if !foundRackup {
+ t.Errorf("expected rackup in ruby args: %v", cmd.Args)
+ }
+ })
+
+ t.Run("with app.rb default", func(t *testing.T) {
+ dir := t.TempDir()
+ cmd := RubyStartCommand(context.Background(), dir, 8080)
+ if cmd == nil {
+ t.Fatal("expected non-nil cmd from RubyStartCommand")
+ }
+ foundAppRb := false
+ for _, arg := range cmd.Args {
+ if arg == "app.rb" {
+ foundAppRb = true
+ }
+ }
+ if !foundAppRb {
+ t.Errorf("expected app.rb in ruby args: %v", cmd.Args)
+ }
+ })
+}
+
+func TestAllAppTypes(t *testing.T) {
+ expected := []AppType{
+ AppNode,
+ AppGo,
+ AppPython,
+ AppPHP,
+ AppRust,
+ AppJava,
+ AppRuby,
+ AppStatic,
+ AppStaticSidecar,
+ }
+
+ all := AllAppTypes()
+ if len(all) != len(expected) {
+ t.Fatalf("AllAppTypes() len = %d, want %d", len(all), len(expected))
+ }
+
+ for _, at := range expected {
+ found := false
+ for _, a := range all {
+ if a == at {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("expected AppType %q in AllAppTypes()", at)
+ }
+ if !at.IsValid() {
+ t.Errorf("expected AppType %q to be valid", at)
+ }
+ }
+}
+
+func TestManifestProfiles(t *testing.T) {
+ runtimes := []AppType{
+ AppRust,
+ AppPHP,
+ AppJava,
+ AppRuby,
+ AppGo,
+ AppNode,
+ AppPython,
+ }
+
+ for _, rt := range runtimes {
+ profile := DefaultProfile(rt)
+ if profile.AppType != rt {
+ t.Errorf("profile.AppType = %q, want %q", profile.AppType, rt)
+ }
+ if profile.BuildCmd == "" {
+ t.Errorf("profile.BuildCmd is empty for %q", rt)
+ }
+ if profile.RunCmd == "" {
+ t.Errorf("profile.RunCmd is empty for %q", rt)
+ }
+ if profile.Port <= 0 {
+ t.Errorf("profile.Port = %d for %q, want > 0", profile.Port, rt)
+ }
+ }
+
+ // Test manifest framework parsing for each new runtime
+ manifestTests := []struct {
+ toml string
+ want AppType
+ }{
+ {`version = 1
+framework = "rust"
+[build]
+command = "cargo build --release"
+[start]
+command = "./target/release/app"
+port = 8080
+`, AppRust},
+ {`version = 1
+framework = "php"
+[build]
+command = "composer install --no-dev"
+[start]
+command = "php -S 0.0.0.0:8080"
+port = 8080
+`, AppPHP},
+ {`version = 1
+framework = "java"
+[build]
+command = "mvn package -DskipTests"
+[start]
+command = "java -jar target/app.jar"
+port = 8080
+`, AppJava},
+ {`version = 1
+framework = "ruby"
+[build]
+command = "bundle install --deployment"
+[start]
+command = "bundle exec ruby app.rb"
+port = 8080
+`, AppRuby},
+ }
+
+ for _, mt := range manifestTests {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "bigbase.toml"), []byte(mt.toml), 0644); err != nil {
+ t.Fatal(err)
+ }
+ m, err := LoadManifest(dir)
+ if err != nil {
+ t.Fatalf("LoadManifest(%q): %v", mt.toml, err)
+ }
+ if got := manifestToAppType(m); got != mt.want {
+ t.Errorf("manifestToAppType = %q, want %q for %s", got, mt.want, mt.toml)
+ }
+ }
+}
diff --git a/components/deploy/security_regression_test.go b/components/deploy/security_regression_test.go
index 5cb41db871..0a68396372 100644
--- a/components/deploy/security_regression_test.go
+++ b/components/deploy/security_regression_test.go
@@ -37,11 +37,41 @@ func TestCommandInjection(t *testing.T) {
}
}
+// TestPathTraversal guards ResolveAppRoot and deploy paths against path traversal attacks (CWE-22).
+func TestPathTraversal(t *testing.T) {
+ dir := t.TempDir()
+ child := filepath.Join(dir, "child")
+ if err := os.MkdirAll(child, 0755); err != nil {
+ t.Fatal(err)
+ }
+
+ traversalRoots := []string{
+ "../",
+ "../../",
+ "../escape",
+ "/etc/passwd",
+ "child/../../escape",
+ }
+
+ for _, p := range traversalRoots {
+ got := ResolveAppRoot(dir, p)
+ if got != dir {
+ t.Errorf("ResolveAppRoot(%q, %q) = %q, want base dir %q", dir, p, got, dir)
+ }
+ }
+
+ // Clean nested path within dir should resolve properly
+ got := ResolveAppRoot(dir, "child")
+ if got != child {
+ t.Errorf("ResolveAppRoot(%q, \"child\") = %q, want %q", dir, got, child)
+ }
+}
+
// TestAppTypeIsValid guards AppType.IsValid(): every known type is valid and
// unknown/empty/differently-cased inputs are rejected. Prevents a silent
// widening or narrowing of the accepted runtime set.
func TestAppTypeIsValid(t *testing.T) {
- valid := []AppType{AppNode, AppGo, AppPython, AppPHP, AppStatic, AppStaticSidecar}
+ valid := []AppType{AppNode, AppGo, AppPython, AppPHP, AppRust, AppJava, AppRuby, AppStatic, AppStaticSidecar}
for _, at := range valid {
if !at.IsValid() {
t.Errorf("AppType %q should be valid", at)
@@ -58,7 +88,7 @@ func TestAppTypeIsValid(t *testing.T) {
t.Errorf("AllAppTypes length = %d, want %d", got, len(valid))
}
- invalid := []AppType{"", "ruby", "rust", "java", "unknown", "NODE", "Static", " node"}
+ invalid := []AppType{"", "unknown", "NODE", "Static", " node", "python3", "golang"}
for _, at := range invalid {
if at.IsValid() {
t.Errorf("AppType %q should be invalid", at)
diff --git a/components/deploy/state_machine.go b/components/deploy/state_machine.go
index 0376134279..f051c1e357 100644
--- a/components/deploy/state_machine.go
+++ b/components/deploy/state_machine.go
@@ -22,9 +22,13 @@ type StatusTransition struct {
}
// StateMachine defines valid transitions between deployment states.
-// 'running' is the terminal state (kept to avoid changing 5 SQL queries
-// that all use WHERE status = 'running'). 'deploying' is the only new,
-// transitional state (building → deploying → running for process apps).
+//
+// 'running' is a stable state (kept to avoid changing the 5 SQL queries that
+// all use WHERE status = 'running'); the deployment is healthy and serving.
+// 'deploying' is a transitional state (building → deploying → running for
+// process apps). 'stopped' is a stable terminal state for the build pipeline,
+// but it is deliberately revivable via stopped → running: rollback restores a
+// previously drained deployment (see components/deploy/rollback.go).
type StateMachine struct {
valid map[string][]string
}
@@ -32,7 +36,9 @@ type StateMachine struct {
func NewStateMachine() *StateMachine {
return &StateMachine{
valid: map[string][]string{
- string(StatePending): {string(StateBuilding)},
+ // pending → failed is used by boot reconciliation: a process restart
+ // can orphan a row before the build goroutine ever runs.
+ string(StatePending): {string(StateBuilding), string(StateFailed)},
string(StateBuilding): {string(StateDeploying), string(StateRunning), string(StateFailed)},
string(StateDeploying): {string(StateRunning), string(StateFailed)},
string(StateRunning): {string(StateFailed), string(StateRolledBack), string(StateDraining)},
@@ -71,3 +77,51 @@ func (sm *StateMachine) IsValidState(status string) bool {
_, ok := sm.valid[status]
return ok
}
+
+// IsActive reports whether the deployment is live or in flight, so a site
+// delete must wait for it: pending, building, deploying, draining, running.
+func (sm *StateMachine) IsActive(status string) bool {
+ return sm.isOneOf(status, StatePending, StateBuilding, StateDeploying, StateDraining, StateRunning)
+}
+
+// IsTerminal reports whether the build/start pipeline has finished: running,
+// stopped, failed, rolled_back. A terminal deployment still has lifecycle
+// transitions (drain, rollback), but no further build work is expected.
+func (sm *StateMachine) IsTerminal(status string) bool {
+ return sm.isOneOf(status, StateRunning, StateStopped, StateFailed, StateRolledBack)
+}
+
+// IsInFlight reports whether the deployment is transient work — a build,
+// deploy, or drain currently in progress — that must not be deleted or chosen
+// as a rollback target.
+func (sm *StateMachine) IsInFlight(status string) bool {
+ return sm.isOneOf(status, StatePending, StateBuilding, StateDeploying, StateDraining)
+}
+
+// InFlightStatuses returns the statuses for which IsInFlight is true. Callers
+// that filter in SQL (rollback target selection) derive their exclusion list
+// from here so the sets cannot drift.
+func (sm *StateMachine) InFlightStatuses() []string {
+ return []string{
+ string(StatePending),
+ string(StateBuilding),
+ string(StateDeploying),
+ string(StateDraining),
+ }
+}
+
+// CanDelete reports whether a deployment may be deleted now. In-flight rows
+// must finish first; running is deletable because the delete path stops it
+// before removing the row. Unknown/legacy statuses remain deletable.
+func (sm *StateMachine) CanDelete(status string) bool {
+ return !sm.IsInFlight(status)
+}
+
+func (sm *StateMachine) isOneOf(status string, states ...DeploymentState) bool {
+ for _, s := range states {
+ if status == string(s) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/components/deploy/state_machine_test.go b/components/deploy/state_machine_test.go
index c9d8defb91..13464dd20e 100644
--- a/components/deploy/state_machine_test.go
+++ b/components/deploy/state_machine_test.go
@@ -11,7 +11,7 @@ func TestStateMachineValidTransitions(t *testing.T) {
from DeploymentState
expected []DeploymentState
}{
- {StatePending, []DeploymentState{StateBuilding}},
+ {StatePending, []DeploymentState{StateBuilding, StateFailed}},
{StateBuilding, []DeploymentState{StateDeploying, StateRunning, StateFailed}},
{StateDeploying, []DeploymentState{StateRunning, StateFailed}},
{StateRunning, []DeploymentState{StateFailed, StateRolledBack, StateDraining}},
@@ -40,6 +40,7 @@ func TestStateMachineCanTransition(t *testing.T) {
valid := []struct{ from, to string }{
{"pending", "building"},
+ {"pending", "failed"},
{"building", "deploying"},
{"building", "running"},
{"building", "failed"},
@@ -62,7 +63,6 @@ func TestStateMachineCanTransition(t *testing.T) {
invalid := []struct{ from, to string }{
{"pending", "running"},
{"pending", "deploying"},
- {"pending", "failed"},
{"running", "building"},
{"running", "deploying"},
{"running", "pending"},
@@ -114,6 +114,74 @@ func TestStatusTransitionStruct(t *testing.T) {
}
}
+func TestStateMachineIsActive(t *testing.T) {
+ sm := NewStateMachine()
+
+ // Active = live or in-flight. A site delete must wait for these.
+ active := []string{"pending", "building", "deploying", "draining", "running"}
+ for _, s := range active {
+ if !sm.IsActive(s) {
+ t.Errorf("IsActive(%s) = false, want true", s)
+ }
+ }
+
+ for _, s := range []string{"stopped", "failed", "rolled_back", "", "unknown", "replaced"} {
+ if sm.IsActive(s) {
+ t.Errorf("IsActive(%s) = true, want false", s)
+ }
+ }
+}
+
+func TestStateMachineIsTerminal(t *testing.T) {
+ sm := NewStateMachine()
+
+ // Terminal = the build/start pipeline has finished. No automatic build
+ // transition is expected, though rollback may revive a stopped deployment.
+ for _, s := range []string{"running", "stopped", "failed", "rolled_back"} {
+ if !sm.IsTerminal(s) {
+ t.Errorf("IsTerminal(%s) = false, want true", s)
+ }
+ }
+
+ for _, s := range []string{"pending", "building", "deploying", "draining", "", "unknown"} {
+ if sm.IsTerminal(s) {
+ t.Errorf("IsTerminal(%s) = true, want false", s)
+ }
+ }
+}
+
+func TestStateMachineCanDelete(t *testing.T) {
+ sm := NewStateMachine()
+
+ // In-flight deployments cannot be deleted out from under the engine.
+ for _, s := range []string{"pending", "building", "deploying", "draining"} {
+ if sm.CanDelete(s) {
+ t.Errorf("CanDelete(%s) = true, want false", s)
+ }
+ }
+
+ // Running is deletable because the delete path stops it first; finished
+ // states are always deletable; unknown legacy rows stay deletable.
+ for _, s := range []string{"running", "stopped", "failed", "rolled_back", "", "replaced"} {
+ if !sm.CanDelete(s) {
+ t.Errorf("CanDelete(%s) = false, want true", s)
+ }
+ }
+}
+
+func TestStateMachineStoppedIsRevivable(t *testing.T) {
+ sm := NewStateMachine()
+
+ // e45 declared stopped terminal, but rollback (TestRollbackStaticSite)
+ // restores a drained deployment, so stopped → running is intentional.
+ if !sm.CanTransition("stopped", "running") {
+ t.Error("CanTransition(stopped, running) = false, want true (rollback relies on it)")
+ }
+ if !sm.IsTerminal("stopped") {
+ t.Error("IsTerminal(stopped) = false, want true (build pipeline is complete)")
+ }
+}
+
func TestStateConstants(t *testing.T) {
if StatePending != "pending" {
t.Errorf("StatePending = %s, want pending", StatePending)
diff --git a/components/deploy/state_transitions.go b/components/deploy/state_transitions.go
index 21c4005183..04de6c784d 100644
--- a/components/deploy/state_transitions.go
+++ b/components/deploy/state_transitions.go
@@ -2,7 +2,9 @@ package deploy
import (
"context"
+ "database/sql"
"encoding/json"
+ "errors"
"fmt"
"strings"
"time"
@@ -10,83 +12,136 @@ import (
"github.com/danielvm/bigbase/kernel"
)
+// ErrTransitionConflict is returned when a competing writer changed the
+// deployment status while a transition was being applied, and the retry budget
+// was exhausted.
+var ErrTransitionConflict = errors.New("deployment status changed concurrently")
+
+// ErrInvalidTransition is returned when a deployment attempts an illegal state change.
+var ErrInvalidTransition = errors.New("invalid transition")
+
+const transitionMaxAttempts = 4
+
+// TransitionState applies a validated state transition atomically.
+//
+// It reads the current status, validates the transition, then performs a
+// compare-and-swap UPDATE (WHERE id = ? AND status = ?) that must affect
+// exactly one row. The status is re-read between attempts, so a lost race
+// either resolves to the requested state (idempotent no-op) or fails
+// deterministically. status_history and events are only written after the
+// CAS succeeds, so two concurrent callers can never record two outcomes.
func (d *Deploy) TransitionState(ctx context.Context, id, newStatus string) error {
if d.sm == nil {
return fmt.Errorf("state machine not initialized")
}
- // Query current status
- var current string
- if err := d.db.QueryRowContext(ctx,
- "SELECT status FROM deployments WHERE id = ?", id).Scan(¤t); err != nil {
- return fmt.Errorf("query current status: %w", err)
- }
+ for attempt := 0; attempt < transitionMaxAttempts; attempt++ {
+ // Query current status outside the lock. The CAS below rejects a write
+ // if another goroutine changed it between this read and the update.
+ var current string
+ if err := d.db.QueryRowContext(ctx,
+ "SELECT status FROM deployments WHERE id = ?", id).Scan(¤t); err != nil {
+ return fmt.Errorf("query current status: %w", err)
+ }
+
+ // Idempotent: same status is a no-op
+ if current == newStatus {
+ return nil
+ }
+
+ // Lenient mode: legacy rows may carry a status the machine no longer
+ // declares (e.g. "replaced"). Normal flows must always take the
+ // validated path below.
+ if !d.sm.IsValidState(current) {
+ d.logger.Warn("transition from unknown state (legacy row)",
+ "id", id, "from", current, "to", newStatus)
+ } else if !d.sm.CanTransition(current, newStatus) {
+ allowed := d.sm.ValidTransitions(current)
+ return fmt.Errorf("%w: %s → %s (allowed: %v)", ErrInvalidTransition, current, newStatus, allowed)
+ }
+
+ // Snapshot build_log for finished states so a concurrent reader never
+ // observes status=running/failed with a stale build_log (the
+ // TestStaticSidecar_BuildAndStart flake: status and build_log were
+ // written in two separate statements).
+ var buildLog string
+ if d.sm.IsTerminal(newStatus) {
+ buildLog = strings.Join(d.getDeployLogs(id), "\n")
+ }
+
+ updated, err := d.compareAndSwapStatus(ctx, id, current, newStatus, buildLog)
+ if err != nil {
+ return err
+ }
+ if !updated {
+ // Lost the race. Re-read on the next attempt: the deployment may
+ // now already be in newStatus (idempotent) or in a state that
+ // rejects this transition (deterministic error).
+ continue
+ }
+
+ transition := StatusTransition{
+ From: current,
+ To: newStatus,
+ Timestamp: time.Now().UTC().Format(time.RFC3339),
+ }
+
+ // Serialize the status_history read-modify-write. Only a CAS winner
+ // reaches this point, so history can never be appended twice.
+ d.mu.Lock()
+ d.appendStatusHistory(ctx, id, transition)
+ d.mu.Unlock()
+
+ // Emit event (nil-safe), outside the lock: handlers may call back.
+ if d.eventBus != nil {
+ siteID, orgID := d.deploymentOrg(ctx, id)
+ _ = d.eventBus.Emit(kernel.Event{
+ Name: "deploy.state_changed",
+ Data: map[string]any{
+ "deployment_id": id,
+ "site_id": siteID,
+ "org_id": orgID,
+ "from_state": current,
+ "to_state": newStatus,
+ "timestamp": transition.Timestamp,
+ },
+ }, &kernel.Context{})
+ if newStatus == string(StateFailed) && current != string(StateFailed) {
+ d.emitDeployFailed(ctx, id)
+ }
+ }
- // Idempotent: same status is a no-op
- if current == newStatus {
return nil
}
- // Lenient mode: if current status is unknown to the state machine, allow transition
- if !d.sm.IsValidState(current) {
- d.logger.Warn("transition from unknown state (lenient mode)",
- "id", id, "from", current, "to", newStatus)
- } else if !d.sm.CanTransition(current, newStatus) {
- allowed := d.sm.ValidTransitions(current)
- return fmt.Errorf("invalid transition: %s → %s (allowed: %v)", current, newStatus, allowed)
- }
+ return fmt.Errorf("%w: deployment %s", ErrTransitionConflict, id)
+}
- // Persist status change
- d.mu.Lock()
- defer d.mu.Unlock()
-
- // Persist status atomically with build_log on terminal states, so a
- // concurrent reader never observes status=running/failed with a stale
- // build_log (the TestStaticSidecar_BuildAndStart flake: status and
- // build_log were written in two separate statements).
- terminal := newStatus == string(StateRunning) || newStatus == string(StateFailed)
- if terminal {
- if buildLog := strings.Join(d.getDeployLogs(id), "\n"); buildLog != "" {
- _, _ = d.db.ExecContext(ctx,
- "UPDATE deployments SET status = ?, build_log = ? WHERE id = ?", newStatus, buildLog, id)
- // skip the fall-through status-only update
- } else {
- _, _ = d.db.ExecContext(ctx,
- "UPDATE deployments SET status = ? WHERE id = ?", newStatus, id)
- }
+// compareAndSwapStatus updates status (and build_log when non-empty) only if
+// the row is still in the expected source state. It reports whether the write
+// applied; callers must treat false as a lost race.
+func (d *Deploy) compareAndSwapStatus(ctx context.Context, id, from, to, buildLog string) (bool, error) {
+ var (
+ res sql.Result
+ err error
+ )
+ if buildLog != "" {
+ res, err = d.db.ExecContext(ctx,
+ "UPDATE deployments SET status = ?, build_log = ? WHERE id = ? AND status = ?",
+ to, buildLog, id, from)
} else {
- _, _ = d.db.ExecContext(ctx,
- "UPDATE deployments SET status = ? WHERE id = ?", newStatus, id)
+ res, err = d.db.ExecContext(ctx,
+ "UPDATE deployments SET status = ? WHERE id = ? AND status = ?",
+ to, id, from)
}
-
- // Append to status_history
- transition := StatusTransition{
- From: current,
- To: newStatus,
- Timestamp: time.Now().UTC().Format(time.RFC3339),
+ if err != nil {
+ return false, fmt.Errorf("update deployment status: %w", err)
}
- d.appendStatusHistory(ctx, id, transition)
-
- // Emit event (nil-safe)
- if d.eventBus != nil {
- siteID, orgID := d.deploymentOrg(ctx, id)
- _ = d.eventBus.Emit(kernel.Event{
- Name: "deploy.state_changed",
- Data: map[string]any{
- "deployment_id": id,
- "site_id": siteID,
- "org_id": orgID,
- "from_state": current,
- "to_state": newStatus,
- "timestamp": transition.Timestamp,
- },
- }, &kernel.Context{})
- if newStatus == string(StateFailed) && current != string(StateFailed) {
- d.emitDeployFailed(ctx, id)
- }
+ affected, err := res.RowsAffected()
+ if err != nil {
+ return false, fmt.Errorf("rows affected: %w", err)
}
-
- return nil
+ return affected == 1, nil
}
func (d *Deploy) appendStatusHistory(ctx context.Context, id string, tr StatusTransition) {
var current string
@@ -106,8 +161,10 @@ func (d *Deploy) appendStatusHistory(ctx context.Context, id string, tr StatusTr
if err != nil {
return
}
- _, _ = d.db.ExecContext(ctx,
- "UPDATE deployments SET status_history = ? WHERE id = ?", string(data), id)
+ if _, err := d.db.ExecContext(ctx,
+ "UPDATE deployments SET status_history = ? WHERE id = ?", string(data), id); err != nil {
+ d.logger.Warn("append status history failed", "deployment_id", id, "error", err)
+ }
}
func (d *Deploy) updateStatus(id, status string) {
_ = d.TransitionState(context.Background(), id, status)
diff --git a/components/deploy/transition_test.go b/components/deploy/transition_test.go
index 5f7143ee0a..13426f3374 100644
--- a/components/deploy/transition_test.go
+++ b/components/deploy/transition_test.go
@@ -3,7 +3,9 @@ package deploy_test
import (
"context"
"encoding/json"
+ "errors"
"strings"
+ "sync"
"testing"
"github.com/danielvm/bigbase/components/db"
@@ -66,8 +68,8 @@ func TestTransitionStateInvalidRejected(t *testing.T) {
if err == nil {
t.Fatalf("pending → running should be invalid, but got nil")
}
- if !strings.Contains(err.Error(), "invalid transition") {
- t.Errorf("error should mention invalid transition, got: %v", err)
+ if !errors.Is(err, deploy.ErrInvalidTransition) {
+ t.Errorf("expected ErrInvalidTransition, got: %v", err)
}
// Status unchanged after rejected transition
@@ -101,8 +103,9 @@ func TestTransitionStateIdempotent(t *testing.T) {
}
func TestTransitionStateLenientMode(t *testing.T) {
- // Lenient mode: if current status is unknown to state machine, allow the transition.
- // This prevents blocking deployments with legacy statuses (e.g., "replaced" from stopDeployment).
+ // Lenient mode exists only for legacy rows that still carry a status the
+ // state machine no longer declares (e.g. "replaced" written by older
+ // builds). Normal flows write only declared DeploymentStates (#303).
dep, _, database, gitDir := setupDeploy(t)
repoID := createTestRepo(t, database, "repo-ts4", gitDir)
@@ -125,6 +128,81 @@ func TestTransitionStateLenientMode(t *testing.T) {
verifyStatusHistory(t, database, depID, 1)
}
+func TestConcurrentTransitionSingleTerminalOutcome(t *testing.T) {
+ // #302: competing transitions must not both write. failed and rolled_back
+ // are both reachable from running but neither allows an outgoing transition,
+ // so exactly one can win and the loser must report a deterministic error.
+ dep, _, database, gitDir := setupDeploy(t)
+ repoID := createTestRepo(t, database, "repo-ts-race", gitDir)
+
+ depID := "dep-ts-race"
+ _, err := database.ExecContext(context.Background(),
+ `INSERT INTO deployments (id, repo_id, site_id, status, url, port, app_type)
+ VALUES (?, ?, '', 'running', '', 0, '')`, depID, repoID)
+ if err != nil {
+ t.Fatalf("insert deployment: %v", err)
+ }
+
+ ctx := context.Background()
+ targets := []string{string(deploy.StateFailed), string(deploy.StateRolledBack)}
+
+ var (
+ wg sync.WaitGroup
+ start = make(chan struct{})
+ errs = make([]error, len(targets))
+ mu sync.Mutex
+ results = make([]string, 0, len(targets))
+ )
+ for i, target := range targets {
+ wg.Add(1)
+ go func(i int, target string) {
+ defer wg.Done()
+ <-start
+ err := dep.TransitionState(ctx, depID, target)
+ mu.Lock()
+ errs[i] = err
+ results = append(results, target)
+ mu.Unlock()
+ }(i, target)
+ }
+ close(start)
+ wg.Wait()
+
+ successes := 0
+ for i, err := range errs {
+ if err == nil {
+ successes++
+ } else if !strings.Contains(err.Error(), "invalid transition") &&
+ !strings.Contains(err.Error(), "changed concurrently") {
+ t.Errorf("loser %d returned non-deterministic error: %v", i, err)
+ }
+ }
+ if successes != 1 {
+ t.Fatalf("successful transitions = %d, want exactly 1 (errs: %v)", successes, errs)
+ }
+
+ var final string
+ if err := database.QueryRowContext(ctx,
+ "SELECT status FROM deployments WHERE id = ?", depID).Scan(&final); err != nil {
+ t.Fatalf("query final status: %v", err)
+ }
+ if final != string(deploy.StateFailed) && final != string(deploy.StateRolledBack) {
+ t.Fatalf("final status = %q, want a single terminal outcome", final)
+ }
+
+ // Only the winning transition may append history.
+ verifyStatusHistory(t, database, depID, 1)
+}
+
+func TestTransitionStateNotFound(t *testing.T) {
+ dep, _, _, _ := setupDeploy(t)
+
+ err := dep.TransitionState(context.Background(), "missing-deployment", string(deploy.StateBuilding))
+ if err == nil {
+ t.Fatal("expected error for missing deployment")
+ }
+}
+
func TestTransitionStateStatusHistoryPersistence(t *testing.T) {
dep, _, database, gitDir := setupDeploy(t)
repoID := createTestRepo(t, database, "repo-ts5", gitDir)
diff --git a/components/deploy/utils.go b/components/deploy/utils.go
index 0dfb4c7a2e..b23004cc6a 100644
--- a/components/deploy/utils.go
+++ b/components/deploy/utils.go
@@ -40,6 +40,9 @@ func DetectAppType(buildDir string) AppType {
if fileExists(filepath.Join(buildDir, "go.mod")) {
return AppGo
}
+ if fileExists(filepath.Join(buildDir, "Cargo.toml")) {
+ return AppRust
+ }
if fileExists(filepath.Join(buildDir, "composer.json")) {
return AppPHP
}
@@ -52,6 +55,14 @@ func DetectAppType(buildDir string) AppType {
fileExists(filepath.Join(buildDir, "main.py")) {
return AppPython
}
+ if fileExists(filepath.Join(buildDir, "pom.xml")) ||
+ fileExists(filepath.Join(buildDir, "build.gradle")) ||
+ fileExists(filepath.Join(buildDir, "build.gradle.kts")) {
+ return AppJava
+ }
+ if fileExists(filepath.Join(buildDir, "Gemfile")) {
+ return AppRuby
+ }
if fileExists(filepath.Join(buildDir, "index.html")) {
return AppStatic
}
diff --git a/components/github/github_test.go b/components/github/github_test.go
index 2c0cdec0c1..3d986afbd9 100644
--- a/components/github/github_test.go
+++ b/components/github/github_test.go
@@ -8,6 +8,9 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
"strings"
"testing"
"time"
@@ -247,3 +250,16 @@ func TestGitHubWebhookPublicNotUnauthorized(t *testing.T) {
t.Fatalf("webhook must not use auth middleware, got %s", w.Body.String())
}
}
+
+// TestCommandInjection guards the "--" delimiter in git clone/fetch mirror commands.
+func TestCommandInjection(t *testing.T) {
+ if _, err := exec.LookPath("git"); err != nil {
+ t.Skip("git not installed")
+ }
+ sentinel := filepath.Join(t.TempDir(), "pwned")
+ cmd := exec.Command("git", "clone", "--bare", "--", "--upload-pack=touch "+sentinel, t.TempDir())
+ _ = cmd.Run()
+ if _, statErr := os.Stat(sentinel); statErr == nil {
+ t.Fatalf("command injection: sentinel %q was created — the -- guard failed", sentinel)
+ }
+}
diff --git a/components/mcp/ci_template_test.go b/components/mcp/ci_template_test.go
index 4296c3fb92..5ac26bb4dc 100644
--- a/components/mcp/ci_template_test.go
+++ b/components/mcp/ci_template_test.go
@@ -73,3 +73,104 @@ command = "npm run start"
}
})
}
+
+func TestCITemplateRust(t *testing.T) {
+ c := mcp.New(mcp.Options{Enabled: true})
+ ctx, session := connectMCPSession(t, c)
+
+ result, err := session.CallTool(ctx, &mcpsdk.CallToolParams{
+ Name: "get_ci_template",
+ Arguments: map[string]any{"app_type": "rust"},
+ })
+ if err != nil {
+ t.Fatalf("CallTool(get_ci_template, rust): %v", err)
+ }
+ content := result.Content[0].(*mcpsdk.TextContent).Text
+ if !strings.Contains(content, "rust") {
+ t.Errorf("expected rust in template content, got: %s", content)
+ }
+ if strings.Contains(content, "No template for") {
+ t.Fatalf("get_ci_template returned no template for rust: %s", content)
+ }
+}
+
+func TestCITemplatePHP(t *testing.T) {
+ c := mcp.New(mcp.Options{Enabled: true})
+ ctx, session := connectMCPSession(t, c)
+
+ result, err := session.CallTool(ctx, &mcpsdk.CallToolParams{
+ Name: "get_ci_template",
+ Arguments: map[string]any{"app_type": "php"},
+ })
+ if err != nil {
+ t.Fatalf("CallTool(get_ci_template, php): %v", err)
+ }
+ content := result.Content[0].(*mcpsdk.TextContent).Text
+ if !strings.Contains(content, "php") {
+ t.Errorf("expected php in template content, got: %s", content)
+ }
+ if strings.Contains(content, "No template for") {
+ t.Fatalf("get_ci_template returned no template for php: %s", content)
+ }
+}
+
+func TestCITemplateJava(t *testing.T) {
+ c := mcp.New(mcp.Options{Enabled: true})
+ ctx, session := connectMCPSession(t, c)
+
+ result, err := session.CallTool(ctx, &mcpsdk.CallToolParams{
+ Name: "get_ci_template",
+ Arguments: map[string]any{"app_type": "java"},
+ })
+ if err != nil {
+ t.Fatalf("CallTool(get_ci_template, java): %v", err)
+ }
+ content := result.Content[0].(*mcpsdk.TextContent).Text
+ if !strings.Contains(content, "java") {
+ t.Errorf("expected java in template content, got: %s", content)
+ }
+ if strings.Contains(content, "No template for") {
+ t.Fatalf("get_ci_template returned no template for java: %s", content)
+ }
+}
+
+func TestCITemplateRuby(t *testing.T) {
+ c := mcp.New(mcp.Options{Enabled: true})
+ ctx, session := connectMCPSession(t, c)
+
+ result, err := session.CallTool(ctx, &mcpsdk.CallToolParams{
+ Name: "get_ci_template",
+ Arguments: map[string]any{"app_type": "ruby"},
+ })
+ if err != nil {
+ t.Fatalf("CallTool(get_ci_template, ruby): %v", err)
+ }
+ content := result.Content[0].(*mcpsdk.TextContent).Text
+ if !strings.Contains(content, "ruby") {
+ t.Errorf("expected ruby in template content, got: %s", content)
+ }
+ if strings.Contains(content, "No template for") {
+ t.Fatalf("get_ci_template returned no template for ruby: %s", content)
+ }
+}
+
+func TestCITemplatesAll(t *testing.T) {
+ c := mcp.New(mcp.Options{Enabled: true})
+ ctx, session := connectMCPSession(t, c)
+
+ types := []string{"static", "node", "go", "python", "php", "rust", "java", "ruby"}
+ for _, appType := range types {
+ result, err := session.CallTool(ctx, &mcpsdk.CallToolParams{
+ Name: "get_ci_template",
+ Arguments: map[string]any{"app_type": appType},
+ })
+ if err != nil {
+ t.Errorf("CallTool(get_ci_template, %s) error: %v", appType, err)
+ continue
+ }
+ content := result.Content[0].(*mcpsdk.TextContent).Text
+ if strings.Contains(content, "No template for") {
+ t.Errorf("missing template for %s: %s", appType, content)
+ }
+ }
+}
diff --git a/components/mcp/knowledge/ci-templates.json b/components/mcp/knowledge/ci-templates.json
index 2b91726c75..c01105fdce 100644
--- a/components/mcp/knowledge/ci-templates.json
+++ b/components/mcp/knowledge/ci-templates.json
@@ -51,6 +51,33 @@
],
"description": "v3.0.0 TBR+deploy via workflow_run + bigbase-deploy@v1 for AppPHP (composer). Pair with test-build-release-php.yml when available.",
"content": "name: Deploy\n# v3.0.0 \u2014 pair with test-build-release-php.yml; e82 will add multipart artifact upload\n\non:\n workflow_run:\n workflows: [\"Test Build Release\"]\n types: [completed]\n\npermissions:\n contents: read\n actions: read\n\nconcurrency:\n group: deploy-production\n cancel-in-progress: false\n\nenv:\n SITE_URL: \"https://CHANGE-ME.bigbase.click\"\n\njobs:\n deploy:\n if: >\n github.event.workflow_run.conclusion == 'success' &&\n github.event.workflow_run.head_branch == 'main'\n runs-on: ubuntu-22.04\n timeout-minutes: 10\n environment: production\n steps:\n - name: Download deploy-meta artifact\n uses: actions/download-artifact@v8\n with:\n name: deploy-meta\n github-token: ${{ secrets.GITHUB_TOKEN }}\n run-id: ${{ github.event.workflow_run.id }}\n repository: ${{ github.event.workflow_run.repository.full_name }}\n path: deploy-meta\n\n - name: Verify deploy-meta artifact\n id: meta\n run: |\n if [ ! -s deploy-meta/deploy-meta.json ]; then\n echo \"::error::deploy-meta artifact missing or empty\"\n exit 1\n fi\n REF=$(jq -r '.sha' deploy-meta/deploy-meta.json)\n APP_TYPE=$(jq -r '.app_type' deploy-meta/deploy-meta.json)\n echo \"ref=$REF\" >> \"$GITHUB_OUTPUT\"\n echo \"app_type=$APP_TYPE\" >> \"$GITHUB_OUTPUT\"\n\n - name: Deploy to BigBase\n uses: danielvm-git/.github/actions/bigbase-deploy@v1\n with:\n site_id: ${{ secrets.BIGBASE_SITE_ID }}\n app_type: ${{ steps.meta.outputs.app_type }}\n site_url: ${{ env.SITE_URL }}\n deploy_token: ${{ secrets.BIGBASE_DEPLOY_TOKEN }}\n ref: ${{ steps.meta.outputs.ref }}\n branch: main\n"
+ },
+ {
+ "id": "github-actions-v3-deploy-rust",
+ "name": "GitHub Actions v3 Deploy (rust)",
+ "app_types": [
+ "rust"
+ ],
+ "description": "v3.0.0 TBR+deploy via workflow_run + bigbase-deploy@v1 for AppRust (cargo). Pair with test-build-release-rust.yml.",
+ "content": "name: Deploy\n# v3.0.0 \u2014 pair with test-build-release-rust.yml; e82 will add multipart artifact upload\n\non:\n workflow_run:\n workflows: [\"Test Build Release\"]\n types: [completed]\n\npermissions:\n contents: read\n actions: read\n\nconcurrency:\n group: deploy-production\n cancel-in-progress: false\n\nenv:\n SITE_URL: \"https://CHANGE-ME.bigbase.click\"\n\njobs:\n deploy:\n if: >\n github.event.workflow_run.conclusion == 'success' &&\n github.event.workflow_run.head_branch == 'main'\n runs-on: ubuntu-22.04\n timeout-minutes: 10\n environment: production\n steps:\n - name: Download deploy-meta artifact\n uses: actions/download-artifact@v8\n with:\n name: deploy-meta\n github-token: ${{ secrets.GITHUB_TOKEN }}\n run-id: ${{ github.event.workflow_run.id }}\n repository: ${{ github.event.workflow_run.repository.full_name }}\n path: deploy-meta\n\n - name: Verify deploy-meta artifact\n id: meta\n run: |\n if [ ! -s deploy-meta/deploy-meta.json ]; then\n echo \"::error::deploy-meta artifact missing or empty\"\n exit 1\n fi\n REF=$(jq -r '.sha' deploy-meta/deploy-meta.json)\n APP_TYPE=$(jq -r '.app_type' deploy-meta/deploy-meta.json)\n echo \"ref=$REF\" >> \"$GITHUB_OUTPUT\"\n echo \"app_type=$APP_TYPE\" >> \"$GITHUB_OUTPUT\"\n\n - name: Deploy to BigBase\n uses: danielvm-git/.github/actions/bigbase-deploy@v1\n with:\n site_id: ${{ secrets.BIGBASE_SITE_ID }}\n app_type: ${{ steps.meta.outputs.app_type }}\n site_url: ${{ env.SITE_URL }}\n deploy_token: ${{ secrets.BIGBASE_DEPLOY_TOKEN }}\n ref: ${{ steps.meta.outputs.ref }}\n branch: main\n"
+ },
+ {
+ "id": "github-actions-v3-deploy-java",
+ "name": "GitHub Actions v3 Deploy (java)",
+ "app_types": [
+ "java"
+ ],
+ "description": "v3.0.0 TBR+deploy via workflow_run + bigbase-deploy@v1 for AppJava (maven/gradle). Pair with test-build-release-java.yml.",
+ "content": "name: Deploy\n# v3.0.0 \u2014 pair with test-build-release-java.yml; e82 will add multipart artifact upload\n\non:\n workflow_run:\n workflows: [\"Test Build Release\"]\n types: [completed]\n\npermissions:\n contents: read\n actions: read\n\nconcurrency:\n group: deploy-production\n cancel-in-progress: false\n\nenv:\n SITE_URL: \"https://CHANGE-ME.bigbase.click\"\n\njobs:\n deploy:\n if: >\n github.event.workflow_run.conclusion == 'success' &&\n github.event.workflow_run.head_branch == 'main'\n runs-on: ubuntu-22.04\n timeout-minutes: 10\n environment: production\n steps:\n - name: Download deploy-meta artifact\n uses: actions/download-artifact@v8\n with:\n name: deploy-meta\n github-token: ${{ secrets.GITHUB_TOKEN }}\n run-id: ${{ github.event.workflow_run.id }}\n repository: ${{ github.event.workflow_run.repository.full_name }}\n path: deploy-meta\n\n - name: Verify deploy-meta artifact\n id: meta\n run: |\n if [ ! -s deploy-meta/deploy-meta.json ]; then\n echo \"::error::deploy-meta artifact missing or empty\"\n exit 1\n fi\n REF=$(jq -r '.sha' deploy-meta/deploy-meta.json)\n APP_TYPE=$(jq -r '.app_type' deploy-meta/deploy-meta.json)\n echo \"ref=$REF\" >> \"$GITHUB_OUTPUT\"\n echo \"app_type=$APP_TYPE\" >> \"$GITHUB_OUTPUT\"\n\n - name: Deploy to BigBase\n uses: danielvm-git/.github/actions/bigbase-deploy@v1\n with:\n site_id: ${{ secrets.BIGBASE_SITE_ID }}\n app_type: ${{ steps.meta.outputs.app_type }}\n site_url: ${{ env.SITE_URL }}\n deploy_token: ${{ secrets.BIGBASE_DEPLOY_TOKEN }}\n ref: ${{ steps.meta.outputs.ref }}\n branch: main\n"
+ },
+ {
+ "id": "github-actions-v3-deploy-ruby",
+ "name": "GitHub Actions v3 Deploy (ruby)",
+ "app_types": [
+ "ruby"
+ ],
+ "description": "v3.0.0 TBR+deploy via workflow_run + bigbase-deploy@v1 for AppRuby (bundler). Pair with test-build-release-ruby.yml.",
+ "content": "name: Deploy\n# v3.0.0 \u2014 pair with test-build-release-ruby.yml; e82 will add multipart artifact upload\n\non:\n workflow_run:\n workflows: [\"Test Build Release\"]\n types: [completed]\n\npermissions:\n contents: read\n actions: read\n\nconcurrency:\n group: deploy-production\n cancel-in-progress: false\n\nenv:\n SITE_URL: \"https://CHANGE-ME.bigbase.click\"\n\njobs:\n deploy:\n if: >\n github.event.workflow_run.conclusion == 'success' &&\n github.event.workflow_run.head_branch == 'main'\n runs-on: ubuntu-22.04\n timeout-minutes: 10\n environment: production\n steps:\n - name: Download deploy-meta artifact\n uses: actions/download-artifact@v8\n with:\n name: deploy-meta\n github-token: ${{ secrets.GITHUB_TOKEN }}\n run-id: ${{ github.event.workflow_run.id }}\n repository: ${{ github.event.workflow_run.repository.full_name }}\n path: deploy-meta\n\n - name: Verify deploy-meta artifact\n id: meta\n run: |\n if [ ! -s deploy-meta/deploy-meta.json ]; then\n echo \"::error::deploy-meta artifact missing or empty\"\n exit 1\n fi\n REF=$(jq -r '.sha' deploy-meta/deploy-meta.json)\n APP_TYPE=$(jq -r '.app_type' deploy-meta/deploy-meta.json)\n echo \"ref=$REF\" >> \"$GITHUB_OUTPUT\"\n echo \"app_type=$APP_TYPE\" >> \"$GITHUB_OUTPUT\"\n\n - name: Deploy to BigBase\n uses: danielvm-git/.github/actions/bigbase-deploy@v1\n with:\n site_id: ${{ secrets.BIGBASE_SITE_ID }}\n app_type: ${{ steps.meta.outputs.app_type }}\n site_url: ${{ env.SITE_URL }}\n deploy_token: ${{ secrets.BIGBASE_DEPLOY_TOKEN }}\n ref: ${{ steps.meta.outputs.ref }}\n branch: main\n"
}
]
}
diff --git a/components/mcp/knowledge/frameworks.json b/components/mcp/knowledge/frameworks.json
index 818bd9ffcd..3d700eff91 100644
--- a/components/mcp/knowledge/frameworks.json
+++ b/components/mcp/knowledge/frameworks.json
@@ -48,6 +48,34 @@
"maturity": "planned",
"recommended_for": "Python web apps (Flask, FastAPI)",
"caveats": "Coming soon"
+ },
+ {
+ "name": "rust",
+ "display": "Rust",
+ "maturity": "full",
+ "recommended_for": "Rust web servers and APIs",
+ "caveats": "Requires cargo build"
+ },
+ {
+ "name": "php",
+ "display": "PHP",
+ "maturity": "full",
+ "recommended_for": "PHP web apps and APIs",
+ "caveats": "Composer dependencies"
+ },
+ {
+ "name": "java",
+ "display": "Java",
+ "maturity": "full",
+ "recommended_for": "Java web apps (Spring Boot, Maven, Gradle)",
+ "caveats": "Requires Java runtime"
+ },
+ {
+ "name": "ruby",
+ "display": "Ruby",
+ "maturity": "full",
+ "recommended_for": "Ruby web apps (Sinatra, Rails, Rack)",
+ "caveats": "Requires Bundler"
}
]
}
diff --git a/components/mcp/mcp.go b/components/mcp/mcp.go
index 865617b49f..da9722ce76 100644
--- a/components/mcp/mcp.go
+++ b/components/mcp/mcp.go
@@ -9,6 +9,7 @@ import (
"fmt"
"net/http"
"os"
+ "regexp"
"strings"
"github.com/BurntSushi/toml"
@@ -19,6 +20,13 @@ import (
const version = "0.1.0"
+// siteEnvKeyPattern mirrors the site env var key contract enforced by the sites
+// component. Keys are validated at the MCP boundary so writes fail with an
+// actionable message before reaching storage.
+var siteEnvKeyPattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`)
+
+const maxSiteEnvKeyLen = 128
+
// DeploymentResult is the MCP-facing deploy trigger result (no deploy import).
type DeploymentResult struct {
ID string
@@ -1148,6 +1156,9 @@ Port is set via ` + "`PORT`" + ` env var. Database is SQLite by default.
}
vars := make(map[string]string, len(varsMap))
for k, v := range varsMap {
+ if len(k) > maxSiteEnvKeyLen || !siteEnvKeyPattern.MatchString(k) {
+ return textResult(fmt.Sprintf("invalid env var key %q: keys must match ^[A-Z][A-Z0-9_]*$", k)), nil, nil
+ }
vars[k] = fmt.Sprintf("%v", v)
}
result, err := c.siteEnvVarManager.SetSiteEnvVars(ctx, siteID, vars)
@@ -1181,6 +1192,9 @@ Port is set via ` + "`PORT`" + ` env var. Database is SQLite by default.
if key == "" {
return textResult("key is required. Use get_site_env_vars to see available keys."), nil, nil
}
+ if len(key) > maxSiteEnvKeyLen || !siteEnvKeyPattern.MatchString(key) {
+ return textResult(fmt.Sprintf("invalid env var key %q: keys must match ^[A-Z][A-Z0-9_]*$", key)), nil, nil
+ }
if err := c.siteEnvVarManager.DeleteSiteEnvVar(ctx, siteID, key); err != nil {
return textResult(safeSiteEnvError(err)), nil, nil
}
diff --git a/components/mcp/mcp_test.go b/components/mcp/mcp_test.go
index 28f08956b9..eba2ccf2b1 100644
--- a/components/mcp/mcp_test.go
+++ b/components/mcp/mcp_test.go
@@ -967,3 +967,137 @@ func TestMCPDeleteSiteEnvVarNoArgs(t *testing.T) {
t.Errorf("expected 'site_id is required', got: %s", tc.Text)
}
}
+
+// callEnvVarTool invokes an env var tool and returns its text payload.
+func callEnvVarTool(t *testing.T, ctx context.Context, session *mcpsdk.ClientSession, name string, args map[string]any) string {
+ t.Helper()
+ result, err := session.CallTool(ctx, &mcpsdk.CallToolParams{Name: name, Arguments: args})
+ if err != nil {
+ t.Fatalf("%s: %v", name, err)
+ }
+ tc, ok := result.Content[0].(*mcpsdk.TextContent)
+ if !ok {
+ t.Fatalf("%s: expected *TextContent, got %T", name, result.Content[0])
+ }
+ return tc.Text
+}
+
+func TestEnvVarGetMasked(t *testing.T) {
+ mock := newMockEnvVarManager()
+ mock.vars["site-1"] = []mcp.SiteEnvVar{
+ {ID: "ev-1", SiteID: "site-1", Key: "DATABASE_URL", ValuePreview: "••••5432", IsRuntime: true},
+ {ID: "ev-2", SiteID: "site-1", Key: "API_KEY", ValuePreview: "••••abcd", IsBuildTime: true},
+ }
+
+ c := mcp.New(mcp.Options{Enabled: true, SiteEnvVarManager: mock})
+ ctx, session := connectMCPSession(t, c)
+
+ text := callEnvVarTool(t, ctx, session, "get_site_env_vars", map[string]any{"site_id": "site-1"})
+ if !strings.Contains(text, "DATABASE_URL") || !strings.Contains(text, "API_KEY") {
+ t.Errorf("expected both keys in response, got: %s", text)
+ }
+ if !strings.Contains(text, "••••5432") || !strings.Contains(text, "••••abcd") {
+ t.Errorf("expected masked value previews in response, got: %s", text)
+ }
+ if strings.Contains(text, "postgres://") {
+ t.Errorf("response leaked a plaintext value: %s", text)
+ }
+}
+
+func TestEnvVarSetUpsertMultiple(t *testing.T) {
+ mock := newMockEnvVarManager()
+ c := mcp.New(mcp.Options{Enabled: true, SiteEnvVarManager: mock})
+ ctx, session := connectMCPSession(t, c)
+
+ first := callEnvVarTool(t, ctx, session, "set_site_env_vars", map[string]any{
+ "site_id": "site-1",
+ "vars": map[string]any{
+ "DATABASE_URL": "postgres://localhost:5432/db",
+ "API_KEY": "test-value-1234",
+ },
+ })
+ if !strings.Contains(first, "DATABASE_URL") || !strings.Contains(first, "API_KEY") {
+ t.Errorf("expected both keys in set response, got: %s", first)
+ }
+ if got := len(mock.vars["site-1"]); got != 2 {
+ t.Fatalf("expected 2 stored vars after first upsert, got %d", got)
+ }
+
+ second := callEnvVarTool(t, ctx, session, "set_site_env_vars", map[string]any{
+ "site_id": "site-1",
+ "vars": map[string]any{"DATABASE_URL": "postgres://remote:9999/new"},
+ })
+ if got := len(mock.vars["site-1"]); got != 2 {
+ t.Errorf("expected upsert to keep 2 stored vars, got %d", got)
+ }
+ if !strings.Contains(second, "••••/new") {
+ t.Errorf("expected updated masked preview in response, got: %s", second)
+ }
+}
+
+func TestEnvVarDelete(t *testing.T) {
+ mock := newMockEnvVarManager()
+ mock.vars["site-1"] = []mcp.SiteEnvVar{
+ {ID: "ev-1", SiteID: "site-1", Key: "DATABASE_URL", ValuePreview: "••••5432"},
+ }
+ c := mcp.New(mcp.Options{Enabled: true, SiteEnvVarManager: mock})
+ ctx, session := connectMCPSession(t, c)
+
+ text := callEnvVarTool(t, ctx, session, "delete_site_env_var", map[string]any{"site_id": "site-1", "key": "DATABASE_URL"})
+ if !strings.Contains(text, "deleted") {
+ t.Errorf("expected 'deleted' response, got: %s", text)
+ }
+ if got := len(mock.vars["site-1"]); got != 0 {
+ t.Errorf("expected 0 vars after delete, got %d", got)
+ }
+
+ missing := callEnvVarTool(t, ctx, session, "delete_site_env_var", map[string]any{"site_id": "site-1", "key": "MISSING"})
+ if !strings.Contains(missing, "not found") {
+ t.Errorf("expected 'not found' for missing key, got: %s", missing)
+ }
+}
+
+func TestEnvVarKeyValidationRejectsInvalid(t *testing.T) {
+ invalid := []string{"lower_case", "1LEADING", "WITH-DASH", "with space", "camelCase", "TRAILING_" + "!"}
+ valid := []string{"DATABASE_URL", "API_KEY_2", "A"}
+
+ for _, key := range invalid {
+ key := key
+ t.Run("invalid/"+key, func(t *testing.T) {
+ mock := newMockEnvVarManager()
+ c := mcp.New(mcp.Options{Enabled: true, SiteEnvVarManager: mock})
+ ctx, session := connectMCPSession(t, c)
+
+ text := callEnvVarTool(t, ctx, session, "set_site_env_vars", map[string]any{
+ "site_id": "site-1",
+ "vars": map[string]any{key: "value"},
+ })
+ if !strings.Contains(text, "invalid env var key") {
+ t.Errorf("expected key %q to be rejected, got: %s", key, text)
+ }
+ if got := len(mock.vars["site-1"]); got != 0 {
+ t.Errorf("expected no vars stored for invalid key %q, got %d", key, got)
+ }
+ })
+ }
+
+ for _, key := range valid {
+ key := key
+ t.Run("valid/"+key, func(t *testing.T) {
+ mock := newMockEnvVarManager()
+ c := mcp.New(mcp.Options{Enabled: true, SiteEnvVarManager: mock})
+ ctx, session := connectMCPSession(t, c)
+
+ text := callEnvVarTool(t, ctx, session, "set_site_env_vars", map[string]any{
+ "site_id": "site-1",
+ "vars": map[string]any{key: "value"},
+ })
+ if strings.Contains(text, "invalid env var key") {
+ t.Errorf("expected key %q to be accepted, got: %s", key, text)
+ }
+ if got := len(mock.vars["site-1"]); got != 1 {
+ t.Errorf("expected 1 stored var for valid key %q, got %d", key, got)
+ }
+ })
+ }
+}
diff --git a/components/monitoring/alert_checker.go b/components/monitoring/alert_checker.go
index 8e2a5a1f7d..fcd87723ae 100644
--- a/components/monitoring/alert_checker.go
+++ b/components/monitoring/alert_checker.go
@@ -32,6 +32,18 @@ func (m *Monitoring) runAlertChecker() {
}
}
+// alertRuleRow is a scanned monitoring_alerts row, buffered before evaluation
+// so incident writes never nest inside the rule cursor (SQLite has a single
+// connection, so a nested query would block until the context deadline).
+type alertRuleRow struct {
+ id string
+ name string
+ metric string
+ threshold float64
+ operator string
+ durationSec int64
+}
+
// checkAlertRules evaluates every enabled alert rule against current host metrics.
func (m *Monitoring) checkAlertRules(breachStart map[breachKey]time.Time) {
if m.db == nil {
@@ -41,6 +53,11 @@ func (m *Monitoring) checkAlertRules(breachStart map[breachKey]time.Time) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
+ // Snapshot state ages before opening the alert-rule rows. SQLite runs with
+ // a single connection; a nested query while a rows cursor is open would
+ // block until the context deadline and make every state-age rule unknown.
+ stateAges := m.stateAgeReport(ctx)
+
rows, err := m.db.QueryContext(ctx,
"SELECT id, name, metric, threshold, operator, duration_seconds FROM monitoring_alerts WHERE enabled = 1")
if err != nil {
@@ -49,23 +66,33 @@ func (m *Monitoring) checkAlertRules(breachStart map[breachKey]time.Time) {
}
defer func() { _ = rows.Close() }()
+ rules := make([]alertRuleRow, 0)
+ for rows.Next() {
+ var rule alertRuleRow
+ if err := rows.Scan(&rule.id, &rule.name, &rule.metric, &rule.threshold,
+ &rule.operator, &rule.durationSec); err != nil {
+ m.logger.Error("alert checker scan", "error", err)
+ continue
+ }
+ rules = append(rules, rule)
+ }
+ if err := rows.Err(); err != nil {
+ m.logger.Error("alert checker rows", "error", err)
+ }
+ _ = rows.Close()
+
host := m.LatestHostMetrics()
sys := m.SystemMetrics()
now := time.Now()
var activeIDs []breachKey
- for rows.Next() {
- var id, name, metric, operator string
- var threshold float64
- var durationSec int64
-
- if err := rows.Scan(&id, &name, &metric, &threshold, &operator, &durationSec); err != nil {
- m.logger.Error("alert checker scan", "error", err)
- continue
- }
+ for _, rule := range rules {
+ id, name, metric := rule.id, rule.name, rule.metric
+ threshold, operator := rule.threshold, rule.operator
+ durationSec := rule.durationSec
- value, known := m.metricValueWithSite(metric, host, sys)
+ value, known := m.metricValueWithState(metric, host, sys, &stateAges)
if !known {
// Fail closed on unknown/typo'd metric names: log loudly and skip
// the rule so it can neither fire forever (lt) nor never fire (gt).
@@ -151,6 +178,13 @@ func metricValue(metric string, host HostMetrics, sys SystemMetrics) (float64, b
// produced a reading), site metrics are treated as unknown so a rule
// referencing them never silently fires on 0.
func (m *Monitoring) metricValueWithSite(metric string, host HostMetrics, sys SystemMetrics) (float64, bool) {
+ return m.metricValueWithState(metric, host, sys, nil)
+}
+
+// metricValueWithState is metricValueWithSite with an optional pre-computed
+// state-age report. The alert checker passes a snapshot so all rules in one
+// pass share a single query and cannot nest a DB read inside an open cursor.
+func (m *Monitoring) metricValueWithState(metric string, host HostMetrics, sys SystemMetrics, stateAges *StateAgeReport) (float64, bool) {
switch metric {
case "site_up", "site_http_status":
if m == nil || m.siteStatus == nil {
@@ -177,6 +211,13 @@ func (m *Monitoring) metricValueWithSite(metric string, host HostMetrics, sys Sy
return float64(st.HTTPStatus), true
}
default:
+ if stateAges != nil {
+ if value, ok, handled := stateAgeMetricValueFromReport(metric, *stateAges); handled {
+ return value, ok
+ }
+ } else if value, ok, handled := m.stateAgeMetricValue(metric); handled {
+ return value, ok
+ }
return metricValue(metric, host, sys)
}
return 0, false
diff --git a/components/monitoring/monitoring.go b/components/monitoring/monitoring.go
index 718e44436c..d20980479b 100644
--- a/components/monitoring/monitoring.go
+++ b/components/monitoring/monitoring.go
@@ -80,6 +80,10 @@ type Monitoring struct {
cpuSampleAt time.Time
cpuTotalNano int64
+ // nowFn is the clock used for derived state-age metrics. Nil means
+ // time.Now; tests inject a fixed clock for deterministic assertions.
+ nowFn func() time.Time
+
stopHost chan struct{}
stopAlerts chan struct{}
lifecycleMu sync.Mutex
@@ -445,7 +449,8 @@ func (m *Monitoring) handleMetrics(w http.ResponseWriter, r *http.Request) {
"by_status": byStatus,
"avg_latency_ms": avgLatency,
},
- "host": m.LatestHostMetrics(),
+ "host": m.LatestHostMetrics(),
+ "state_ages": m.stateAgeReport(r.Context()),
})
}
@@ -518,6 +523,7 @@ func (m *Monitoring) handlePrometheusMetrics(w http.ResponseWriter, r *http.Requ
writePromGauge(w, "bigbase_goroutines", "Number of goroutines", float64(s.sys.Goroutines))
writePromGauge(w, "bigbase_memory_mb", "Memory usage in MB", s.sys.MemoryMB)
writePromGauge(w, "bigbase_cpu_percent", "CPU usage percent", s.sys.CPUPercent)
+ writeStateAgeGauges(w, m.stateAgeReport(r.Context()))
for path, ep := range s.endpointStats {
writePromLinef(w, "bigbase_endpoint_requests_total{path=\"%s\"} %d\n", path, ep.count)
diff --git a/components/monitoring/state_age.go b/components/monitoring/state_age.go
new file mode 100644
index 0000000000..45b1223ba0
--- /dev/null
+++ b/components/monitoring/state_age.go
@@ -0,0 +1,318 @@
+package monitoring
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// Non-terminal state sets. They mirror the authoritative state machines in
+// components/deploy/state_machine.go (IsActive) and components/cici/state.go
+// (queued/running, with legacy "pending" aliased to queued). Monitoring owns
+// the observability copy because querying those tables must not import the
+// deploy/cici packages, which would invert the component dependency graph.
+var stateMachineSpecs = []stateMachineSpec{
+ {
+ Name: "deployment",
+ Table: "deployments",
+ TimestampColumn: "created_at",
+ States: []string{"pending", "building", "deploying", "draining"},
+ },
+ {
+ Name: "cici_run",
+ Table: "cici_runs",
+ TimestampColumn: "started_at",
+ States: []string{"queued", "running"},
+ StatusAliases: map[string]string{"pending": "queued"},
+ },
+}
+
+const stateAgeQueryTimeout = 2 * time.Second
+
+// stateMachineSpec describes one table whose rows have a non-terminal
+// lifecycle state and a timestamp from which staleness can be derived.
+type stateMachineSpec struct {
+ Name string
+ Table string
+ TimestampColumn string
+ States []string
+ // StatusAliases maps legacy status spellings onto a canonical state.
+ StatusAliases map[string]string
+}
+
+// StateAgeReading is the staleness of one machine/state pair. AgeSeconds is
+// the age of the oldest row in that state; Rows is the number of non-terminal
+// rows observed.
+type StateAgeReading struct {
+ Machine string `json:"machine"`
+ State string `json:"state"`
+ AgeSeconds float64 `json:"age_seconds"`
+ Rows int `json:"rows"`
+ OldestID string `json:"oldest_id,omitempty"`
+}
+
+// StateAgeReport is the derived view of rows stuck in non-terminal states.
+// Worst identifies the single oldest row across every machine/state, so
+// operators can jump straight to the offender. Unavailable lists machines whose
+// table could not be queried (for example, a deployment-free installation).
+type StateAgeReport struct {
+ Worst *StateAgeReading `json:"worst,omitempty"`
+ ByState []StateAgeReading `json:"by_state"`
+ Unavailable []string `json:"unavailable,omitempty"`
+}
+
+// now returns the injected clock when set, otherwise wall time. Injecting a
+// clock keeps state-age assertions deterministic.
+func (m *Monitoring) now() time.Time {
+ if m != nil && m.nowFn != nil {
+ return m.nowFn()
+ }
+ return time.Now()
+}
+
+// stateAgeReport queries every configured state machine and returns the
+// staleness of each non-terminal state.
+func (m *Monitoring) stateAgeReport(ctx context.Context) StateAgeReport {
+ report := StateAgeReport{ByState: []StateAgeReading{}}
+ if m == nil || m.db == nil {
+ for _, spec := range stateMachineSpecs {
+ report.Unavailable = append(report.Unavailable, spec.Name)
+ }
+ return report
+ }
+
+ now := m.now()
+ for _, spec := range stateMachineSpecs {
+ readings, err := m.readStateAges(ctx, spec, now)
+ if err != nil {
+ report.Unavailable = append(report.Unavailable, spec.Name)
+ continue
+ }
+ report.ByState = append(report.ByState, readings...)
+ }
+
+ for i := range report.ByState {
+ r := &report.ByState[i]
+ if r.Rows == 0 {
+ continue
+ }
+ if report.Worst == nil || r.AgeSeconds > report.Worst.AgeSeconds {
+ worst := *r
+ report.Worst = &worst
+ }
+ }
+ return report
+}
+
+// readStateAges returns one reading per declared state, in declaration order.
+// The state set is static so a state with zero rows still surfaces as a zero
+// gauge instead of disappearing from the metric.
+func (m *Monitoring) readStateAges(ctx context.Context, spec stateMachineSpec, now time.Time) ([]StateAgeReading, error) {
+ readings := make([]StateAgeReading, 0, len(spec.States))
+ index := make(map[string]int, len(spec.States))
+ for _, state := range spec.States {
+ index[state] = len(readings)
+ readings = append(readings, StateAgeReading{Machine: spec.Name, State: state})
+ }
+
+ queryStates := append([]string{}, spec.States...)
+ for alias := range spec.StatusAliases {
+ queryStates = append(queryStates, alias)
+ }
+ placeholders := strings.TrimRight(strings.Repeat("?,", len(queryStates)), ",")
+ query := fmt.Sprintf("SELECT id, status, %s FROM %s WHERE status IN (%s)",
+ spec.TimestampColumn, spec.Table, placeholders)
+ args := make([]any, len(queryStates))
+ for i, state := range queryStates {
+ args[i] = state
+ }
+
+ rows, err := m.db.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+
+ for rows.Next() {
+ var id, status string
+ var rawTimestamp sql.NullString
+ if err := rows.Scan(&id, &status, &rawTimestamp); err != nil {
+ return nil, err
+ }
+ canonical := strings.ToLower(strings.TrimSpace(status))
+ if alias, ok := spec.StatusAliases[canonical]; ok {
+ canonical = alias
+ }
+ pos, ok := index[canonical]
+ if !ok {
+ continue
+ }
+ reading := &readings[pos]
+ reading.Rows++
+
+ observedAt, err := parseStateAgeTimestamp(rawTimestamp.String)
+ if err != nil {
+ continue
+ }
+ age := now.Sub(observedAt).Seconds()
+ if age < 0 {
+ age = 0
+ }
+ if age > reading.AgeSeconds || reading.OldestID == "" {
+ reading.AgeSeconds = age
+ reading.OldestID = id
+ }
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return readings, nil
+}
+
+// parseStateAgeTimestamp accepts both the RFC3339 timestamps written by the
+// deploy/cici components and the "YYYY-MM-DD HH:MM:SS" form produced by
+// SQLite's datetime('now') default.
+func parseStateAgeTimestamp(raw string) (time.Time, error) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return time.Time{}, fmt.Errorf("empty timestamp")
+ }
+ for _, layout := range []string{
+ time.RFC3339Nano,
+ time.RFC3339,
+ "2006-01-02 15:04:05",
+ "2006-01-02T15:04:05",
+ } {
+ if layout == "2006-01-02 15:04:05" || layout == "2006-01-02T15:04:05" {
+ if parsed, err := time.ParseInLocation(layout, raw, time.UTC); err == nil {
+ return parsed, nil
+ }
+ continue
+ }
+ if parsed, err := time.Parse(layout, raw); err == nil {
+ return parsed, nil
+ }
+ }
+ return time.Time{}, fmt.Errorf("unrecognized timestamp %q", raw)
+}
+
+// machineAvailable reports whether a machine's table was queried successfully.
+func (r StateAgeReport) machineAvailable(machine string) bool {
+ for _, unavailable := range r.Unavailable {
+ if unavailable == machine {
+ return false
+ }
+ }
+ return true
+}
+
+// maxAge returns the oldest reading for a machine (or across all machines when
+// machine is empty). The second return value is false when no machine could be
+// queried, so callers can fail closed instead of alerting on an unknown value.
+func (r StateAgeReport) maxAge(machine string) (float64, bool) {
+ available := false
+ max := 0.0
+ for _, reading := range r.ByState {
+ if machine != "" && reading.Machine != machine {
+ continue
+ }
+ available = true
+ if reading.AgeSeconds > max {
+ max = reading.AgeSeconds
+ }
+ }
+ if available {
+ return max, true
+ }
+ for _, spec := range stateMachineSpecs {
+ if (machine == "" || spec.Name == machine) && r.machineAvailable(spec.Name) {
+ return 0, true
+ }
+ }
+ return 0, false
+}
+
+// stateAgeMetricValue resolves alert-rule metric names for state staleness.
+// The bool pair is (value, ok), matching metricValue; the third return value
+// reports whether the name belongs to the state-age family at all, so
+// metricValueWithSite can fall through to the host/process metrics.
+//
+// Supported names:
+// - state_age_seconds — oldest row across all machines
+// - deployment_state_age_seconds — oldest deployment
+// - deployment_pending_age_seconds — oldest deployment in one state
+// - cici_run_state_age_seconds — oldest CICI run
+// - cici_run_running_age_seconds — oldest CICI run in one state
+// - cici_state_age_seconds / cici_queued_age_seconds (short aliases)
+func (m *Monitoring) stateAgeMetricValue(metric string) (float64, bool, bool) {
+ return stateAgeMetricValueFromReport(metric, m.stateAgeReportForAlerts())
+}
+
+func stateAgeMetricValueFromReport(metric string, report StateAgeReport) (float64, bool, bool) {
+ if metric == "state_age_seconds" {
+ value, ok := report.maxAge("")
+ return value, ok, true
+ }
+
+ for _, spec := range stateMachineSpecs {
+ prefixes := []string{spec.Name, strings.TrimSuffix(spec.Name, "_run")}
+ for _, prefix := range prefixes {
+ if metric == prefix+"_state_age_seconds" {
+ value, ok := report.maxAge(spec.Name)
+ return value, ok, true
+ }
+ for _, state := range spec.States {
+ if metric == prefix+"_"+state+"_age_seconds" {
+ return report.stateAgeFor(spec.Name, state)
+ }
+ }
+ }
+ }
+ return 0, false, false
+}
+
+// stateAgeFor returns the age of one machine/state pair. An unavailable table
+// or an unknown state is reported as unknown (ok=false).
+func (r StateAgeReport) stateAgeFor(machine, state string) (float64, bool, bool) {
+ if !r.machineAvailable(machine) {
+ return 0, false, true
+ }
+ for _, reading := range r.ByState {
+ if reading.Machine == machine && reading.State == state {
+ return reading.AgeSeconds, true, true
+ }
+ }
+ return 0, false, true
+}
+
+// writeStateAgeGauges emits the derived staleness gauge for every available
+// machine/state pair. Unavailable tables are skipped so a deployment-free
+// installation does not advertise a misleading 0.
+func writeStateAgeGauges(w http.ResponseWriter, report StateAgeReport) {
+ if len(report.ByState) == 0 {
+ return
+ }
+ writePromLine(w, "# HELP bigbase_state_age_seconds Age of the oldest row in a non-terminal state, in seconds\n")
+ writePromLine(w, "# TYPE bigbase_state_age_seconds gauge\n")
+ for _, reading := range report.ByState {
+ if !report.machineAvailable(reading.Machine) {
+ continue
+ }
+ writePromLinef(w, "bigbase_state_age_seconds{machine=\"%s\",state=\"%s\"} %.2f\n",
+ reading.Machine, reading.State, reading.AgeSeconds)
+ }
+ writePromLine(w, "\n")
+}
+
+// stateAgeReportForAlerts computes a fresh report for the alert checker.
+func (m *Monitoring) stateAgeReportForAlerts() StateAgeReport {
+ if m == nil || m.db == nil {
+ return StateAgeReport{}
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), stateAgeQueryTimeout)
+ defer cancel()
+ return m.stateAgeReport(ctx)
+}
diff --git a/components/monitoring/state_age_test.go b/components/monitoring/state_age_test.go
new file mode 100644
index 0000000000..8c3f7a2903
--- /dev/null
+++ b/components/monitoring/state_age_test.go
@@ -0,0 +1,323 @@
+package monitoring
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/danielvm/bigbase/components/db"
+ "github.com/danielvm/bigbase/kernel"
+)
+
+var stateAgeTestNow = time.Date(2026, time.January, 2, 12, 0, 0, 0, time.UTC)
+
+func newStateAgeDB(t *testing.T) *db.DB {
+ t.Helper()
+ database := db.New(db.Options{Path: ":memory:", Logger: kernel.NoopLogger{}})
+ if err := database.Init(nil, nil); err != nil {
+ t.Fatalf("db init: %v", err)
+ }
+ if err := database.Start(nil); err != nil {
+ t.Fatalf("db start: %v", err)
+ }
+ t.Cleanup(func() { _ = database.Stop(nil) })
+ return database
+}
+
+func newStateAgeFixture(t *testing.T) (*Monitoring, DBer) {
+ t.Helper()
+ database := newStateAgeDB(t)
+ for _, schema := range []string{
+ `CREATE TABLE deployments (
+ id TEXT PRIMARY KEY,
+ status TEXT NOT NULL,
+ created_at TEXT NOT NULL
+ )`,
+ `CREATE TABLE cici_runs (
+ id TEXT PRIMARY KEY,
+ status TEXT NOT NULL,
+ started_at TEXT NOT NULL
+ )`,
+ `CREATE TABLE monitoring_alerts (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ metric TEXT NOT NULL,
+ threshold REAL NOT NULL DEFAULT 0,
+ operator TEXT NOT NULL DEFAULT 'gt',
+ enabled INTEGER NOT NULL DEFAULT 1,
+ duration_seconds INTEGER NOT NULL DEFAULT 300
+ )`,
+ } {
+ if _, err := database.ExecContext(context.Background(), schema); err != nil {
+ t.Fatalf("create schema: %v", err)
+ }
+ }
+ m := New(Options{DB: database, Logger: kernel.NoopLogger{}})
+ m.nowFn = func() time.Time { return stateAgeTestNow }
+ return m, database
+}
+
+func seedDeployment(t *testing.T, d DBer, id, status string, age time.Duration) {
+ t.Helper()
+ if _, err := d.ExecContext(context.Background(),
+ `INSERT INTO deployments (id, status, created_at) VALUES (?, ?, ?)`,
+ id, status, stateAgeTestNow.Add(-age).Format(time.RFC3339)); err != nil {
+ t.Fatalf("seed deployment %s: %v", id, err)
+ }
+}
+
+func seedCiciRun(t *testing.T, d DBer, id, status string, age time.Duration) {
+ t.Helper()
+ if _, err := d.ExecContext(context.Background(),
+ `INSERT INTO cici_runs (id, status, started_at) VALUES (?, ?, ?)`,
+ id, status, stateAgeTestNow.Add(-age).Format(time.RFC3339)); err != nil {
+ t.Fatalf("seed cici run %s: %v", id, err)
+ }
+}
+
+func findReading(report StateAgeReport, machine, state string) (StateAgeReading, bool) {
+ for _, r := range report.ByState {
+ if r.Machine == machine && r.State == state {
+ return r, true
+ }
+ }
+ return StateAgeReading{}, false
+}
+
+func TestStateAgeReportCoversNonTerminalStates(t *testing.T) {
+ m, d := newStateAgeFixture(t)
+
+ seedDeployment(t, d, "dep-pending", "pending", 10*time.Minute)
+ seedDeployment(t, d, "dep-building", "building", 45*time.Minute)
+ seedDeployment(t, d, "dep-running", "running", 2*time.Hour)
+ seedDeployment(t, d, "dep-failed", "failed", 3*time.Hour)
+
+ seedCiciRun(t, d, "run-running", "running", 30*time.Minute)
+ seedCiciRun(t, d, "run-queued", "queued", 5*time.Minute)
+ seedCiciRun(t, d, "run-legacy-pending", "pending", 15*time.Minute)
+ seedCiciRun(t, d, "run-success", "success", 4*time.Hour)
+
+ report := m.stateAgeReport(context.Background())
+
+ tests := []struct {
+ machine string
+ state string
+ age float64
+ rows int
+ oldest string
+ }{
+ {"deployment", "pending", 600, 1, "dep-pending"},
+ {"deployment", "building", 2700, 1, "dep-building"},
+ {"deployment", "deploying", 0, 0, ""},
+ {"deployment", "draining", 0, 0, ""},
+ {"cici_run", "running", 1800, 1, "run-running"},
+ {"cici_run", "queued", 900, 2, "run-legacy-pending"},
+ }
+ for _, tt := range tests {
+ r, ok := findReading(report, tt.machine, tt.state)
+ if !ok {
+ t.Fatalf("missing reading %s/%s", tt.machine, tt.state)
+ }
+ if r.AgeSeconds != tt.age {
+ t.Errorf("%s/%s age = %f, want %f", tt.machine, tt.state, r.AgeSeconds, tt.age)
+ }
+ if r.Rows != tt.rows {
+ t.Errorf("%s/%s rows = %d, want %d", tt.machine, tt.state, r.Rows, tt.rows)
+ }
+ if r.OldestID != tt.oldest {
+ t.Errorf("%s/%s oldest = %q, want %q", tt.machine, tt.state, r.OldestID, tt.oldest)
+ }
+ }
+
+ if _, ok := findReading(report, "deployment", "running"); ok {
+ t.Error("running deployment state must not appear in the non-terminal report")
+ }
+ if _, ok := findReading(report, "deployment", "failed"); ok {
+ t.Error("terminal deployment state must not appear in the report")
+ }
+ if _, ok := findReading(report, "cici_run", "success"); ok {
+ t.Error("terminal cici state must not appear in the report")
+ }
+
+ if report.Worst == nil {
+ t.Fatal("expected a worst offender")
+ }
+ if report.Worst.Machine != "deployment" || report.Worst.State != "building" {
+ t.Fatalf("worst = %s/%s, want deployment/building", report.Worst.Machine, report.Worst.State)
+ }
+ if report.Worst.AgeSeconds != 2700 || report.Worst.OldestID != "dep-building" {
+ t.Fatalf("worst reading = %+v", *report.Worst)
+ }
+}
+
+func TestStateAgeReportParsesSQLiteDatetime(t *testing.T) {
+ m, d := newStateAgeFixture(t)
+
+ if _, err := d.ExecContext(context.Background(),
+ `INSERT INTO deployments (id, status, created_at) VALUES (?, ?, ?)`,
+ "dep-sqlite-ts", "deploying", stateAgeTestNow.Add(-20*time.Minute).Format("2006-01-02 15:04:05")); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+
+ report := m.stateAgeReport(context.Background())
+ r, ok := findReading(report, "deployment", "deploying")
+ if !ok {
+ t.Fatal("missing deployment/deploying reading")
+ }
+ if r.AgeSeconds != 1200 {
+ t.Fatalf("age = %f, want 1200", r.AgeSeconds)
+ }
+}
+
+func TestStateAgeReportMissingTableIsUnavailable(t *testing.T) {
+ database := newStateAgeDB(t)
+ m := New(Options{DB: database, Logger: kernel.NoopLogger{}})
+ m.nowFn = func() time.Time { return stateAgeTestNow }
+
+ report := m.stateAgeReport(context.Background())
+ if len(report.ByState) != 0 {
+ t.Fatalf("expected no readings without tables, got %d", len(report.ByState))
+ }
+ if len(report.Unavailable) != len(stateMachineSpecs) {
+ t.Fatalf("unavailable = %v, want all machines", report.Unavailable)
+ }
+
+ if _, ok := m.metricValueWithSite("deployment_state_age_seconds", HostMetrics{}, SystemMetrics{}); ok {
+ t.Fatal("state-age metric must be unknown when its table is unavailable")
+ }
+}
+
+func TestStateAgeMetricValue(t *testing.T) {
+ m, d := newStateAgeFixture(t)
+ seedDeployment(t, d, "dep-pending", "pending", 10*time.Minute)
+ seedDeployment(t, d, "dep-building", "building", 45*time.Minute)
+ seedCiciRun(t, d, "run-queued", "queued", 15*time.Minute)
+
+ tests := []struct {
+ metric string
+ want float64
+ ok bool
+ }{
+ {"state_age_seconds", 2700, true},
+ {"deployment_state_age_seconds", 2700, true},
+ {"deployment_pending_age_seconds", 600, true},
+ {"deployment_draining_age_seconds", 0, true},
+ {"deployment_running_age_seconds", 0, false},
+ {"cici_run_state_age_seconds", 900, true},
+ {"cici_state_age_seconds", 900, true},
+ {"cici_run_queued_age_seconds", 900, true},
+ {"cici_queued_age_seconds", 900, true},
+ {"deployment_unknown_age_seconds", 0, false},
+ {"unrelated_metric", 0, false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.metric, func(t *testing.T) {
+ got, ok := m.metricValueWithSite(tt.metric, HostMetrics{}, SystemMetrics{})
+ if ok != tt.ok {
+ t.Fatalf("ok = %v, want %v", ok, tt.ok)
+ }
+ if ok && got != tt.want {
+ t.Fatalf("value = %f, want %f", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestStateAgeAlertRuleTriggersIncident(t *testing.T) {
+ m, d := newStateAgeFixture(t)
+ if err := m.initObservability(); err != nil {
+ t.Fatalf("init observability: %v", err)
+ }
+ seedDeployment(t, d, "dep-stuck", "building", 30*time.Minute)
+
+ if _, err := d.ExecContext(context.Background(),
+ `INSERT INTO monitoring_alerts (id, name, metric, threshold, operator, enabled, duration_seconds)
+ VALUES ('stuck-deploy', 'stuck deploy', 'deployment_state_age_seconds', 600, 'gt', 1, 0)`); err != nil {
+ t.Fatalf("seed alert: %v", err)
+ }
+
+ m.checkAlertRules(map[breachKey]time.Time{})
+
+ var count, triggered int
+ if err := d.QueryRowContext(context.Background(),
+ `SELECT COUNT(*), COALESCE(SUM(triggered), 0) FROM monitoring_alert_incidents WHERE rule_id = 'stuck-deploy'`).Scan(&count, &triggered); err != nil {
+ t.Fatalf("count incidents: %v", err)
+ }
+ if count != 1 || triggered != 1 {
+ t.Fatalf("incidents = %d, triggered = %d, want 1/1", count, triggered)
+ }
+}
+
+func TestStateAgeAlertRuleBelowThresholdDoesNotTrigger(t *testing.T) {
+ m, d := newStateAgeFixture(t)
+ if err := m.initObservability(); err != nil {
+ t.Fatalf("init observability: %v", err)
+ }
+ seedDeployment(t, d, "dep-fresh", "building", 5*time.Minute)
+
+ if _, err := d.ExecContext(context.Background(),
+ `INSERT INTO monitoring_alerts (id, name, metric, threshold, operator, enabled, duration_seconds)
+ VALUES ('stuck-deploy', 'stuck deploy', 'deployment_state_age_seconds', 600, 'gt', 1, 0)`); err != nil {
+ t.Fatalf("seed alert: %v", err)
+ }
+
+ m.checkAlertRules(map[breachKey]time.Time{})
+
+ var count int
+ if err := d.QueryRowContext(context.Background(),
+ `SELECT COUNT(*) FROM monitoring_alert_incidents WHERE rule_id = 'stuck-deploy'`).Scan(&count); err != nil {
+ t.Fatalf("count incidents: %v", err)
+ }
+ if count != 0 {
+ t.Fatalf("incidents = %d, want 0", count)
+ }
+}
+
+func TestStateAgeExposedOnMetricsEndpoints(t *testing.T) {
+ m, d := newStateAgeFixture(t)
+ seedDeployment(t, d, "dep-stuck", "building", 30*time.Minute)
+ seedCiciRun(t, d, "run-stuck", "running", 10*time.Minute)
+
+ mux := m.Handler()
+
+ t.Run("json", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/api/monitoring/metrics", nil)
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d: %s", w.Code, w.Body.String())
+ }
+ var body struct {
+ StateAges StateAgeReport `json:"state_ages"`
+ }
+ if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if body.StateAges.Worst == nil || body.StateAges.Worst.OldestID != "dep-stuck" {
+ t.Fatalf("worst = %+v, want dep-stuck", body.StateAges.Worst)
+ }
+ })
+
+ t.Run("prometheus", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/api/monitoring/metrics/prometheus", nil)
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d: %s", w.Code, w.Body.String())
+ }
+ body := w.Body.String()
+ for _, want := range []string{
+ `bigbase_state_age_seconds{machine="deployment",state="building"} 1800.00`,
+ `bigbase_state_age_seconds{machine="cici_run",state="running"} 600.00`,
+ `# TYPE bigbase_state_age_seconds gauge`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Errorf("prometheus output missing %q\n%s", want, body)
+ }
+ }
+ })
+}
diff --git a/components/sites/domains.go b/components/sites/domains.go
index 727ef27f89..8910736668 100644
--- a/components/sites/domains.go
+++ b/components/sites/domains.go
@@ -39,6 +39,32 @@ var domainRE = regexp.MustCompile(`^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[
// certWarnWindow is how far before expiry a certificate is flagged "expiring_soon".
const certWarnWindow = 30 * 24 * time.Hour
+// Certificate attempt budget. A verified domain with no cached certificate is
+// "provisioning"; it ages out to "failed" once either limit is reached so a
+// broken ACME issuance cannot stay provisioning forever.
+const (
+ // certMaxAttempts is the number of provisioning observations tolerated
+ // before the domain is reported as failed.
+ certMaxAttempts = 5
+ // certStaleAfter is the wall-clock age, measured from the start of the
+ // provisioning window, after which the domain is reported as failed.
+ certStaleAfter = 15 * time.Minute
+ // certRetryAfter is the minimum interval between recorded provisioning
+ // observations and the backoff advertised after a manual retry.
+ certRetryAfter = 2 * time.Minute
+)
+
+// certState is the persisted attempt metadata for one domain. Nil timestamps
+// mean "not recorded yet"; the verified_at column is used as a fallback anchor
+// so domains verified before this metadata existed still age out.
+type certState struct {
+ Attempts int
+ LastAttemptAt *time.Time
+ LastError string
+ RetryAfter *time.Time
+ StartedAt *time.Time
+}
+
type SiteDomain struct {
ID string `json:"id"`
SiteID string `json:"site_id"`
@@ -50,6 +76,20 @@ type SiteDomain struct {
// never stored. Empty when the domain is unverified or HTTPS is disabled.
CertStatus string `json:"cert_status,omitempty"`
CertExpiresAt *string `json:"cert_expires_at,omitempty"`
+ // CertError carries the stored failure reason when cert_status=failed.
+ CertError string `json:"cert_error,omitempty"`
+ // CertAttempts and CertRetryAfter expose the persisted attempt budget so
+ // operators (and the UI) can see how close a domain is to failing.
+ CertAttempts int `json:"cert_attempts"`
+ CertRetryAfter *string `json:"cert_retry_after,omitempty"`
+}
+
+// CertRetryResult is the typed response returned by the manual retry endpoint.
+type CertRetryResult struct {
+ Domain string `json:"domain"`
+ CertStatus string `json:"cert_status"`
+ Attempts int `json:"attempts"`
+ RetryAfter string `json:"retry_after,omitempty"`
}
// verifyLimiter throttles the (DNS-lookup-backed) verify endpoint per site+domain
@@ -75,7 +115,26 @@ func (v *verifyLimiter) allow(key string, now time.Time) bool {
}
func (s *Sites) migrateDomains() error {
- return s.db.Migrate(domainsMigration)
+ if err := s.db.Migrate(domainsMigration); err != nil {
+ return err
+ }
+ // Persisted certificate attempt metadata. ALTER TABLE ADD COLUMN is not
+ // idempotent, so each statement is attempted and duplicate-column errors
+ // ignored. Any unexpected database failure is returned wrapped.
+ // The DDL is deliberately dialect-neutral (TEXT / INTEGER, no datetime('now')
+ // defaults) so it also applies cleanly on PostgreSQL.
+ for _, stmt := range []string{
+ `ALTER TABLE site_domains ADD COLUMN cert_attempts INTEGER NOT NULL DEFAULT 0`,
+ `ALTER TABLE site_domains ADD COLUMN cert_started_at TEXT`,
+ `ALTER TABLE site_domains ADD COLUMN cert_last_attempt_at TEXT`,
+ `ALTER TABLE site_domains ADD COLUMN cert_last_error TEXT NOT NULL DEFAULT ''`,
+ `ALTER TABLE site_domains ADD COLUMN cert_retry_after TEXT`,
+ } {
+ if err := s.db.Migrate(stmt); err != nil && !isDuplicateColumnError(err) {
+ return fmt.Errorf("migrate site_domains column: %w", err)
+ }
+ }
+ return nil
}
func (s *Sites) handleDomains(w http.ResponseWriter, r *http.Request) {
@@ -102,6 +161,8 @@ func (s *Sites) handleDomains(w http.ResponseWriter, r *http.Request) {
s.deleteDomain(w, r, siteID, parts[2])
case len(parts) == 4 && parts[3] == "verify" && r.Method == http.MethodPost:
s.verifyDomain(w, r, siteID, parts[2])
+ case len(parts) == 5 && parts[3] == "cert" && parts[4] == "retry" && r.Method == http.MethodPost:
+ s.retryDomainCert(w, r, siteID, parts[2])
default:
kernel.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
}
@@ -112,7 +173,8 @@ func (s *Sites) listDomains(w http.ResponseWriter, r *http.Request, siteID strin
defer cancel()
rows, err := s.db.QueryContext(ctx,
- `SELECT id, site_id, domain, verify_token, verified_at, created_at
+ `SELECT id, site_id, domain, verify_token, verified_at, created_at,
+ cert_attempts, cert_started_at, cert_last_attempt_at, cert_last_error, cert_retry_after
FROM site_domains WHERE site_id = ? ORDER BY created_at`, siteID)
if err != nil {
s.logger.Error("list domains", "site_id", siteID, "error", err)
@@ -122,15 +184,25 @@ func (s *Sites) listDomains(w http.ResponseWriter, r *http.Request, siteID strin
defer func() { _ = rows.Close() }()
domains := make([]SiteDomain, 0)
+ states := make([]certState, 0)
for rows.Next() {
var d SiteDomain
- var verifiedAt *string
- if err := rows.Scan(&d.ID, &d.SiteID, &d.Domain, &d.VerifyToken, &verifiedAt, &d.CreatedAt); err != nil {
+ var verifiedAt, startedAt, lastAttemptAt, retryAfter *string
+ var lastError string
+ if err := rows.Scan(&d.ID, &d.SiteID, &d.Domain, &d.VerifyToken, &verifiedAt, &d.CreatedAt,
+ &d.CertAttempts, &startedAt, &lastAttemptAt, &lastError, &retryAfter); err != nil {
s.logger.Error("scan domain", "error", err)
continue
}
d.VerifiedAt = verifiedAt
domains = append(domains, d)
+ states = append(states, certState{
+ Attempts: d.CertAttempts,
+ StartedAt: parseRFC3339Ptr(startedAt),
+ LastAttemptAt: parseRFC3339Ptr(lastAttemptAt),
+ LastError: lastError,
+ RetryAfter: parseRFC3339Ptr(retryAfter),
+ })
}
if err := rows.Err(); err != nil {
s.logger.Error("iterate domains", "error", err)
@@ -138,27 +210,131 @@ func (s *Sites) listDomains(w http.ResponseWriter, r *http.Request, siteID strin
return
}
- // Annotate verified domains with live certificate status from the proxy.
+ // Annotate verified domains with live certificate status from the proxy,
+ // falling back to the persisted attempt budget when no certificate is cached.
for i := range domains {
if domains[i].VerifiedAt == nil {
continue
}
- status, expiresAt := s.certStatusFor(ctx, domains[i].Domain)
+ st := &states[i]
+ status, expiresAt, reason := s.certStatusFor(ctx, siteID, domains[i].Domain, domains[i].VerifiedAt, st)
domains[i].CertStatus = status
domains[i].CertExpiresAt = expiresAt
+ domains[i].CertError = reason
+ domains[i].CertAttempts = st.Attempts
+ if st.RetryAfter != nil {
+ iso := st.RetryAfter.UTC().Format(time.RFC3339)
+ domains[i].CertRetryAfter = &iso
+ }
}
kernel.WriteJSON(w, http.StatusOK, map[string]any{"data": domains})
}
// certStatusFor resolves the certificate status for a verified domain via the
-// optional CertInfo hook. Returns "provisioning" when no cert is cached yet.
-func (s *Sites) certStatusFor(ctx context.Context, domain string) (string, *string) {
+// optional CertInfo hook, then applies the persisted attempt budget when no
+// certificate is cached yet. It returns the status, ISO expiry (when present),
+// and a human-readable failure reason (only when status is "failed").
+//
+// While a domain is still provisioning, this records at most one attempt
+// observation per certRetryAfter window. That advances the N-attempt budget
+// without requiring a live ACME callback inside the sites component.
+func (s *Sites) certStatusFor(ctx context.Context, siteID, domain string, verifiedAt *string, st *certState) (string, *string, string) {
if s.certInfo == nil {
- return "", nil
+ return "", nil, ""
+ }
+ now := time.Now().UTC()
+ if notAfter, ok := s.certInfo(ctx, domain); ok {
+ status, expiresAt := certStatus(notAfter, true, now)
+ return status, expiresAt, ""
+ }
+
+ verified := parseRFC3339Ptr(verifiedAt)
+ if failed, reason := evaluateCertAttempts(now, *st, verified); failed {
+ return "failed", nil, reason
+ }
+
+ if st.RetryAfter == nil || !now.Before(*st.RetryAfter) {
+ next := now.Add(certRetryAfter)
+ recorded, err := s.recordCertAttempt(ctx, siteID, domain, now, next)
+ if err != nil {
+ s.logger.Warn("record cert attempt", "site_id", siteID, "domain", domain, "error", err)
+ } else if recorded {
+ st.Attempts++
+ st.LastAttemptAt = &now
+ st.RetryAfter = &next
+ if st.StartedAt == nil {
+ st.StartedAt = &now
+ }
+ if failed, reason := evaluateCertAttempts(now, *st, verified); failed {
+ return "failed", nil, reason
+ }
+ }
+ }
+ return "provisioning", nil, ""
+}
+
+// evaluateCertAttempts reports whether a provisioning domain has exhausted its
+// retry budget (N attempts) or its provisioning window (T time).
+func evaluateCertAttempts(now time.Time, st certState, verifiedAt *time.Time) (bool, string) {
+ if st.Attempts >= certMaxAttempts {
+ return true, certFailureReason(st.LastError,
+ fmt.Sprintf("certificate provisioning failed after %d attempts", st.Attempts))
+ }
+ anchor := st.StartedAt
+ if anchor == nil {
+ anchor = verifiedAt
+ }
+ if anchor != nil && now.Sub(*anchor) >= certStaleAfter {
+ return true, certFailureReason(st.LastError,
+ fmt.Sprintf("certificate provisioning timed out after %s", certStaleAfter))
+ }
+ return false, ""
+}
+
+func certFailureReason(lastError, fallback string) string {
+ if strings.TrimSpace(lastError) != "" {
+ return lastError
+ }
+ return fallback
+}
+
+// recordCertAttempt persists one provisioning observation and advances the
+// retry window. The WHERE clause makes the observation atomic across concurrent
+// list calls, so only one caller can consume a retry window. cert_started_at is
+// only set once so the T timeout is measured from the beginning of the
+// provisioning window, not from the latest poll. It reports whether a row was
+// actually updated.
+func (s *Sites) recordCertAttempt(ctx context.Context, siteID, domain string, now, next time.Time) (bool, error) {
+ res, err := s.db.ExecContext(ctx,
+ `UPDATE site_domains
+ SET cert_attempts = cert_attempts + 1,
+ cert_last_attempt_at = ?,
+ cert_retry_after = ?,
+ cert_started_at = COALESCE(cert_started_at, ?)
+ WHERE site_id = ? AND domain = ?
+ AND (cert_retry_after IS NULL OR cert_retry_after <= ?)`,
+ now.Format(time.RFC3339), next.Format(time.RFC3339), now.Format(time.RFC3339),
+ siteID, domain, now.Format(time.RFC3339))
+ if err != nil {
+ return false, err
+ }
+ affected, err := res.RowsAffected()
+ if err != nil {
+ return false, err
+ }
+ return affected > 0, nil
+}
+
+func parseRFC3339Ptr(v *string) *time.Time {
+ if v == nil || *v == "" {
+ return nil
}
- notAfter, ok := s.certInfo(ctx, domain)
- return certStatus(notAfter, ok, time.Now())
+ t, err := time.Parse(time.RFC3339, *v)
+ if err != nil {
+ return nil
+ }
+ return &t
}
// certStatus maps a certificate expiry to a display status + ISO expiry string.
@@ -298,8 +474,11 @@ func (s *Sites) verifyDomain(w http.ResponseWriter, r *http.Request, siteID, dom
if verified {
now := time.Now().UTC().Format(time.RFC3339)
_, _ = s.db.ExecContext(ctx,
- `UPDATE site_domains SET verified_at = ? WHERE site_id = ? AND domain = ?`,
- now, siteID, domain)
+ `UPDATE site_domains
+ SET verified_at = ?,
+ cert_started_at = COALESCE(cert_started_at, ?)
+ WHERE site_id = ? AND domain = ?`,
+ now, now, siteID, domain)
// Activate routing immediately rather than waiting for the next deploy.
if s.activateDomain != nil {
if err := s.activateDomain(ctx, siteID, domain); err != nil {
@@ -316,6 +495,72 @@ func (s *Sites) verifyDomain(w http.ResponseWriter, r *http.Request, siteID, dom
})
}
+// retryDomainCert resets the certificate attempt budget for a verified domain
+// and asks the proxy to re-register the host so issuance can be retried. It is
+// the manual escape hatch for a domain stuck in cert_status=failed.
+func (s *Sites) retryDomainCert(w http.ResponseWriter, r *http.Request, siteID, domain string) {
+ if !s.verifyLim.allow("cert|"+siteID+"|"+domain, time.Now()) {
+ w.Header().Set("Retry-After", "5")
+ kernel.WriteJSON(w, http.StatusTooManyRequests, map[string]string{"error": "too many retry attempts, try again shortly"})
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
+ defer cancel()
+
+ var verifiedAt *string
+ err := s.db.QueryRowContext(ctx,
+ `SELECT verified_at FROM site_domains WHERE site_id = ? AND domain = ?`,
+ siteID, domain).Scan(&verifiedAt)
+ if err != nil {
+ kernel.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "domain not registered"})
+ return
+ }
+ if verifiedAt == nil {
+ kernel.WriteJSON(w, http.StatusConflict, map[string]string{"error": "domain is not verified"})
+ return
+ }
+
+ now := time.Now().UTC()
+ retryAfter := now.Add(certRetryAfter)
+ _, err = s.db.ExecContext(ctx,
+ `UPDATE site_domains
+ SET cert_attempts = 0,
+ cert_started_at = ?,
+ cert_last_attempt_at = ?,
+ cert_last_error = '',
+ cert_retry_after = ?
+ WHERE site_id = ? AND domain = ?`,
+ now.Format(time.RFC3339), now.Format(time.RFC3339), retryAfter.Format(time.RFC3339), siteID, domain)
+ if err != nil {
+ s.logger.Error("reset cert attempts", "site_id", siteID, "domain", domain, "error", err)
+ kernel.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
+ return
+ }
+
+ // Re-register the host with the proxy so a previously failed domain is
+ // served again and autocert gets a fresh issuance opportunity.
+ if s.activateDomain != nil {
+ if err := s.activateDomain(ctx, siteID, domain); err != nil {
+ s.logger.Warn("activate custom domain after cert retry", "domain", domain, "error", err)
+ }
+ }
+
+ status := "provisioning"
+ if s.certInfo != nil {
+ if notAfter, ok := s.certInfo(ctx, domain); ok {
+ status, _ = certStatus(notAfter, true, now)
+ }
+ }
+
+ kernel.WriteJSON(w, http.StatusOK, CertRetryResult{
+ Domain: domain,
+ CertStatus: status,
+ Attempts: 0,
+ RetryAfter: retryAfter.Format(time.RFC3339),
+ })
+}
+
// checkDNSTXT looks up TXT records for the domain and returns true if expected is found.
func checkDNSTXT(ctx context.Context, domain, expected string) bool {
records, err := net.DefaultResolver.LookupTXT(ctx, domain)
diff --git a/components/sites/domains_cert_test.go b/components/sites/domains_cert_test.go
new file mode 100644
index 0000000000..84534086e9
--- /dev/null
+++ b/components/sites/domains_cert_test.go
@@ -0,0 +1,291 @@
+package sites_test
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/danielvm/bigbase/components/db"
+ "github.com/danielvm/bigbase/components/git"
+ "github.com/danielvm/bigbase/components/sites"
+ "github.com/danielvm/bigbase/kernel"
+)
+
+// certMaxAttempts mirrors the unexported budget in the sites package. Keeping
+// the literal here makes the test an independent oracle for the contract.
+const certMaxAttempts = 5
+
+func setupCertTest(t *testing.T, info sites.CertInfoFunc) (*sites.Sites, http.Handler, *db.DB) {
+ t.Helper()
+ logger := testLogger{}
+ k := kernel.New(logger)
+
+ d := db.New(db.Options{Path: ":memory:", Logger: logger})
+ g := git.New(git.Options{DB: d, Logger: logger, Dir: t.TempDir()})
+ s := sites.New(sites.Options{DB: d, Logger: logger, CertInfo: info})
+
+ k.Register(d)
+ k.Register(g)
+ k.Register(s)
+
+ if err := k.Start(); err != nil {
+ t.Fatalf("kernel start: %v", err)
+ }
+ t.Cleanup(func() { _ = k.Stop() })
+ return s, s.Handler(), d
+}
+
+// certSeed describes the persisted attempt metadata for a seeded domain.
+type certSeed struct {
+ attempts int
+ startedAt *time.Time
+ lastAttemptAt *time.Time
+ retryAfter *time.Time
+ lastError string
+}
+
+func timePtr(t time.Time) *time.Time { return &t }
+
+// seedDomain inserts an already-verified custom domain with explicit cert
+// attempt metadata so status transitions can be exercised without DNS or a
+// real ACME server.
+func seedDomain(t *testing.T, d *db.DB, siteID, domain string, seed certSeed) {
+ t.Helper()
+ now := time.Now().UTC()
+ started := now
+ if seed.startedAt != nil {
+ started = *seed.startedAt
+ }
+ var lastAttempt, retryAfter any
+ if seed.lastAttemptAt != nil {
+ lastAttempt = seed.lastAttemptAt.UTC().Format(time.RFC3339)
+ }
+ if seed.retryAfter != nil {
+ retryAfter = seed.retryAfter.UTC().Format(time.RFC3339)
+ }
+ _, err := d.Exec(
+ `INSERT INTO site_domains
+ (id, site_id, domain, verify_token, verified_at, created_at,
+ cert_attempts, cert_started_at, cert_last_attempt_at, cert_last_error, cert_retry_after)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ "dom-"+domain, siteID, domain, "token", now.Format(time.RFC3339), now.Format(time.RFC3339),
+ seed.attempts, started.Format(time.RFC3339), lastAttempt, seed.lastError, retryAfter)
+ if err != nil {
+ t.Fatalf("seed domain %s: %v", domain, err)
+ }
+}
+
+func listDomain(t *testing.T, h http.Handler, siteID, domain string) map[string]any {
+ t.Helper()
+ req := authedRequest(http.MethodGet, "/api/sites/"+siteID+"/domains", nil)
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("list domains: expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ var resp struct {
+ Data []map[string]any `json:"data"`
+ }
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("decode list: %v", err)
+ }
+ for _, d := range resp.Data {
+ if d["domain"] == domain {
+ return d
+ }
+ }
+ t.Fatalf("domain %q not found in list: %s", domain, w.Body.String())
+ return nil
+}
+
+func TestCertStatusAging(t *testing.T) {
+ now := time.Now().UTC()
+ noCert := func(context.Context, string) (time.Time, bool) { return time.Time{}, false }
+ futureCert := func(context.Context, string) (time.Time, bool) {
+ return now.Add(90 * 24 * time.Hour), true
+ }
+ expiredCert := func(context.Context, string) (time.Time, bool) {
+ return now.Add(-time.Hour), true
+ }
+
+ tests := []struct {
+ name string
+ certInfo sites.CertInfoFunc
+ seed certSeed
+ wantStatus string
+ wantErrContain string
+ }{
+ {
+ name: "fresh provisioning stays provisioning",
+ certInfo: noCert,
+ seed: certSeed{attempts: 1, startedAt: timePtr(now)},
+ wantStatus: "provisioning",
+ },
+ {
+ name: "fails once the attempt budget is exhausted",
+ certInfo: noCert,
+ seed: certSeed{
+ attempts: certMaxAttempts,
+ startedAt: timePtr(now),
+ lastError: "acme: dns-01 challenge failed",
+ },
+ wantStatus: "failed",
+ wantErrContain: "dns-01",
+ },
+ {
+ name: "fails once provisioning is stale",
+ certInfo: noCert,
+ seed: certSeed{
+ attempts: 1,
+ startedAt: timePtr(now.Add(-2 * time.Hour)),
+ },
+ wantStatus: "failed",
+ wantErrContain: "timed out",
+ },
+ {
+ name: "valid certificate is unchanged",
+ certInfo: futureCert,
+ seed: certSeed{},
+ wantStatus: "valid",
+ },
+ {
+ name: "expired certificate is unchanged",
+ certInfo: expiredCert,
+ seed: certSeed{},
+ wantStatus: "expired",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, h, d := setupCertTest(t, tt.certInfo)
+ siteID := createTestSite(t, h, d)
+ domain := "cert-" + strings.ReplaceAll(tt.name, " ", "-") + ".example.com"
+ seedDomain(t, d, siteID, domain, tt.seed)
+
+ got := listDomain(t, h, siteID, domain)
+ if got["cert_status"] != tt.wantStatus {
+ t.Errorf("cert_status = %v, want %s", got["cert_status"], tt.wantStatus)
+ }
+ errText, _ := got["cert_error"].(string)
+ if tt.wantErrContain == "" && errText != "" {
+ t.Errorf("cert_error = %q, want empty", errText)
+ }
+ if tt.wantErrContain != "" && !strings.Contains(errText, tt.wantErrContain) {
+ t.Errorf("cert_error = %q, want substring %q", errText, tt.wantErrContain)
+ }
+ })
+ }
+}
+
+func TestRetryDomainCertResetsAttempts(t *testing.T) {
+ now := time.Now().UTC()
+ noCert := func(context.Context, string) (time.Time, bool) { return time.Time{}, false }
+
+ _, h, d := setupCertTest(t, noCert)
+ siteID := createTestSite(t, h, d)
+ domain := "retry.example.com"
+ seedDomain(t, d, siteID, domain, certSeed{
+ attempts: certMaxAttempts,
+ startedAt: timePtr(now.Add(-2 * time.Hour)),
+ lastAttemptAt: timePtr(now.Add(-2 * time.Hour)),
+ lastError: "acme: rate limited",
+ })
+
+ // Pre-condition: the domain is already failed before the retry.
+ if before := listDomain(t, h, siteID, domain); before["cert_status"] != "failed" {
+ t.Fatalf("precondition: cert_status = %v, want failed", before["cert_status"])
+ }
+
+ req := authedRequest(http.MethodPost,
+ "/api/sites/"+siteID+"/domains/"+domain+"/cert/retry", nil)
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("retry: expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var res struct {
+ Domain string `json:"domain"`
+ CertStatus string `json:"cert_status"`
+ Attempts int `json:"attempts"`
+ }
+ if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
+ t.Fatalf("decode retry result: %v", err)
+ }
+ if res.Domain != domain {
+ t.Errorf("retry domain = %q, want %q", res.Domain, domain)
+ }
+ if res.CertStatus != "provisioning" {
+ t.Errorf("retry cert_status = %q, want provisioning", res.CertStatus)
+ }
+ if res.Attempts != 0 {
+ t.Errorf("retry attempts = %d, want 0", res.Attempts)
+ }
+
+ var attempts int
+ var lastError string
+ if err := d.QueryRow(
+ `SELECT cert_attempts, cert_last_error FROM site_domains WHERE site_id = ? AND domain = ?`,
+ siteID, domain).Scan(&attempts, &lastError); err != nil {
+ t.Fatalf("read attempt metadata: %v", err)
+ }
+ if attempts != 0 {
+ t.Errorf("persisted attempts = %d, want 0", attempts)
+ }
+ if lastError != "" {
+ t.Errorf("persisted last_error = %q, want empty", lastError)
+ }
+
+ after := listDomain(t, h, siteID, domain)
+ if after["cert_status"] != "provisioning" {
+ t.Errorf("post-retry cert_status = %v, want provisioning", after["cert_status"])
+ }
+ if errText, _ := after["cert_error"].(string); errText != "" {
+ t.Errorf("post-retry cert_error = %q, want empty", errText)
+ }
+}
+
+func TestRetryDomainCertRequiresVerifiedDomain(t *testing.T) {
+ noCert := func(context.Context, string) (time.Time, bool) { return time.Time{}, false }
+
+ _, h, d := setupCertTest(t, noCert)
+ siteID := createTestSite(t, h, d)
+
+ body := `{"domain":"unverified.example.com"}`
+ req := authedRequest(http.MethodPost, "/api/sites/"+siteID+"/domains", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, req)
+ if w.Code != http.StatusCreated {
+ t.Fatalf("register: expected 201, got %d: %s", w.Code, w.Body.String())
+ }
+
+ req = authedRequest(http.MethodPost,
+ "/api/sites/"+siteID+"/domains/unverified.example.com/cert/retry", nil)
+ w = httptest.NewRecorder()
+ h.ServeHTTP(w, req)
+ if w.Code != http.StatusConflict {
+ t.Fatalf("retry unverified: expected 409, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestRetryDomainCertUnknownDomain(t *testing.T) {
+ noCert := func(context.Context, string) (time.Time, bool) { return time.Time{}, false }
+
+ _, h, d := setupCertTest(t, noCert)
+ siteID := createTestSite(t, h, d)
+
+ req := authedRequest(http.MethodPost,
+ fmt.Sprintf("/api/sites/%s/domains/missing.example.com/cert/retry", siteID), nil)
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, req)
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("retry unknown: expected 404, got %d: %s", w.Code, w.Body.String())
+ }
+}
diff --git a/components/sites/sites.go b/components/sites/sites.go
index ea63f9de0c..c17b659b86 100644
--- a/components/sites/sites.go
+++ b/components/sites/sites.go
@@ -240,9 +240,15 @@ func (s *Sites) Start(ctx *kernel.Context) error {
if err := s.db.Migrate(sitesMigration); err != nil {
return fmt.Errorf("migrate sites: %w", err)
}
- _ = s.db.Migrate(`ALTER TABLE sites ADD COLUMN auth_policy TEXT NOT NULL DEFAULT '{}'`)
- _ = s.db.Migrate(`ALTER TABLE sites ADD COLUMN org_id INTEGER NOT NULL DEFAULT 0`)
- _ = s.db.Migrate(`ALTER TABLE sites ADD COLUMN project_id TEXT REFERENCES projects(id)`)
+ for _, stmt := range []string{
+ `ALTER TABLE sites ADD COLUMN auth_policy TEXT NOT NULL DEFAULT '{}'`,
+ `ALTER TABLE sites ADD COLUMN org_id INTEGER NOT NULL DEFAULT 0`,
+ `ALTER TABLE sites ADD COLUMN project_id TEXT REFERENCES projects(id)`,
+ } {
+ if err := s.db.Migrate(stmt); err != nil && !isDuplicateColumnError(err) {
+ return fmt.Errorf("migrate sites column: %w", err)
+ }
+ }
_ = s.db.Migrate(`CREATE INDEX IF NOT EXISTS idx_sites_project_id ON sites(project_id)`)
// Reassign orphaned sites (org_id=0 from the DEFAULT) to the admin's
// default_org_id so they become visible under org-scoped queries.
@@ -265,11 +271,26 @@ func (s *Sites) Start(ctx *kernel.Context) error {
return fmt.Errorf("migrate site project attachments: %w", err)
}
}
- _ = s.db.Migrate(`ALTER TABLE sites ADD COLUMN deploy_defaults TEXT NOT NULL DEFAULT '{}'`)
+ if err := s.db.Migrate(`ALTER TABLE sites ADD COLUMN deploy_defaults TEXT NOT NULL DEFAULT '{}'`); err != nil && !isDuplicateColumnError(err) {
+ return fmt.Errorf("migrate sites deploy_defaults: %w", err)
+ }
s.logger.Info("sites component ready")
return nil
}
+// isDuplicateColumnError matches SQLite ("duplicate column name") and
+// PostgreSQL ("column ... already exists") migration noise.
+func isDuplicateColumnError(err error) bool {
+ if err == nil {
+ return false
+ }
+ msg := strings.ToLower(err.Error())
+ if strings.Contains(msg, "duplicate column") {
+ return true
+ }
+ return strings.Contains(msg, "column") && strings.Contains(msg, "already exists")
+}
+
func (s *Sites) Stop(ctx *kernel.Context) error {
return nil
}
@@ -902,13 +923,36 @@ func (s *Sites) siteDeleteTarget(ctx context.Context, id string) (siteDeleteTarg
return target, err
}
+// activeDeploymentStatuses mirrors deploy.StateMachine.IsActive (#304). The
+// sites component deliberately does not import components/deploy — they are
+// wired together by the composition root — so this allow-list is the one
+// intentional duplication; TestActiveDeploymentStatusesMatchDeployPredicate
+// fails if it drifts.
+var activeDeploymentStatuses = []string{"pending", "building", "deploying", "draining", "running"}
+
+// IsActiveDeploymentStatus reports whether a deployment in this status should
+// block deleting its site.
+func IsActiveDeploymentStatus(status string) bool {
+ for _, s := range activeDeploymentStatuses {
+ if status == s {
+ return true
+ }
+ }
+ return false
+}
+
func (s *Sites) hasActiveDeployment(ctx context.Context, target siteDeleteTarget) (bool, error) {
- var count int
- err := s.db.QueryRowContext(ctx,
- `SELECT COUNT(*) FROM deployments
+ placeholders := strings.TrimSuffix(strings.Repeat("?,", len(activeDeploymentStatuses)), ",")
+ query := `SELECT COUNT(*) FROM deployments
WHERE (site_id = ? OR (site_id = '' AND repo_id = ?))
- AND status IN ('pending', 'building', 'running')`,
- target.ID, target.GitRepoID).Scan(&count)
+ AND status IN (` + placeholders + `)`
+ args := make([]any, 0, 2+len(activeDeploymentStatuses))
+ args = append(args, target.ID, target.GitRepoID)
+ for _, status := range activeDeploymentStatuses {
+ args = append(args, status)
+ }
+ var count int
+ err := s.db.QueryRowContext(ctx, query, args...).Scan(&count)
if isMissingOptionalTable(err) {
return false, nil
}
diff --git a/components/sites/sites_migration_test.go b/components/sites/sites_migration_test.go
index 8c18968a28..ddd4996f00 100644
--- a/components/sites/sites_migration_test.go
+++ b/components/sites/sites_migration_test.go
@@ -63,3 +63,32 @@ func TestSitesMigrationConstExtracted(t *testing.T) {
t.Errorf("Start() does not reference the sitesMigration const; expected s.db.Migrate(sitesMigration)")
}
}
+
+func TestIsDuplicateColumnError(t *testing.T) {
+ cases := []struct {
+ msg string
+ want bool
+ }{
+ {"", false},
+ {"duplicate column name: cert_attempts", true},
+ {"column cert_attempts already exists", true},
+ {"pq: column \"cert_attempts\" of relation \"site_domains\" already exists", true},
+ {"syntax error at or near 'ALTER'", false},
+ {"connection refused", false},
+ }
+ for _, tc := range cases {
+ var err error
+ if tc.msg != "" {
+ err = &testMigrationErr{msg: tc.msg}
+ }
+ if got := isDuplicateColumnError(err); got != tc.want {
+ t.Errorf("isDuplicateColumnError(%q) = %v, want %v", tc.msg, got, tc.want)
+ }
+ }
+}
+
+type testMigrationErr struct {
+ msg string
+}
+
+func (e *testMigrationErr) Error() string { return e.msg }
diff --git a/components/sites/sites_test.go b/components/sites/sites_test.go
index 489451f1b3..09251b57f7 100644
--- a/components/sites/sites_test.go
+++ b/components/sites/sites_test.go
@@ -386,7 +386,7 @@ func TestDeleteSiteNotFound(t *testing.T) {
}
func TestDeleteSiteActiveDeploymentConflict(t *testing.T) {
- for _, status := range []string{"pending", "building", "running"} {
+ for _, status := range []string{"pending", "building", "deploying", "draining", "running"} {
t.Run(status, func(t *testing.T) {
d, h := setupSitesDeleteTest(t)
createSiteDeleteSupportTables(t, d)
@@ -409,6 +409,43 @@ func TestDeleteSiteActiveDeploymentConflict(t *testing.T) {
}
}
+func TestDeleteSiteInactiveDeploymentAllowed(t *testing.T) {
+ for _, status := range []string{"stopped", "failed", "rolled_back"} {
+ t.Run(status, func(t *testing.T) {
+ d, h := setupSitesDeleteTest(t)
+ createSiteDeleteSupportTables(t, d)
+ seedSiteForDelete(t, d, "site-"+status, "repo-"+status)
+ _, _ = d.ExecContext(context.Background(),
+ `INSERT INTO deployments (id, site_id, repo_id, status) VALUES (?, ?, ?, ?)`,
+ "dep-"+status, "site-"+status, "repo-"+status, status)
+
+ req := authedRequestSite(http.MethodDelete, "/api/sites/site-"+status, nil)
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, req)
+
+ if w.Code != http.StatusNoContent {
+ t.Fatalf("expected 204 for inactive %s, got %d: %s", status, w.Code, w.Body.String())
+ }
+ })
+ }
+}
+
+// TestActiveDeploymentStatusesMatchDeployPredicate guards the deliberate
+// duplication in sites (no components/deploy import) against drift from the
+// authoritative deploy.StateMachine.IsActive predicate (#304).
+func TestActiveDeploymentStatusesMatchDeployPredicate(t *testing.T) {
+ sm := deploy.NewStateMachine()
+ for _, candidate := range []string{
+ "pending", "building", "deploying", "draining", "running",
+ "stopped", "failed", "rolled_back",
+ } {
+ if sites.IsActiveDeploymentStatus(candidate) != sm.IsActive(candidate) {
+ t.Errorf("IsActiveDeploymentStatus(%q) = %v, deploy.IsActive = %v",
+ candidate, sites.IsActiveDeploymentStatus(candidate), sm.IsActive(candidate))
+ }
+ }
+}
+
func TestDeleteSiteCascade(t *testing.T) {
t.Run("canonical site id", func(t *testing.T) {
d, h := setupSitesDeleteTest(t)
diff --git a/package.json b/package.json
index b3af3f69cc..06ee8bf8f1 100644
--- a/package.json
+++ b/package.json
@@ -1,8 +1,8 @@
{
"scripts": {
"preflight": "npm run preflight:go && npm run preflight:build && npm run preflight:codeql && npm run preflight:secrets",
- "preflight:go": "go vet ./... && go test -timeout 600s ./... && go run github.com/securego/gosec/v2/cmd/gosec@v2.28.0 -conf .gosec.json -quiet -exclude-dir=ui/node_modules -exclude=G104,G115,G404,G702,G703,G704,G112,G114,G118,G120,G124,G204,G301,G304,G306,G705,G710 ./...",
- "preflight:secscan": "go vet ./... && go run github.com/securego/gosec/v2/cmd/gosec@v2.28.0 -conf .gosec.json -quiet -exclude-dir=ui/node_modules -exclude=G104,G115,G404,G702,G703,G704,G112,G114,G118,G120,G124,G204,G301,G304,G306,G705,G710 ./...",
+ "preflight:go": "go vet ./... && go test -timeout 600s ./... && go run github.com/securego/gosec/v2/cmd/gosec@v2.28.0 -conf .gosec.json -quiet -exclude-dir=ui/node_modules ./...",
+ "preflight:secscan": "go vet ./... && go run github.com/securego/gosec/v2/cmd/gosec@v2.28.0 -conf .gosec.json -quiet -exclude-dir=ui/node_modules ./...",
"preflight:build": "go build -o /dev/null .",
"preflight:codeql": "./scripts/codeql-scan.sh",
"preflight:secrets": "gitleaks detect --source . --no-git --no-banner 2>&1 && echo 'secrets: OK' || echo 'gitleaks: SKIP (not installed)'",
diff --git a/scripts/check-version-drift.sh b/scripts/check-version-drift.sh
index c3d3d095f5..fe9f295a17 100755
--- a/scripts/check-version-drift.sh
+++ b/scripts/check-version-drift.sh
@@ -102,6 +102,16 @@ for f in README.md CONTRIBUTING.md CLAUDE.md GEMINI.md AGENTS.md; do
fi
done
+# ── README version badge must be dynamic ──────────────────────────────────────
+# The release bot bumps VERSION/CHANGELOG, not README. A literal
+# `badge/version-X.Y.Z` silently drifts (it read 2.89.0 while VERSION was
+# 2.92.2). Require the dynamic GitHub release badge instead.
+if [[ -f README.md ]] && grep -qE 'badge/version-[0-9]+\.[0-9]+\.[0-9]+' README.md; then
+ fail "README.md hardcodes a version badge (use shields github/v/release)"
+else
+ ok "README.md version badge is dynamic"
+fi
+
# ── Result ────────────────────────────────────────────────────────────────────
echo ""
if [[ "$FAILED" == "true" ]]; then
diff --git a/specs/MCP_CONFIG_INVENTORY.md b/specs/MCP_CONFIG_INVENTORY.md
index 82a7e4c9bd..20804d00f6 100644
--- a/specs/MCP_CONFIG_INVENTORY.md
+++ b/specs/MCP_CONFIG_INVENTORY.md
@@ -4,6 +4,14 @@
**Root:** repository-relative paths only
**Status:** credentials use `${CONTEXT7_API_KEY}`; local MCP packages are pinned
+> **Canonical source:** `.mcp.json` is the single source of truth for the server
+> list. `opencode.jsonc` is generated from it by `scripts/gen-mcp-configs.sh`
+> (CI fails on drift via `--check`); `.claude/mcp.json` and `.cursor/mcp.json`
+> are local-only generated copies (gitignored). When adding or removing a
+> server, update `.mcp.json` first, run the generator, then mirror the change
+> into the remaining tracked adapters (`.agents/`, `.vscode/`, `.pi/`,
+> `opencode.json`). Never add `@latest` — pin every npx-spawned package.
+
---
## Summary
diff --git a/specs/audit/2026-11-hotpaths-state-machines.md b/specs/audit/2026-11-hotpaths-state-machines.md
new file mode 100644
index 0000000000..1cc9423ee7
--- /dev/null
+++ b/specs/audit/2026-11-hotpaths-state-machines.md
@@ -0,0 +1,212 @@
+# Auditoria — Hot paths, async sequencial e máquinas de estado
+
+Data: 2026-11-03
+Branch: `audit/hotpaths-state-machines`
+Escopo: Go (kernel + components) e UI React (`ui/src`, `packages/`, `agentic-cps/`).
+
+## 1. Método
+
+1. Varredura de todos os call sites de I/O (`fetch`, `http.Client.Do`, `QueryContext`) à
+ procura de awaits/loops sequenciais que pudessem ser concorrentes.
+2. Extração de todos os `CREATE TABLE` com colunas `status`/`state`/`cert_status` e de
+ todos os literais de estado gravados no código (fonte da verdade real).
+3. Comparação entre as máquinas de estado implícitas e as explícitas, incluindo specs
+ (`specs/epics/e45-drain.yaml`) e comentários de código.
+4. Testes RED→GREEN para cada correção; suíte completa executada no final.
+
+## 2. Hot paths — async sequencial
+
+### 2.1 Corrigido
+
+| Local | Antes | Depois | Impacto |
+|---|---|---|---|
+| `ui/src/pages/SiteDetailPage.tsx` (`load`) | `await getSite` → `await getSiteDeployments` → `await getRollbackEvents` | `Promise.all([...])` | 3 RTT → 1 RTT. É hot path: esse `load` roda a cada 2s enquanto há deploy ativo. |
+| `ui/src/pages/CreateSitePage.tsx` (`init`) | `await getGitHubStatus` → `await getGitHubRepos` → `await getGitRepos` | `Promise.all([...])` | 3 RTT → 1 RTT no bootstrap da página. |
+
+Testes de concorrência adicionados (provam que os três começam antes de qualquer um
+resolver, com fail-safe de 100 ms): `SiteDetailPage.test.tsx` e
+`CreateSitePage.test.tsx`.
+
+### 2.2 Já estava correto
+
+- `ui/src/pages/DashboardPage.tsx`, `CiciPage.tsx`, `ForgePage.tsx` já usam `Promise.all`.
+- `fetch(...).then(r => r.json())`: sequencial por necessidade (o body depende da resposta).
+
+### 2.3 Avaliado e mantido sequencial (intencional)
+
+| Local | Motivo |
+|---|---|
+| `ui/src/components/SiteEnvVarsTab.tsx` (import em massa) | Precisa do resultado por chave para agregar erros; SQLite tem writer único, paralelizar só enfileira no servidor. Se o volume crescer, criar endpoint bulk no servidor e usar concorrência limitada no cliente. |
+| `ui/src/lib/secretsData.ts` (`importEnvSecrets`) | Mesmo motivo; o resultado reporta `failedKeys` por chave. |
+| `components/deploy/health.go` | Retry com backoff: sequencial por definição. |
+| `components/internal/llm/llm.go` | Retry com backoff. |
+| `components/deploy/deploy_runner.go` | Start de background processes: ordem importa. |
+| `kernel/eventbus` | Hooks são despachados por prioridade: a ordem é contrato. |
+
+### 2.4 Oportunidade adicional (não corrigida)
+
+`getSiteDeployments(siteId)` (`ui/src/lib/sitesData.ts`) chama `getSite(siteId)`
+internamente só para obter `git_repo_id`. Em `SiteDetailPage`, `getSite` é chamado duas
+vezes por `load`. Passar `repoId` como parâmetro elimina uma requisição por ciclo de
+polling (a cada 2s durante deploys).
+
+### 2.5 Go — N+1 / fan-out
+
+Nenhum loop com `QueryContext`/`QueryRowContext`/`http.Do` foi encontrado em código de
+produção fora de retries intencionais. Não há fan-out sequencial a corrigir.
+
+## 3. Máquinas de estado implícitas (inventário)
+
+| # | Máquina | Fonte da verdade | Estados | Terminal | Onde é decidida |
+|---|---|---|---|---|---|
+| 1 | **Deployments** | `components/deploy/state_machine.go` + tabela `deployments` | `pending → building → deploying → running → draining → stopped → running`; `failed`, `rolled_back` | `failed`, `rolled_back` | Explícita (`StateMachine.CanTransition`), mas com escapes (ver §5) |
+| 2 | **CICI runs** | `components/cici/runs.go` + `cici_runs` | `pending` (default nunca usado), `running`, `success`, `failure` | `success`, `failure` | Implícita: strings literais em `triggerRun`/`finishRun`; sem validação de transição |
+| 3 | **Secret key rotation** | `components/secrets/schema.go` (`CHECK`) + `rotation.go` | `active`, `rotating`, `retired` | `retired` | Semi-explícita (CHECK no banco), transições em Go |
+| 4 | **Legacy migration checkpoint** | `components/backup/legacy_migration.go` | `in_progress`, `done` | `done` | Implícita (`markCheckpoint`) |
+| 5 | **Monitoring incidents** | `components/monitoring/observability.go` | aberto (`resolved_at IS NULL`) / resolvido (`resolved_at` preenchido) | resolvido | Implícita; `status=open` é filtro de query |
+| 6 | **Org invites** | `components/auth/membership.go` (`org_invites`) | pendente (`accepted_at IS NULL`) / aceito / expirado | aceito/expirado (por `expires_at`) | Implícita |
+| 7 | **Password reset** | `components/auth/passwordreset.go` | não usado / usado / expirado | usado/expirado | Implícita (`used`, `expires_at`) |
+| 8 | **Refresh tokens** | `components/auth/refreshtoken.go` | ativo / usado / expirado / família revogada (reuse detection) | usado/expirado/revogado | Implícita |
+| 9 | **API/site keys** | `components/auth/apikeys.go` | ativo / revogado | revogado | Implícita (`revoked` 0/1) |
+| 10 | **Email verify / magic link / OTP** | `components/auth/{emailverify,magiclink,otp,phone}.go` | pendente / verificado / expirado / bloqueado por tentativas | verificado/expirado | Implícita (token + `expires_at`/`attempts`; magic link/OTP em memória) |
+| 11 | **Domain TLS cert** | `components/sites/domains.go` | `provisioning`, `valid`, `expired` | `valid`/`expired` | Derivada do cache de certificado |
+| 12 | **Forge issues** | `components/forge/forge.go` | `open`, `closed` | `closed` (reabrível) | Implícita |
+| 13 | **Messages** | `components/messaging/messaging.go` | `sent`, `failed` | ambos | Implícita |
+| 14 | **Function executions** | `components/functions/functions.go` | `success`, `error` | ambos | Implícita |
+| 15 | **UI deployment polling** | `ui/src/pages/SiteDetailPage.tsx`, `CreateSitePage.tsx` | `pending`, `building`, `deploying` = ativo; demais = parado | n/a | Implícita (condição de `setInterval`) |
+| 16 | **UI create-site wizard** | `ui/src/pages/CreateSitePage.tsx` | `source → configure → review → deploy` | `deploy` (sucesso/erro) | `useState`, sem máquina explícita |
+| 17 | **MCP service status** | `components/mcp/knowledge.go` | lido do registry (`running`/`stopped`) | n/a | Somente leitura |
+
+## 4. Estados não-terminais (risco de "preso para sempre")
+
+| Máquina | Estado não-terminal | Persistido? | Timeout em memória | Reconciliação no boot | Risco |
+|---|---|---|---|---|---|
+| Deployments | `pending`, `building`, `deploying` | Sim | `runDeployment` tem `context.WithTimeout(5m)` | **Não** — `loadResumeCandidates` só busca `running AND port > 0` | **ALTO**: restart/crash no meio do deploy deixa o registro preso; alguns caminhos negam delete (`409`) e a UI polla para sempre |
+| Deployments | `running` com `port = 0` | Sim | — | **Não** (filtro `port > 0`) | **ALTO**: nunca é retomado nem marcado `failed` |
+| Deployments | `draining` | Sim | `DrainTimeout` (30s) no goroutine | **Não** | MÉDIO: se o processo morrer durante o drain, fica `draining` |
+| Deployments | `stopped` | Sim | — | n/a | BAIXO: semi-terminal reversível (`stopped → running`) |
+| CICI runs | `running` | Sim | `context.WithTimeout(10m)` | **Não** | **ALTO**: crash do processo deixa `running` para sempre; `finishRun` engole erro de escrita |
+| CICI runs | `pending` | Sim (default) | — | **Não** | BAIXO: estado órfão, nunca usado (o insert grava `running`) |
+| Secret rotation | `rotating` | Sim | — | Retomável: `BeginKeyRotation` usa `ON CONFLICT DO NOTHING` + reload | MÉDIO: exige reexecução manual da rotação, mas não perde decryptabilidade |
+| Legacy checkpoint | `in_progress` | Sim | — | Reexecução idempotente (checkpoint != done re-roda) | BAIXO |
+| Incidents | aberto | Sim | — | Loop de avaliação/resolução | MÉDIO (por design): pode ficar aberto indefinidamente |
+| Domain cert | `provisioning` | Derivado | — | Depende do cache externo | MÉDIO: sem timeout explícito |
+| Invites / reset / refresh / verify | pendente | Sim | `expires_at` | Validação por expiração | BAIXO: limitado no tempo |
+
+### Resumo dos não-terminais
+
+- **Deployments**: `pending`, `building`, `deploying`, `running`, `draining`, `stopped`
+ (apenas `failed` e `rolled_back` são terminais de fato).
+- **CICI runs**: `running` (e o órfão `pending`).
+- **Secrets**: `rotating` (recuperável).
+- **Backup**: `in_progress` (recuperável).
+- **Monitoring**: incidente aberto (por design).
+- **Domains**: `provisioning`.
+- **Tokens/sessões**: pendentes, mas com `expires_at`.
+
+## 5. Conflitos encontrados
+
+### 5.1 Corrigidos
+
+1. **`deploying` ausente nos guards de "deploy ativo"**
+ - `components/sites/sites.go` (`hasActiveDeployment`): `IN ('pending','building','running')`
+ permitia deletar um site enquanto o deploy estava em `deploying`.
+ - `components/deploy/gateway.go` (delete de deployment): `status == "pending" || "building"`
+ permitia deletar em `deploying`.
+ - Ambos corrigidos + testes de regressão (`409` para `deploying`).
+
+### 5.2 Encontrados e documentados (não corrigidos nesta varredura)
+
+2. **Spec vs. código (e45)**: `specs/epics/e45-drain.yaml` declara `stopped` terminal
+ ("no outgoing transitions"), mas `state_machine.go` permite `stopped → running` porque
+ `rollback.go` restaura um deployment parado para `running`. Um dos dois está errado.
+3. **Comentário vs. código**: `state_machine.go` diz que `running` é o estado terminal, mas
+ `running` transiciona para `failed`, `rolled_back` e `draining`. Em
+ `state_transitions.go`, `running` e `failed` são tratados como "terminal" apenas para
+ gravar `build_log` atomicamente — `draining`/`stopped`/`rolled_back` ficam de fora.
+4. **Estado órfão `"replaced"`**: `gateway.go` grava `"replaced"` via `stopDeployment`, mas
+ `"replaced"` não existe em `DeploymentState`. O rollback depende do modo leniente
+ ("unknown state") para sair de `replaced`. Isso fura a máquina de estados.
+5. **Rollback escolhe alvo em andamento**: a query de "previous successful deployment"
+ exclui `failed`, `pending`, `building`, mas **não** `deploying` nem `draining`. Em
+ zero-downtime, o deployment mais novo pode estar `deploying` e ser escolhido como alvo.
+6. **TOCTOU em `TransitionState`** (`state_transitions.go`): o `SELECT status` + validação
+ acontece **antes** de `d.mu.Lock()`; o `UPDATE` não tem `WHERE status = ?`. Duas
+ transições concorrentes podem validar contra o mesmo estado e ambas gravarem.
+7. **`stopDeployment` burla a máquina**: faz `UPDATE deployments SET status = ?` direto,
+ sem validação, sem `status_history` e sem evento `deploy.state_changed`.
+8. **CICI sem máquina**: `cici_runs` aceita qualquer status; o default `pending` nunca é
+ usado; não há validação de transição nem deadline persistido.
+
+## 6. Recomendações priorizadas
+
+**P0**
+- Reconciliação no boot para `pending`/`building`/`deploying` (e `running` com `port = 0`)
+ mais antigos que N minutos → `failed` com `error_message`.
+- Reconciliação no boot para `cici_runs.status = 'running'` órfãos.
+- Tornar `TransitionState` atômico (`UPDATE ... WHERE id = ? AND status = ?`, checar
+ rows-affected) ou manter o lock durante SELECT + validação + UPDATE.
+- Declarar `replaced` (ou removê-lo) e alinhar e45 (`stopped` terminal vs. reversível).
+
+**P1**
+- Exportar `IsActive`/`IsTerminal` da máquina de deploy e reutilizar em sites, gateway e
+ rollback (hoje cada um repete a lista literal e divergiu).
+- Criar máquina explícita para CICI (`queued/running/success/failure/cancelled`) com
+ validação e `deadline_at` persistido.
+- Timeout/refresh para `cert_status = provisioning`.
+
+**P2**
+- Remover a chamada duplicada de `getSite` dentro de `getSiteDeployments`.
+- Concorrência limitada nos imports em massa, se/quando existir endpoint bulk.
+- Métrica/alerta para "estado não-terminal há mais de X minutos" por máquina.
+
+## 7. Evidência
+
+```
+RED (antes da correção):
+ sites: TestDeleteSiteActiveDeploymentConflict/deploying → expected 409, got 204
+ deploy: TestDeleteDeployment/409_on_deploying_deployment → expected 409, got 204
+ UI: loads ... concurrently → expected 1 (sequential) to be 3
+
+GREEN (depois):
+ go test ./components/sites/ ./components/deploy/ ./components/cici/ -count=1
+ → 476 passed in 3 packages
+ vitest run
+ → 94 files / 740 tests passed
+ go build ./... → exit 0
+ go vet (sites, deploy) → no issues
+ tsc -b --pretty false → exit 0
+ eslint (arquivos tocados) → exit 0
+```
+
+## 8. Arquivos alterados
+
+- `ui/src/pages/SiteDetailPage.tsx` — `load` concorrente.
+- `ui/src/pages/SiteDetailPage.test.tsx` — teste de concorrência + mock de `getSite`.
+- `ui/src/pages/CreateSitePage.tsx` — `init` concorrente (removeu `loadGitHub`).
+- `ui/src/pages/CreateSitePage.test.tsx` — teste de concorrência.
+- `components/sites/sites.go` — `deploying` no guard de site ativo.
+- `components/sites/sites_test.go` — caso `deploying`.
+- `components/deploy/gateway.go` — `deploying` no guard de delete.
+- `components/deploy/deploy_test.go` — caso `deploying`.
+
+## 9. Rastreamento no GitHub
+
+Itens acionáveis abertos como issues (uma por item):
+
+| Issue | Item | Épico vinculado |
+|---|---|---|
+| #300 | Reconciliação de deployments em voo no boot | e45 |
+| #301 | Reconciliação de CICI runs órfãos no boot | e09 |
+| #302 | Transições atômicas (TOCTOU) | e45 |
+| #303 | Remover estado `replaced` e alinhar e45 | e45 |
+| #304 | Centralizar predicados + filtro de rollback | e45 |
+| #305 | Máquina de estados explícita do CICI | e09 |
+| #306 | Timeout/refresh para `cert_status=provisioning` | e46 |
+| #307 | Remover `getSite` duplicado | e15 |
+| #308 | Alerta para estado não-terminal antigo | e24 |
+| #309 | Concorrência limitada / bulk import | e41 |
+
+Os épicos receberam um campo `follow_ups:` com os números correspondentes.
+As duas correções já aplicadas (`deploying` ausente nos guards) não geraram issue.
+
diff --git a/specs/codebase-wiki/traceability.md b/specs/codebase-wiki/traceability.md
index 99bbc0f1d3..598601c303 100644
--- a/specs/codebase-wiki/traceability.md
+++ b/specs/codebase-wiki/traceability.md
@@ -1,6 +1,6 @@
# Traceability Matrix
-Generated: 2026-08-12T00:24:37.616472+00:00
+Generated: 2026-11-04T19:50:28.289069+00:00
## Story Coverage
@@ -126,12 +126,12 @@ Generated: 2026-08-12T00:24:37.616472+00:00
| e88s02 | Formal conformance exceptions (1.4.8 partial + 2.2.3 security) | 0 | 0 | Dark |
| e88s03 | AAA re-certification — matrix + axe + conformance statement | 0 | 0 | Dark |
| e89s01 | Harden existing site-secret storage and delivery | 0 | 0 | Dark |
-| e89s02 | Add organization-scoped Projects and Environments | 0 | 0 | Dark |
+| e89s02 | Add organization-scoped Projects and Environments | 1 | 1 | Covered |
| e89s03 | Add versioned envelope-encrypted project secrets | 0 | 0 | Dark |
| e89s04 | Add secret REST API, policies, and audit events | 0 | 0 | Dark |
-| e89s05 | Add Admin UI for project secrets | 0 | 0 | Dark |
+| e89s05 | Add Admin UI for project secrets | 1 | 1 | Covered |
| e89s06 | Resolve project and site secrets in deployments | 0 | 0 | Dark |
-| e89s07 | Secure MCP secret-management tools | 0 | 0 | Dark |
+| e89s07 | Secure MCP secret-management tools | 2 | 1 | Covered |
## Gaps
@@ -239,13 +239,10 @@ Generated: 2026-08-12T00:24:37.616472+00:00
- e88s02: no story-tagged implementation files
- e88s03: no story-tagged implementation files
- e89s01: no story-tagged implementation files
-- e89s02: no story-tagged implementation files
- e89s03: no story-tagged implementation files
- e89s04: no story-tagged implementation files
-- e89s05: no story-tagged implementation files
- e89s06: no story-tagged implementation files
-- e89s07: no story-tagged implementation files
## Summary
-Stories: 16 covered / 110 dark / 126 total.
+Stories: 19 covered / 107 dark / 126 total.
diff --git a/specs/epics/e09-cici.yaml b/specs/epics/e09-cici.yaml
index 0f578c2ccb..ba01fd05d0 100644
--- a/specs/epics/e09-cici.yaml
+++ b/specs/epics/e09-cici.yaml
@@ -5,6 +5,9 @@ title: "CI/CD — Pipelines & Workflows"
wsjf: 1.0
wsjf_rationale: "Historical — foundation epic. BV=8(CI automation) TC=5 RR=5 / JS=8"
status: done
+follow_ups:
+ - 301 # reconcile orphaned running runs on boot
+ - 305 # explicit CICI state machine + persisted deadline
migrated_from: specs/archive/slices/009-cici.md
stories:
- id: e09s01
diff --git a/specs/epics/e15-sites.yaml b/specs/epics/e15-sites.yaml
index 6a41ef6d17..cf1f5d9b3b 100644
--- a/specs/epics/e15-sites.yaml
+++ b/specs/epics/e15-sites.yaml
@@ -5,6 +5,8 @@ title: "Sites — Deploy from GitHub Journey"
wsjf: 1.0
wsjf_rationale: "Historical — foundation epic. BV=8(GitHub integration) TC=8 RR=8 / JS=8"
status: done
+follow_ups:
+ - 307 # avoid duplicate getSite request in getSiteDeployments
migrated_from: specs/archive/slices/015-deploy-github-journey.md
stories:
- id: e15s01
diff --git a/specs/epics/e24-wire-observability.yaml b/specs/epics/e24-wire-observability.yaml
index b29c58a948..c3fc901678 100644
--- a/specs/epics/e24-wire-observability.yaml
+++ b/specs/epics/e24-wire-observability.yaml
@@ -4,6 +4,8 @@ id: e24
title: Wire Observability
wsjf: 5.0
status: done
+follow_ups:
+ - 308 # alert on non-terminal states older than X minutes
covers: []
stories:
- id: e24s01
diff --git a/specs/epics/e41-env-vars-ui.yaml b/specs/epics/e41-env-vars-ui.yaml
index ad63254475..2f7ee2a63f 100644
--- a/specs/epics/e41-env-vars-ui.yaml
+++ b/specs/epics/e41-env-vars-ui.yaml
@@ -5,6 +5,8 @@ title: "Deploy — Environment Variables UI"
wsjf: 4.0
wsjf_rationale: "BV=6(moderate: stops SSH-based env var management — daily quality-of-life win for solo dev) TC=3(no urgent deadline) RR=6(reduces friction: env vars in UI vs CLI flags) / JS=2(~1.5 days across 2 stories)"
status: done
+follow_ups:
+ - 309 # bounded concurrency or bulk endpoint for env/secret imports
tier: 2
depends_on: []
covers:
diff --git a/specs/epics/e45-drain.yaml b/specs/epics/e45-drain.yaml
index 6abe27b6b1..3aa296cd39 100644
--- a/specs/epics/e45-drain.yaml
+++ b/specs/epics/e45-drain.yaml
@@ -5,6 +5,11 @@ title: "Deploy — Zero-Downtime Drain"
wsjf: 3.5
wsjf_rationale: "BV=6(moderate: keeps live traffic flowing during deploys — essential for production, less critical for solo dev instance) TC=3(no deadline, but becomes urgent when users complain about brief outages) RR=5(reduces deploy anxiety: safe to deploy during business hours) / JS=4(~3 days across 2 stories)"
status: done
+follow_ups:
+ - 300 # reconcile in-flight deployments on boot
+ - 302 # atomic status transitions (TOCTOU)
+ - 303 # remove undeclared "replaced" state, align e45 spec
+ - 304 # centralize state predicates, fix rollback target filter
tier: 4
depends_on: []
covers:
diff --git a/specs/epics/e46-custom-domains.yaml b/specs/epics/e46-custom-domains.yaml
index c1edcc2a0d..f418ed1b76 100644
--- a/specs/epics/e46-custom-domains.yaml
+++ b/specs/epics/e46-custom-domains.yaml
@@ -4,7 +4,9 @@ id: e46
title: "Infra — Custom Domains & Auto SSL"
wsjf: 4.5
wsjf_rationale: "BV=8(high: production apps need myapp.com not myapp.bigbase.click — this is table stakes for a BaaS) TC=5(blocks making BigBase a primary hosting platform) RR=5(auto Let's Encrypt removes manual SSL management) / JS=4(~3 days across 3 stories)"
-status: in_progress
+status: done
+follow_ups:
+ - 306 # cert_status=provisioning has no timeout/refresh path
tier: 5
depends_on: []
covers:
diff --git a/specs/epics/e81-multi-language-deploy.yaml b/specs/epics/e81-multi-language-deploy.yaml
index a2e0b06b4f..2807194a9e 100644
--- a/specs/epics/e81-multi-language-deploy.yaml
+++ b/specs/epics/e81-multi-language-deploy.yaml
@@ -31,7 +31,7 @@ stories:
- id: e81s01
title: "Rust runtime support"
depends_on: [e81s00]
- status: open
+ status: done
tasks:
- desc: "Detect Cargo.toml in repo root"
verify: "go test ./components/deploy/ -run TestDetectRust -v"
@@ -45,7 +45,7 @@ stories:
- id: e81s02
title: "PHP runtime support"
depends_on: [e81s00]
- status: open
+ status: done
tasks:
- desc: "Detect composer.json in repo root"
verify: "go test ./components/deploy/ -run TestDetectPHP -v"
@@ -59,7 +59,7 @@ stories:
- id: e81s03
title: "Java runtime support"
depends_on: [e81s00]
- status: open
+ status: done
tasks:
- desc: "Detect pom.xml or build.gradle in repo root"
verify: "go test ./components/deploy/ -run TestDetectJava -v"
@@ -73,7 +73,7 @@ stories:
- id: e81s04
title: "Ruby runtime support"
depends_on: [e81s00]
- status: open
+ status: done
tasks:
- desc: "Detect Gemfile in repo root"
verify: "go test ./components/deploy/ -run TestDetectRuby -v"
@@ -87,7 +87,7 @@ stories:
- id: e81s05
title: "Deploy profiles & bigbase.toml for new runtimes"
depends_on: [e81s01, e81s02, e81s03, e81s04]
- status: open
+ status: done
tasks:
- desc: "Add rust/php/java/ruby to AllAppTypes()"
verify: "go test ./components/deploy/ -run TestAllAppTypes -v"
@@ -99,7 +99,7 @@ stories:
- id: e81s06
title: "Security regression tests — bug registry gaps"
depends_on: []
- status: open
+ status: done
note: "6 regression test gaps from bug registry (2026-07-12 audit). Code guards exist but no tests to prevent reintroduction."
tasks:
- desc: "Path traversal regression test — verify filepath.Clean + containment in engine.go, gateway.go, git.go reject ../ patterns"
diff --git a/specs/epics/e83-mcp-env-var-tools.yaml b/specs/epics/e83-mcp-env-var-tools.yaml
index 99547d472f..973e8be699 100644
--- a/specs/epics/e83-mcp-env-var-tools.yaml
+++ b/specs/epics/e83-mcp-env-var-tools.yaml
@@ -4,7 +4,7 @@ id: e83
title: "MCP Tools — Site-Scoped Environment Variables"
wsjf: 7.0
wsjf_rationale: "BV=8(blocks CI/CD automation for env var management via MCP) TC=3(no urgent deadline) RR=7(unblocks fully automated deploy pipelines for encrypted-health-data apps) / JS=2(~1 day across 1 story)"
-status: open
+status: done
tier: 1
depends_on: [e41, e38]
covers:
@@ -15,7 +15,7 @@ covers:
stories:
- id: e83s01
title: "MCP tools for site-scoped env var CRUD"
- status: open
+ status: done
bcps: 3
tasks:
- desc: "Add SiteEnvVarManager interface and adapter"
diff --git a/specs/epics/e84-per-site-csp/epic.yaml b/specs/epics/e84-per-site-csp/epic.yaml
index d49d0f02bb..8e7c31b674 100644
--- a/specs/epics/e84-per-site-csp/epic.yaml
+++ b/specs/epics/e84-per-site-csp/epic.yaml
@@ -4,7 +4,7 @@ id: e84
title: "Per-Site CSP Configuration (Issue #197)"
wsjf: 6.0
total_bcps: 8
-status: open
+status: done
tier: 2
depends_on: []
note: "3 stories, 8 BCPs. Makes CSP configurable per deployed site via deploy_defaults and bigbase.toml manifest."
@@ -12,18 +12,18 @@ stories:
- id: e84s01
title: "Site model + API layer — add csp_policy to DeployDefaults"
bcps: 3
- status: open
+ status: done
depends_on: []
spec: e84s01-spec.md
- id: e84s02
title: "Proxy per-site CSP override at request time"
bcps: 3
- status: open
+ status: done
depends_on: [e84s01]
spec: e84s02-spec.md
- id: e84s03
title: "Manifest security.csp field + deploy-time propagation"
bcps: 2
- status: open
+ status: done
depends_on: [e84s02]
spec: e84s03-spec.md
diff --git a/specs/epics/e90-dev-environment-hardening.yaml b/specs/epics/e90-dev-environment-hardening.yaml
index 04a3e2c545..0146e7485a 100644
--- a/specs/epics/e90-dev-environment-hardening.yaml
+++ b/specs/epics/e90-dev-environment-hardening.yaml
@@ -76,7 +76,7 @@ stories:
- id: e90s02
title: "Fix .gitignore conflict marker and consolidate gosec config"
- status: open
+ status: done
bcps: 1
priority: P1
problem: >
@@ -104,7 +104,7 @@ stories:
- id: e90s03
title: "Reconcile AGENTS.md ctxo mandate with 2026-08-11 revert reality"
- status: open
+ status: done
bcps: 2
priority: P1
problem: >
@@ -158,7 +158,7 @@ stories:
- id: e90s05
title: "Canonical MCP config — single source, generated adapters"
- status: open
+ status: done
bcps: 3
priority: P1
problem: >
@@ -191,7 +191,7 @@ stories:
- id: e90s06
title: "Consolidate agent instruction files — AGENTS.md as single source"
- status: open
+ status: done
bcps: 2
priority: P1
problem: >
@@ -244,7 +244,7 @@ stories:
- id: e90s08
title: "Dependabot grouping and semantic-release push discipline"
- status: open
+ status: done
bcps: 2
priority: P2
problem: >
diff --git a/specs/execution-status.yaml b/specs/execution-status.yaml
index 88cc59bbb4..0b6b660b66 100644
--- a/specs/execution-status.yaml
+++ b/specs/execution-status.yaml
@@ -47,7 +47,7 @@ epics:
e43: done
e44: done
e45: done
- e46: in_progress
+ e46: done
e47: done
e48: done
e49: done
@@ -82,11 +82,13 @@ epics:
e79: planned
e89: planned
e81: open
- e84: open
+ e83: done
+ e84: done
e85: done
- e86: open
- e87: open
- e88: open
+ e86: done
+ e87: done
+ e88: done
+ e90: open
stories:
e01s01: done
e02s01: done
@@ -321,28 +323,38 @@ stories:
e89s06: todo
e89s07: todo
e81s00: open
- e81s01: open
- e81s02: open
- e81s03: open
- e81s04: open
- e81s05: open
- e81s06: open
- e84s01: open
- e84s02: open
- e84s03: open
+ e81s01: done
+ e81s02: done
+ e81s03: done
+ e81s04: done
+ e81s05: done
+ e81s06: done
+ e83s01: done
+ e84s01: done
+ e84s02: done
+ e84s03: done
e85s01: done
e85s02: done
- e86s01: open
- e86s02: open
- e86s03: open
- e86s04: open
- e87s01: open
- e87s02: open
- e87s03: open
- e87s04: open
- e87s05: open
- e87s06: open
- e87s07: open
- e88s01: open
- e88s02: open
- e88s03: open
+ e86s01: done
+ e86s02: done
+ e86s03: done
+ e86s04: done
+ e87s01: done
+ e87s02: done
+ e87s03: done
+ e87s04: done
+ e87s05: done
+ e87s06: done
+ e87s07: done
+ e88s01: done
+ e88s02: done
+ e88s03: done
+ e90s01: open
+ e90s02: done
+ e90s03: done
+ e90s04: open
+ e90s05: done
+ e90s06: done
+ e90s07: open
+ e90s08: done
+ e90s09: open
diff --git a/specs/plans/EXECUTION-2026-11-AUDIT-WAVES.md b/specs/plans/EXECUTION-2026-11-AUDIT-WAVES.md
new file mode 100644
index 0000000000..1cfe512407
--- /dev/null
+++ b/specs/plans/EXECUTION-2026-11-AUDIT-WAVES.md
@@ -0,0 +1,185 @@
+# Execution Plan — Audit 2026-11 Waves (release 3.1.0)
+
+Status: completed
+Integration branch: `integration/3.1.0` (from `audit/hotpaths-state-machines`)
+Base commit: `dd9787283`
+Source audit: `specs/audit/2026-11-hotpaths-state-machines.md`
+Issues: #300–#309 (all closed)
+
+## 1. Objective
+
+Close every gap found in the 2026-11 audit and the open GitHub issues, then the
+open/in-progress epics that touch the same subsystems, using parallel worktrees
+with one owner per file boundary and a single integration train.
+
+Definition of done for the program:
+
+1. All 10 issues (#300–#309) closed by merged commits with tests.
+2. `go test ./...`, `go test -race ./components/deploy/ ./components/cici/`,
+ `vitest run`, `tsc`, `eslint`, `go vet` green on `integration/3.1.0`.
+3. `bash scripts/validate-specs-yaml.sh` and `bash scripts/trace-stories.sh --strict` green.
+4. One PR `integration/3.1.0` → `main` (or ordered PRs per wave) with CI green.
+
+## 2. Scope and inventory
+
+### Issues (primary)
+
+| Issue | Priority | Workstream |
+|---|---|---|
+| #302 | P0 | deploy-state |
+| #303 | P1 | deploy-state |
+| #304 | P1 | deploy-state |
+| #305 | P1 | cici-state |
+| #301 | P0 | cici-state |
+| #306 | P1 | cert-provisioning |
+| #307 | P2 | ui-fetch-imports |
+| #309 | P2 | ui-fetch-imports |
+| #300 | P0 | deploy-reconcile (wave 2) |
+| #308 | P2 | state-observability (wave 2) |
+
+### Open/in-progress epics (secondary)
+
+| Epic | Status | Workstream |
+|---|---|---|
+| e46 Custom Domains & Auto SSL | in_progress | cert-provisioning (#306) |
+| e90 Dev-Environment Hardening | open | dev-env-hardening |
+| e83 MCP site env vars | open | mcp-env-tools |
+| e84 Per-site CSP | open | per-site-csp |
+| e86 Monitoring logs | open | monitoring-logs |
+| e81 Multi-language deploy | open | multi-lang-deploy (wave 3; e81s00 needs VPS) |
+
+### Out of scope (do not execute)
+
+- e57–e66: `superseded` (archived). Do not resurrect.
+- e68, e70, e73, e74, e89: `planned` future release work, not audit gaps.
+- Bug registry: **0 open bugs** (130 fixed, 10 wontfix, 1 done, 1 resolved).
+
+## 3. Dependency DAG
+
+```mermaid
+flowchart LR
+ subgraph W1[Wave 1 — parallel]
+ A[deploy-state
#302 #303 #304]
+ B[cici-state
#305 #301]
+ C[cert-provisioning
#306]
+ D[ui-fetch-imports
#307 #309]
+ E[dev-env-hardening
e90]
+ F[mcp-env-tools
e83]
+ G[per-site-csp
e84]
+ H[monitoring-logs
e86]
+ end
+ subgraph W2[Wave 2 — after W1]
+ A --> I[deploy-reconcile
#300]
+ B --> J[state-observability
#308]
+ A --> J
+ end
+ subgraph W3[Wave 3 — integration]
+ I --> K[merge train + full CI]
+ J --> K
+ C --> K
+ D --> K
+ E --> K
+ F --> K
+ G --> K
+ H --> K
+ end
+```
+
+Rules:
+
+- `deploy-state` owns `components/deploy/state_*.go`, `gateway.go`,
+ `rollback.go`, `components/sites/sites.go`, and their tests. No other
+ workstream may touch those files until it merges.
+- `cici-state` owns `components/cici/*`.
+- `cert-provisioning` owns `components/sites/domains.go` (+ proxy only if the
+ retry path needs it). Coordinate with `deploy-state` on `sites.go`; do not
+ edit `sites.go`.
+- `ui-fetch-imports` owns `ui/src/lib/sitesData.ts`, `ui/src/pages/SiteDetailPage.*`,
+ `ui/src/components/SiteEnvVarsTab.*`, `ui/src/lib/secretsData.*`.
+- Epic workstreams own their component dirs; they must not touch deploy/cici
+ state files.
+
+## 4. Worktree map (Orca)
+
+| Workstream | Branch | Path | Linked |
+|---|---|---|---|
+| deploy-state | `fix/deploy-state-machine` | `/Users/danielvm/orca/workspaces/bigbase/deploy-state` | #302 |
+| cici-state | `fix/cici-state-machine` | `/Users/danielvm/orca/workspaces/bigbase/cici-state` | #305 |
+| cert-provisioning | `fix/cert-provisioning` | `/Users/danielvm/orca/workspaces/bigbase/cert-provisioning` | #306 |
+| ui-fetch-imports | `fix/ui-fetch-imports` | `/Users/danielvm/orca/workspaces/bigbase/ui-fetch-imports` | #307 |
+| dev-env-hardening | `chore/e90-dev-env` | `/Users/danielvm/orca/workspaces/bigbase/dev-env-hardening` | e90 |
+| mcp-env-tools | `feat/e83-mcp-env-vars` | `/Users/danielvm/orca/workspaces/bigbase/mcp-env-tools` | e83 |
+| per-site-csp | `feat/e84-per-site-csp` | `/Users/danielvm/orca/workspaces/bigbase/per-site-csp` | e84 |
+| monitoring-logs | `feat/e86-monitoring-logs` | `/Users/danielvm/orca/workspaces/bigbase/monitoring-logs` | e86 |
+| deploy-reconcile | `fix/deploy-reconcile` | `/Users/danielvm/orca/workspaces/bigbase/deploy-reconcile` | #300 |
+| state-observability | `feat/state-observability` | `/Users/danielvm/orca/workspaces/bigbase/state-observability` | #308 |
+
+All are based on `integration/3.1.0`; setup hooks skipped. UI worktrees must
+symlink `ui/node_modules` from the main checkout before running vitest.
+
+## 5. Concurrency protocol
+
+1. **Max 4 implementation agents at once** (file-boundary independence + review
+ bandwidth). Epics run only when an issue slot frees up.
+2. Every workstream follows `develop-tdd`: RED test → minimal GREEN → refactor,
+ with a `verify:` command per step.
+3. Agents do **not** commit. The coordinator reviews the diff, runs the full
+ package tests, then commits with Conventional Commits.
+4. Merge order into `integration/3.1.0`:
+ `deploy-state` → `cici-state` → `cert-provisioning` → `ui-fetch-imports` →
+ `deploy-reconcile` → `state-observability` → epics.
+5. After each merge: run the package suite + `go build ./...`; after the last
+ merge: full preflight (`npm run compliance`,
+ `bash scripts/run-verification-gates.sh`, `sync-skills`, `trace-stories`).
+6. On conflict: rebase the workstream on `integration/3.1.0`; never force-push.
+
+## 6. Per-workstream acceptance
+
+### deploy-state (#302 #303 #304)
+- CAS/atomic transition; no two terminal outcomes under `-race -count=20`.
+- No persisted status outside `DeploymentState`; `replaced` resolved.
+- `IsActive`/`IsTerminal`/`CanDelete` exported and consumed; rollback excludes
+ `deploying`/`draining`.
+- verify: `go test -race ./components/deploy/ ./components/sites/ -count=1`
+
+### cici-state (#305 #301)
+- `queued → running → success|failure|cancelled`, persisted `deadline_at`,
+ invalid transitions rejected.
+- Boot reconciliation finishes stale `running` rows as `failure`.
+- verify: `go test ./components/cici/ -count=1`
+
+### cert-provisioning (#306)
+- `provisioning` ages out to `failed` with a reason; retry resets attempts.
+- verify: `go test ./components/sites/ -count=1`
+
+### ui-fetch-imports (#307 #309)
+- one `getSite` per `SiteDetailPage.load`; imports bounded (server bulk or
+ concurrency cap) with per-key errors preserved.
+- verify: `cd ui && npx vitest run src/pages/SiteDetailPage.test.tsx src/lib/secretsData.test.ts src/components/SiteEnvVarsTab.test.tsx`
+
+### Epics
+Each epic closes only when its stories are `done` with verify evidence, and the
+full package + UI suite stays green. e81s00 (VPS runtimes) is gated on SSH and
+`harden-vps`; run it last.
+
+## 7. Risk register
+
+| Risk | Mitigation |
+|---|---|
+| Subagents edit stale checkout | Prompt pins absolute worktree path; coordinator checks `git -C status` before review |
+| UI worktree lacks node_modules | Symlink main `ui/node_modules` before tests |
+| Deploy/cici semantic overlap | File-boundary rules; deploy-state merges first |
+| Merge train drift | Rebase each workstream on integration before merge; CI per merge |
+| e81 VPS work touches prod | Run through `harden-vps`, 8-gate verification, off-peak |
+| Context rot | One plan file + todo list; per-workstream handoff summaries |
+
+## 8. Execution log
+
+| Time | Event |
+|---|---|
+| Wave 0 | Audit fixes committed (`ec74645de`, `4e2c08f38`, `dd9787283`); `integration/3.1.0` created |
+| Wave 0 | 10 Orca worktrees created from `integration/3.1.0` |
+| Wave 1 | Workstreams merged: `deploy-state` (`834cdfd54`), `cici-state` (`945f3156f`), `cert-provisioning` (`5b8ed5a8e`), `ui-fetch-imports` (`2814d8ea5`), `mcp-env-tools` (`37aede3a6`), `dev-env-hardening` (`8c6ed55e5`) |
+| Wave 2 | Workstreams merged: `deploy-reconcile` (`ea4da03fc`), `state-observability` (`87a57c1c9`) |
+| Wave 3 | `multi-lang-deploy` (e81s01–s06) implemented with tests and merged (`9f57588af`); e81s00 (VPS runtimes) deferred to harden-vps session |
+| Close | GitHub issues #300–#309 closed with commit references; e46, e83, e84, e86 marked done; e81 and e90 statuses reconciled |
diff --git a/specs/release-plan.yaml b/specs/release-plan.yaml
index 48b510dfab..28195c1fef 100644
--- a/specs/release-plan.yaml
+++ b/specs/release-plan.yaml
@@ -708,12 +708,22 @@ epics:
note: "6 stories (incl. e81s00 infra). Extends Deploy with 4 new runtimes + profiles + CI templates. Ships in Wave 1."
file: epics/e81-multi-language-deploy.yaml
+- id: e83
+ title: "MCP Tools — Site-Scoped Environment Variables"
+ wsjf: 7.0
+ mode: flat
+ tier: 1
+ status: done
+ depends_on: [e41, e38]
+ note: "1 story, 3 BCPs. MCP tools for site-scoped env var CRUD with key validation and value masking. SHIPPED on integration/3.1.0."
+ file: epics/e83-mcp-env-var-tools.yaml
+
- id: e84
title: "Per-Site CSP Configuration (Issue #197)"
wsjf: 6.0
mode: capsule
tier: 2
- status: open
+ status: done
depends_on: []
note: "3 stories, 8 BCPs. Makes CSP configurable per deployed site via DeployDefaults and bigbase.toml [security] section."
capsule_dir: epics/e84-per-site-csp
@@ -735,7 +745,7 @@ epics:
wsjf: 3.2
mode: capsule
tier: 2
- status: open
+ status: done
depends_on: [e14, e39]
note: "4 stories, 12 BCPs. SSE live log streaming, keyset pagination (replaces LIMIT 100), org_id isolation (closes cross-tenant log leak), deploy-component auto-ingestion via event bus. Deltas: ADDED streaming+ingestion, MODIFIED pagination+org scope. WSJF 3.2 = (7+3+6)/5."
capsule_dir: epics/e86-monitoring-logs
@@ -762,3 +772,13 @@ epics:
note: "3 stories, 8 BCPs. SHIPPED 2026-08-11. Per-theme brandLink >=7:1 for all 13 accent themes (7.10-9.93:1), CONFORMANCE.md formal exceptions (1.4.8 partial + 2.2.3 security), re-certified: matrix 13/13 PASS, axe 17/17 zero violations, UI 709/709. Final claim: AA certified; AAA except documented partials. Archived to epics/archive/."
capsule_dir: epics/archive/e88-aaa-closing
file: epics/archive/e88-aaa-closing/epic.yaml
+
+- id: e90
+ title: "Dev-Environment Hardening — Config Consolidation, CI Gaps, Tooling Debt"
+ wsjf: 9.0
+ mode: flat
+ tier: 1
+ status: open
+ depends_on: [e89]
+ note: "Dev-environment hardening: config consolidation, CI gates, MCP configs, single source agent docs."
+ file: epics/e90-dev-environment-hardening.yaml
diff --git a/specs/traceability-matrix.json b/specs/traceability-matrix.json
index 2124ee80c7..4e56dd4482 100644
--- a/specs/traceability-matrix.json
+++ b/specs/traceability-matrix.json
@@ -1,5 +1,5 @@
{
- "generated_at": "2026-08-12T00:24:37.616472+00:00",
+ "generated_at": "2026-11-04T19:50:28.289069+00:00",
"source": [
"specs/release-plan.yaml",
"specs/epics/**/epic.yaml",
@@ -934,9 +934,13 @@
{
"story_id": "e89s02",
"title": "Add organization-scoped Projects and Environments",
- "files": [],
- "tests": [],
- "status": "Dark"
+ "files": [
+ "tests/e2e/project-environments-ui.spec.ts"
+ ],
+ "tests": [
+ "tests/e2e/project-environments-ui.spec.ts"
+ ],
+ "status": "Covered"
},
{
"story_id": "e89s03",
@@ -955,9 +959,13 @@
{
"story_id": "e89s05",
"title": "Add Admin UI for project secrets",
- "files": [],
- "tests": [],
- "status": "Dark"
+ "files": [
+ "tests/e2e/project-secrets-ui.spec.ts"
+ ],
+ "tests": [
+ "tests/e2e/project-secrets-ui.spec.ts"
+ ],
+ "status": "Covered"
},
{
"story_id": "e89s06",
@@ -969,19 +977,24 @@
{
"story_id": "e89s07",
"title": "Secure MCP secret-management tools",
- "files": [],
- "tests": [],
- "status": "Dark"
+ "files": [
+ "components/mcp/secrets.go",
+ "components/mcp/secrets_test.go"
+ ],
+ "tests": [
+ "components/mcp/secrets_test.go"
+ ],
+ "status": "Covered"
}
],
"summary": {
"total": 126,
- "covered": 16,
- "dark": 110,
+ "covered": 19,
+ "dark": 107,
"orphans": 1
},
"oracle_stats": {
- "explicit_tag_links": 16,
+ "explicit_tag_links": 19,
"heuristic_links": 0,
"heuristic_ratio": 0.0
}
diff --git a/ui/src/components/SiteEnvVarsTab.test.tsx b/ui/src/components/SiteEnvVarsTab.test.tsx
new file mode 100644
index 0000000000..4a90142c90
--- /dev/null
+++ b/ui/src/components/SiteEnvVarsTab.test.tsx
@@ -0,0 +1,106 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest'
+import { render, screen, fireEvent, waitFor } from '@testing-library/react'
+import { IMPORT_CONCURRENCY_LIMIT } from '../lib/boundedConcurrency'
+
+const getEnvVarsMock = vi.fn()
+const createEnvVarMock = vi.fn()
+const updateEnvVarMock = vi.fn()
+const deleteEnvVarMock = vi.fn()
+
+vi.mock('../lib/sitesData', () => ({
+ getEnvVars: (...args: unknown[]) => getEnvVarsMock(...args),
+ createEnvVar: (...args: unknown[]) => createEnvVarMock(...args),
+ updateEnvVar: (...args: unknown[]) => updateEnvVarMock(...args),
+ deleteEnvVar: (...args: unknown[]) => deleteEnvVarMock(...args),
+}))
+
+import { SiteEnvVarsTab } from './SiteEnvVarsTab'
+
+function envFile(keys: string[]): File {
+ return new File([keys.map((key, i) => `${key}=value${i}`).join('\n')], 'bulk.env', {
+ type: 'text/plain',
+ })
+}
+
+async function renderReady() {
+ render()
+ await screen.findByRole('button', { name: /Import \.env/ })
+}
+
+async function importKeys(keys: string[]) {
+ fireEvent.change(screen.getByLabelText('Import .env file'), {
+ target: { files: [envFile(keys)] },
+ })
+}
+
+describe('SiteEnvVarsTab .env import', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ getEnvVarsMock.mockResolvedValue([])
+ createEnvVarMock.mockResolvedValue({ ok: true })
+ updateEnvVarMock.mockResolvedValue({ ok: true })
+ deleteEnvVarMock.mockResolvedValue({ ok: true })
+ })
+
+ it('caps concurrent env-var writes at the configured import limit', async () => {
+ let inFlight = 0
+ let maxInFlight = 0
+ createEnvVarMock.mockImplementation(async () => {
+ inFlight += 1
+ maxInFlight = Math.max(maxInFlight, inFlight)
+ await new Promise(resolve => setTimeout(resolve, 5))
+ inFlight -= 1
+ return { ok: true }
+ })
+
+ await renderReady()
+ const keys = Array.from({ length: 12 }, (_, i) => `KEY_${i}`)
+ await importKeys(keys)
+
+ await waitFor(() => {
+ expect(createEnvVarMock).toHaveBeenCalledTimes(keys.length)
+ })
+ expect(maxInFlight).toBe(IMPORT_CONCURRENCY_LIMIT)
+ expect(maxInFlight).toBeLessThanOrEqual(IMPORT_CONCURRENCY_LIMIT)
+ })
+
+ it('reports per-key failures in input order and never echoes values', async () => {
+ const failing = new Set(['KEY_1', 'KEY_4', 'KEY_7'])
+ createEnvVarMock.mockImplementation(
+ async (_siteId: string, payload: { key: string }) => {
+ const index = Number(payload.key.slice('KEY_'.length))
+ await new Promise(resolve => setTimeout(resolve, (10 - index) * 2))
+ if (failing.has(payload.key)) return { ok: false, error: 'write rejected' }
+ return { ok: true }
+ },
+ )
+
+ await renderReady()
+ const keys = Array.from({ length: 10 }, (_, i) => `KEY_${i}`)
+ await importKeys(keys)
+
+ const message = await screen.findByText(/3 var\(s\) failed to import/)
+ const text = message.textContent ?? ''
+ expect(failing.size).toBe(3)
+ const order = [...failing].map(key => text.indexOf(key))
+ expect(order).toEqual([...order].sort((a, b) => a - b))
+ expect(order.every(position => position >= 0)).toBe(true)
+ // Submitted values never appear in the aggregated error report.
+ expect(text).not.toMatch(/value\d/)
+ })
+
+ it('deduplicates duplicate keys in imported file, keeping the last value', async () => {
+ createEnvVarMock.mockResolvedValue({ ok: true })
+ await renderReady()
+ const file = new File(['KEY_A=first\nKEY_B=only\nKEY_A=second\n'], 'bulk.env', { type: 'text/plain' })
+ fireEvent.change(screen.getByLabelText('Import .env file'), {
+ target: { files: [file] },
+ })
+
+ await waitFor(() => {
+ expect(createEnvVarMock).toHaveBeenCalledTimes(2)
+ })
+ expect(createEnvVarMock).toHaveBeenCalledWith('site-1', expect.objectContaining({ key: 'KEY_A', value: 'second' }))
+ expect(createEnvVarMock).toHaveBeenCalledWith('site-1', expect.objectContaining({ key: 'KEY_B', value: 'only' }))
+ })
+})
diff --git a/ui/src/components/SiteEnvVarsTab.tsx b/ui/src/components/SiteEnvVarsTab.tsx
index b063e88d35..baf78ed4fd 100644
--- a/ui/src/components/SiteEnvVarsTab.tsx
+++ b/ui/src/components/SiteEnvVarsTab.tsx
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react'
import { Button, Card } from '.'
import { getEnvVars, createEnvVar, updateEnvVar, deleteEnvVar } from '../lib/sitesData'
+import { IMPORT_CONCURRENCY_LIMIT, mapWithConcurrency } from '../lib/boundedConcurrency'
import type { EnvVar } from '../types/sites'
const KEY_RE = /^[A-Z][A-Z0-9_]*$/
@@ -214,8 +215,16 @@ export function SiteEnvVarsTab({ siteId, id }: { siteId: string; id?: string })
const handleImportFile = async (file: File) => {
setImporting(true); setImportError(null)
const text = await file.text()
- const pairs = parseEnvFile(text)
- if (pairs.length === 0) { setImportError('No valid KEY=value pairs found'); setImporting(false); return }
+ const rawPairs = parseEnvFile(text)
+ if (rawPairs.length === 0) { setImportError('No valid KEY=value pairs found'); setImporting(false); return }
+
+ // Deduplicate parsed pairs so duplicate keys in a single file take the last
+ // value (standard .env precedence) and don't trigger concurrent create races.
+ const dedupedMap = new Map()
+ for (const { key, value } of rawPairs) {
+ dedupedMap.set(key, value)
+ }
+ const pairs = Array.from(dedupedMap.entries()).map(([key, value]) => ({ key, value }))
const existingKeys = new Set(envVars.map(e => e.key))
const overlapping = pairs.filter(p => existingKeys.has(p.key)).map(p => p.key)
@@ -225,17 +234,19 @@ export function SiteEnvVarsTab({ siteId, id }: { siteId: string; id?: string })
}
}
- // F04: collect per-item errors and report aggregate
- const errors: string[] = []
- for (const { key, value } of pairs) {
- let res: { ok: boolean; error?: string }
- if (existingKeys.has(key)) {
- res = await updateEnvVar(siteId, key, { value, is_build_time: false, is_runtime: true })
- } else {
- res = await createEnvVar(siteId, { key, value, is_build_time: false, is_runtime: true })
- }
- if (!res.ok) errors.push(`${key}: ${res.error ?? 'failed'}`)
- }
+ // F04: collect per-item errors and report aggregate. Writes run with bounded
+ // concurrency (issue #309); results keep .env order for a stable report.
+ const outcomes = await mapWithConcurrency(
+ pairs,
+ IMPORT_CONCURRENCY_LIMIT,
+ async ({ key, value }): Promise => {
+ const res = existingKeys.has(key)
+ ? await updateEnvVar(siteId, key, { value, is_build_time: false, is_runtime: true })
+ : await createEnvVar(siteId, { key, value, is_build_time: false, is_runtime: true })
+ return res.ok ? null : `${key}: ${res.error ?? 'failed'}`
+ },
+ )
+ const errors = outcomes.filter((error): error is string => error !== null)
setImporting(false)
if (errors.length > 0) {
setImportError(`${errors.length} var(s) failed to import:\n${errors.join('\n')}`)
diff --git a/ui/src/lib/boundedConcurrency.test.ts b/ui/src/lib/boundedConcurrency.test.ts
new file mode 100644
index 0000000000..830d787442
--- /dev/null
+++ b/ui/src/lib/boundedConcurrency.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from 'vitest'
+import { mapWithConcurrency } from './boundedConcurrency'
+
+describe('mapWithConcurrency', () => {
+ it('returns empty array when items is empty without spawning workers', async () => {
+ let callCount = 0
+ const res = await mapWithConcurrency([], 4, async (item) => {
+ callCount++
+ return item
+ })
+ expect(res).toEqual([])
+ expect(callCount).toBe(0)
+ })
+
+ it('preserves input order even when tasks finish out of order', async () => {
+ const items = [50, 10, 30, 20]
+ const res = await mapWithConcurrency(items, 4, async (delay, idx) => {
+ await new Promise(r => setTimeout(r, delay))
+ return `item-${idx}-${delay}`
+ })
+ expect(res).toEqual([
+ 'item-0-50',
+ 'item-1-10',
+ 'item-2-30',
+ 'item-3-20',
+ ])
+ })
+
+ it('bounds active concurrency to limit', async () => {
+ const items = Array.from({ length: 10 }, (_, i) => i)
+ let active = 0
+ let maxActive = 0
+ await mapWithConcurrency(items, 3, async () => {
+ active++
+ maxActive = Math.max(maxActive, active)
+ await new Promise(r => setTimeout(r, 20))
+ active--
+ })
+ expect(maxActive).toBeLessThanOrEqual(3)
+ })
+
+ it('aborts remaining tasks on failure without unhandled rejections', async () => {
+ const items = [1, 2, 3, 4, 5, 6, 7, 8]
+ const executed: number[] = []
+ await expect(
+ mapWithConcurrency(items, 2, async (item) => {
+ executed.push(item)
+ if (item === 2) {
+ throw new Error('fail on 2')
+ }
+ await new Promise(r => setTimeout(r, 30))
+ return item
+ }),
+ ).rejects.toThrow('fail on 2')
+
+ // Remaining tasks beyond the initial worker pool must not be executed
+ expect(executed).not.toContain(5)
+ expect(executed).not.toContain(6)
+ expect(executed).not.toContain(7)
+ expect(executed).not.toContain(8)
+ })
+})
diff --git a/ui/src/lib/boundedConcurrency.ts b/ui/src/lib/boundedConcurrency.ts
new file mode 100644
index 0000000000..1472deea93
--- /dev/null
+++ b/ui/src/lib/boundedConcurrency.ts
@@ -0,0 +1,40 @@
+/**
+ * Upper bound on parallel writes during a .env import. Four requests in flight
+ * turns queue latency from O(n) round-trips into O(n / 4) while keeping the
+ * single SQLite writer from being flooded (issue #309).
+ */
+export const IMPORT_CONCURRENCY_LIMIT = 4
+
+/**
+ * Runs `task` over `items` with at most `limit` promises in flight, returning
+ * results in input order regardless of completion order. Rejections are
+ * propagated as soon as a worker throws; callers that need per-item errors
+ * should catch inside `task`.
+ */
+export async function mapWithConcurrency(
+ items: readonly T[],
+ limit: number,
+ task: (item: T, index: number) => Promise,
+): Promise {
+ if (items.length === 0) return []
+ const results = new Array(items.length)
+ const workerCount = Math.max(1, Math.min(limit, items.length))
+ let next = 0
+ let aborted = false
+
+ const worker = async (): Promise => {
+ while (!aborted && next < items.length) {
+ const index = next
+ next += 1
+ try {
+ results[index] = await task(items[index], index)
+ } catch (err) {
+ aborted = true
+ throw err
+ }
+ }
+ }
+
+ await Promise.all(Array.from({ length: workerCount }, worker))
+ return results
+}
diff --git a/ui/src/lib/secretsData.test.ts b/ui/src/lib/secretsData.test.ts
index a9856c5ed5..af439ba6eb 100644
--- a/ui/src/lib/secretsData.test.ts
+++ b/ui/src/lib/secretsData.test.ts
@@ -11,6 +11,7 @@ import {
readSecretValue,
updateSecret,
} from './secretsData'
+import { IMPORT_CONCURRENCY_LIMIT } from './boundedConcurrency'
import type { SecretMetadata, SecretValue } from '../types/secrets'
function jsonResponse(body: unknown, status = 200): Response {
@@ -243,3 +244,82 @@ describe('importEnvSecrets (safe .env import)', () => {
expect(result.failedKeys.join(' ')).not.toMatch(/[12]/)
})
})
+
+describe('importEnvSecrets (bounded concurrency)', () => {
+ afterEach(() => vi.unstubAllGlobals())
+
+ it('caps concurrent writes at the configured import limit', async () => {
+ let inFlight = 0
+ let maxInFlight = 0
+ stubFetch(async () => {
+ inFlight += 1
+ maxInFlight = Math.max(maxInFlight, inFlight)
+ await new Promise(resolve => setTimeout(resolve, 5))
+ inFlight -= 1
+ return jsonResponse({ data: metaFixture('K') })
+ })
+
+ const keys = Array.from({ length: 12 }, (_, i) => `KEY_${i}`)
+ const result = await importEnvSecrets('p1', 'e1', keys.map(k => `${k}=v`).join('\n'), new Set())
+
+ expect(result.created).toBe(12)
+ expect(maxInFlight).toBe(IMPORT_CONCURRENCY_LIMIT)
+ expect(maxInFlight).toBeLessThanOrEqual(IMPORT_CONCURRENCY_LIMIT)
+ })
+
+ it('reports per-key failures in input order even when writes complete out of order', async () => {
+ const failing = new Set(['KEY_1', 'KEY_4', 'KEY_7'])
+ stubFetch(async (url) => {
+ const key = decodeURIComponent(String(url).split('/').pop() ?? '')
+ const index = Number(key.slice('KEY_'.length))
+ // Earlier keys finish later, so completion order is reversed.
+ await new Promise(resolve => setTimeout(resolve, (10 - index) * 2))
+ if (failing.has(key)) return jsonResponse({ error: 'boom' }, 500)
+ return jsonResponse({ data: metaFixture(key) })
+ })
+
+ const keys = Array.from({ length: 10 }, (_, i) => `KEY_${i}`)
+ const result = await importEnvSecrets(
+ 'p1',
+ 'e1',
+ keys.map(k => `${k}=v`).join('\n'),
+ new Set(keys),
+ )
+
+ expect(result.updated).toBe(7)
+ expect(result.created).toBe(0)
+ expect(result.failedKeys).toEqual(['KEY_1', 'KEY_4', 'KEY_7'])
+ })
+
+ it('keeps client-side invalid keys in order alongside API failures', async () => {
+ stubFetch(async () => {
+ await new Promise(resolve => setTimeout(resolve, 5))
+ return jsonResponse({ error: 'boom' }, 500)
+ })
+
+ const text = ['Z_INVALID=1', 'A_1=ok', 'a_bad=x', 'B_1=ok2'].join('\n')
+ const result = await importEnvSecrets('p1', 'e1', text, new Set())
+
+ // The invalid key is reported without a write; API failures stay in .env order.
+ expect(result.failedKeys).toEqual(['Z_INVALID', 'A_1', 'a_bad', 'B_1'])
+ expect(result.created).toBe(0)
+ })
+
+ it('deduplicates duplicate keys keeping the last value', async () => {
+ const writtenValues: Record = {}
+ stubFetch(async (_url, init) => {
+ const body = JSON.parse(String(init?.body ?? '{}')) as { key?: string; value?: string }
+ if (body.key && body.value) {
+ writtenValues[body.key] = body.value
+ }
+ return jsonResponse({ data: metaFixture(body.key ?? 'key') })
+ })
+
+ const text = ['FOO_VAR=first', 'BAR_VAR=only', 'FOO_VAR=second'].join('\n')
+ const result = await importEnvSecrets('p1', 'e1', text, new Set())
+
+ expect(result.created).toBe(2)
+ expect(writtenValues.FOO_VAR).toBe('second')
+ expect(writtenValues.BAR_VAR).toBe('only')
+ })
+})
diff --git a/ui/src/lib/secretsData.ts b/ui/src/lib/secretsData.ts
index 6aa0b3afb7..cb4529e8c3 100644
--- a/ui/src/lib/secretsData.ts
+++ b/ui/src/lib/secretsData.ts
@@ -1,3 +1,4 @@
+import { IMPORT_CONCURRENCY_LIMIT, mapWithConcurrency } from './boundedConcurrency'
import type {
Project,
ProjectEnvironment,
@@ -240,29 +241,45 @@ export async function importEnvSecrets(
text: string,
existingKeys: ReadonlySet,
): Promise {
- const pairs = parseEnvFile(text)
+ const rawPairs = parseEnvFile(text)
const result: ImportSecretsResult = { created: 0, updated: 0, failedKeys: [], skipped: 0 }
+ // Deduplicate parsed pairs so duplicate keys in a single file take the last
+ // value (standard .env precedence) and don't trigger concurrent create races.
+ const dedupedMap = new Map()
+ for (const { key, value } of rawPairs) {
+ dedupedMap.set(key, value)
+ }
+ const pairs = Array.from(dedupedMap.entries()).map(([key, value]) => ({ key, value }))
+
const bounded = pairs.slice(0, MAX_SECRET_BATCH_KEYS)
result.skipped = Math.max(0, pairs.length - MAX_SECRET_BATCH_KEYS)
- for (const pair of bounded) {
- // Client-side validation mirrors the server bounds so invalid keys are
- // reported by name without issuing a write or echoing the value.
- if (!isValidSecretKey(pair.key) || pair.value.length > MAX_SECRET_VALUE_BYTES) {
- result.failedKeys.push(pair.key)
- continue
- }
- const res = existingKeys.has(pair.key)
- ? await updateSecret(projectId, envId, pair.key, { value: pair.value })
- : await createSecret(projectId, envId, { key: pair.key, value: pair.value })
- if (!res.ok) {
- result.failedKeys.push(pair.key)
- continue
- }
- if (existingKeys.has(pair.key)) result.updated += 1
+ // Writes run with bounded concurrency (issue #309); results are collected by
+ // input index so failures are reported in .env order, not completion order.
+ const outcomes = await mapWithConcurrency(
+ bounded,
+ IMPORT_CONCURRENCY_LIMIT,
+ async (pair): Promise<'created' | 'updated' | 'failed'> => {
+ // Client-side validation mirrors the server bounds so invalid keys are
+ // reported by name without issuing a write or echoing the value.
+ if (!isValidSecretKey(pair.key) || pair.value.length > MAX_SECRET_VALUE_BYTES) {
+ return 'failed'
+ }
+ const res = existingKeys.has(pair.key)
+ ? await updateSecret(projectId, envId, pair.key, { value: pair.value })
+ : await createSecret(projectId, envId, { key: pair.key, value: pair.value })
+ if (!res.ok) return 'failed'
+ return existingKeys.has(pair.key) ? 'updated' : 'created'
+ },
+ )
+
+ bounded.forEach((pair, index) => {
+ const outcome = outcomes[index]
+ if (outcome === 'failed') result.failedKeys.push(pair.key)
+ else if (outcome === 'updated') result.updated += 1
else result.created += 1
- }
+ })
return result
}
diff --git a/ui/src/lib/sitesData.test.ts b/ui/src/lib/sitesData.test.ts
index 7dceb1378a..f00ac32e0b 100644
--- a/ui/src/lib/sitesData.test.ts
+++ b/ui/src/lib/sitesData.test.ts
@@ -1,5 +1,50 @@
import { describe, expect, it, vi, afterEach } from 'vitest'
-import { getGitHubRepos, rollbackDeployment, getRollbackEvents } from './sitesData'
+import {
+ getGitHubRepos,
+ getRollbackEvents,
+ getSite,
+ getSiteDeployments,
+ rollbackDeployment,
+} from './sitesData'
+import type { Site } from '../types/sites'
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
+}
+
+function stubFetchUrls(handler: (url: string) => Response) {
+ const urls: string[] = []
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn((input: RequestInfo | URL) => {
+ const url = typeof input === 'string' ? input : String(input)
+ urls.push(url)
+ return Promise.resolve(handler(url))
+ }),
+ )
+ return urls
+}
+
+const siteFixture: Site = {
+ id: 'site-1',
+ name: 'My Site',
+ full_name: 'org/mysite',
+ git_repo_id: 'repo-1',
+ production_branch: 'main',
+ root_path: './',
+}
+
+const deploymentFixture = {
+ id: 'd1',
+ repo_id: 'repo-1',
+ branch: 'main',
+ commit_sha: 'abc1234',
+ status: 'running',
+ url: '',
+ port: 4000,
+ app_type: 'static',
+ created_at: '2026-06-01T10:00:00Z',
+}
describe('getGitHubRepos', () => {
afterEach(() => {
@@ -138,3 +183,51 @@ describe('getRollbackEvents', () => {
expect(result.events).toEqual([])
})
})
+
+describe('getSiteDeployments (prefetched site)', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('falls back to getSite for legacy callers that pass no site', async () => {
+ const urls = stubFetchUrls(url => {
+ if (url === '/api/sites/site-1') return jsonResponse(siteFixture)
+ if (url === '/api/deploy') return jsonResponse({ data: [deploymentFixture] })
+ return jsonResponse({}, 404)
+ })
+
+ const result = await getSiteDeployments('site-1')
+
+ expect(urls).toContain('/api/sites/site-1')
+ expect(urls).toContain('/api/deploy')
+ expect(result.data).toHaveLength(1)
+ expect(result.data[0].repo_id).toBe('repo-1')
+ })
+
+ it('uses a prefetched site and never re-fetches it', async () => {
+ const urls = stubFetchUrls(url => {
+ if (url === '/api/deploy') return jsonResponse({ data: [deploymentFixture] })
+ return jsonResponse({}, 404)
+ })
+
+ const result = await getSiteDeployments('site-1', { data: siteFixture, previewMode: false })
+
+ expect(urls).toEqual(['/api/deploy'])
+ expect(result.data).toHaveLength(1)
+ expect(result.data[0].repo_id).toBe('repo-1')
+ })
+
+ it('accepts an in-flight getSite promise so callers can prefetch concurrently', async () => {
+ const urls = stubFetchUrls(url => {
+ if (url === '/api/sites/site-1') return jsonResponse(siteFixture)
+ if (url === '/api/deploy') return jsonResponse({ data: [deploymentFixture] })
+ return jsonResponse({}, 404)
+ })
+
+ const sitePromise = getSite('site-1')
+ const result = await getSiteDeployments('site-1', sitePromise)
+
+ expect(urls.filter(url => url === '/api/sites/site-1')).toHaveLength(1)
+ expect(result.data).toHaveLength(1)
+ })
+})
diff --git a/ui/src/lib/sitesData.ts b/ui/src/lib/sitesData.ts
index e639f8aad9..7f036d597f 100644
--- a/ui/src/lib/sitesData.ts
+++ b/ui/src/lib/sitesData.ts
@@ -106,12 +106,35 @@ export async function getSite(siteId: string): Promise> {
+/**
+ * A site lookup the caller already started. SiteDetailPage fetches the site and
+ * deployments in parallel, so passing the in-flight promise avoids the duplicate
+ * `GET /api/sites/:id` that the 2s deploy poll used to issue (issue #307).
+ */
+export type PrefetchedSite =
+ | SitesDataResult
+ | Promise>
+
+async function resolveSiteRepoId(
+ siteId: string,
+ prefetchedSite?: PrefetchedSite,
+): Promise {
+ if (prefetchedSite) {
+ const site = await prefetchedSite
+ return site.data?.git_repo_id ?? siteId
+ }
+ const site = await getSite(siteId)
+ return site.data?.git_repo_id ?? siteId
+}
+
+export async function getSiteDeployments(
+ siteId: string,
+ prefetchedSite?: PrefetchedSite,
+): Promise> {
if (isPreviewForced()) {
return { data: mockDeployments, previewMode: true }
}
- const site = await getSite(siteId)
- const repoId = site.data?.git_repo_id ?? siteId
+ const repoId = await resolveSiteRepoId(siteId, prefetchedSite)
const { ok, data } = await fetchJSON<{ data: Deployment[] }>('/api/deploy')
if (!ok) {
return { data: isPreviewForced() ? mockDeployments : [], previewMode: true }
diff --git a/ui/src/pages/CreateSitePage.test.tsx b/ui/src/pages/CreateSitePage.test.tsx
index d25c6f6deb..1ee8c1357f 100644
--- a/ui/src/pages/CreateSitePage.test.tsx
+++ b/ui/src/pages/CreateSitePage.test.tsx
@@ -48,6 +48,48 @@ describe('CreateSitePage layout', () => {
})
})
+ it('loads github status, github repos and local repos concurrently', async () => {
+ // The three bootstrap requests are independent; awaiting them one at a
+ // time means the gate never sees all three start before it releases.
+ const started: string[] = []
+ let startedAtRelease = -1
+ let released = false
+ let release!: () => void
+ const gate = new Promise((resolve) => { release = resolve })
+ const releaseOnce = () => {
+ if (released) return
+ released = true
+ startedAtRelease = started.length
+ release()
+ }
+ const failSafe = setTimeout(releaseOnce, 100)
+ const track = (name: string, value: T): Promise => {
+ started.push(name)
+ if (started.length === 3) {
+ clearTimeout(failSafe)
+ releaseOnce()
+ }
+ return gate.then(() => value)
+ }
+ getGitHubStatus.mockImplementation(() => track('status', {
+ data: { connected: true, configured: true, login: 'testuser' },
+ previewMode: false,
+ }))
+ getGitHubRepos.mockImplementation(() => track('github-repos', {
+ data: [{ id: 1, full_name: 'acme/one', default_branch: 'main', private: false }],
+ previewMode: false,
+ }))
+ getGitRepos.mockImplementation(() => track('local-repos', { data: [], previewMode: false }))
+
+ renderPage()
+ await waitFor(() => {
+ expect(screen.getByText('acme/one')).toBeInTheDocument()
+ })
+
+ expect(startedAtRelease).toBe(3)
+ expect([...started].sort()).toEqual(['github-repos', 'local-repos', 'status'])
+ })
+
it('applies grid layout to choice cards', async () => {
const { container } = renderPage()
await waitFor(() => {
diff --git a/ui/src/pages/CreateSitePage.tsx b/ui/src/pages/CreateSitePage.tsx
index ec75a22734..117f996364 100644
--- a/ui/src/pages/CreateSitePage.tsx
+++ b/ui/src/pages/CreateSitePage.tsx
@@ -82,28 +82,26 @@ export default function CreateSitePage() {
}, [logError])
/* eslint-enable react-hooks/set-state-in-effect */
- const loadGitHub = async () => {
- setGhLoading(true)
- setGhReposError('')
- const st = await getGitHubStatus()
- setGhConnected(st.data.connected)
- setGhConfigured(st.data.configured)
- setGhLogin(st.data.login)
- setPreviewMode(p => p || st.previewMode)
- const repos = await getGitHubRepos()
- setGhRepos(repos.data)
- setGhReposError(repos.error ?? '')
- setPreviewMode(p => p || repos.previewMode)
- setGhLoading(false)
- }
-
useEffect(() => {
const init = async () => {
const force = isPreviewForced()
setPreviewMode(force)
- await loadGitHub()
- const local = await getGitRepos()
+ setGhLoading(true)
+ setGhReposError('')
+ const [st, repos, local] = await Promise.all([
+ getGitHubStatus(),
+ getGitHubRepos(),
+ getGitRepos(),
+ ])
+ setGhConnected(st.data.connected)
+ setGhConfigured(st.data.configured)
+ setGhLogin(st.data.login)
+ setPreviewMode(p => p || st.previewMode)
+ setGhRepos(repos.data)
+ setGhReposError(repos.error ?? '')
+ setPreviewMode(p => p || repos.previewMode)
setLocalRepos(local.data)
+ setGhLoading(false)
if (preRepo) {
const r = local.data.find(x => x.id === preRepo)
if (r) {
diff --git a/ui/src/pages/SiteDetailPage.test.tsx b/ui/src/pages/SiteDetailPage.test.tsx
index 3d59455954..a7c1f7e6cd 100644
--- a/ui/src/pages/SiteDetailPage.test.tsx
+++ b/ui/src/pages/SiteDetailPage.test.tsx
@@ -20,12 +20,13 @@ const mockDeploymentsWithPrevious = [
]
// vi.fn() references kept outside vi.mock so tests can override per-describe
+const getSiteMock = vi.fn()
const getSiteDeploymentsMock = vi.fn()
const getRollbackEventsMock = vi.fn()
const rollbackDeploymentMock = vi.fn()
vi.mock('../lib/sitesData', () => ({
- getSite: () => Promise.resolve({ data: mockSite, previewMode: false }),
+ getSite: (...args: unknown[]) => getSiteMock(...args),
getSiteDeployments: (...args: unknown[]) => getSiteDeploymentsMock(...args),
getSiteManifest: () => Promise.resolve({ data: { exists: false, content: '' }, previewMode: false }),
saveSiteManifest: () => Promise.resolve({ ok: true, previewMode: false }),
@@ -49,6 +50,7 @@ function renderPage() {
describe('SiteDetailPage', () => {
beforeEach(() => {
vi.clearAllMocks()
+ getSiteMock.mockResolvedValue({ data: mockSite, previewMode: false })
getSiteDeploymentsMock.mockResolvedValue({ data: mockDeployments, previewMode: false })
getRollbackEventsMock.mockResolvedValue({ ok: true, events: [] })
rollbackDeploymentMock.mockResolvedValue({ ok: true })
@@ -71,6 +73,60 @@ describe('SiteDetailPage', () => {
})
})
+ it('fetches the site once per load and shares it with the deployments request', async () => {
+ renderPage()
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: 'My Site' })).toBeInTheDocument()
+ })
+
+ expect(getSiteMock).toHaveBeenCalledTimes(1)
+ expect(getSiteDeploymentsMock).toHaveBeenCalledTimes(1)
+
+ // The page hands the in-flight site result to getSiteDeployments so the
+ // deployments lookup does not issue a duplicate GET /api/sites/:id.
+ const deployedArgs = getSiteDeploymentsMock.mock.calls[0]
+ expect(deployedArgs[0]).toBe('site-1')
+ expect(deployedArgs[1]).toBeDefined()
+ await expect(deployedArgs[1]).resolves.toEqual({ data: mockSite, previewMode: false })
+ })
+
+ it('loads site, deployments and rollback events concurrently', async () => {
+ // All three resources are independent. If the page awaits them one by one,
+ // the first promise never sees the other two start, so startedAtRelease
+ // stays below 3 when the fail-safe releases the gate.
+ const started: string[] = []
+ let startedAtRelease = -1
+ let released = false
+ let release!: () => void
+ const gate = new Promise((resolve) => { release = resolve })
+ const releaseOnce = () => {
+ if (released) return
+ released = true
+ startedAtRelease = started.length
+ release()
+ }
+ const failSafe = setTimeout(releaseOnce, 100)
+ const track = (name: string, value: T): Promise => {
+ started.push(name)
+ if (started.length === 3) {
+ clearTimeout(failSafe)
+ releaseOnce()
+ }
+ return gate.then(() => value)
+ }
+ getSiteMock.mockImplementation(() => track('site', { data: mockSite, previewMode: false }))
+ getSiteDeploymentsMock.mockImplementation(() => track('deployments', { data: mockDeployments, previewMode: false }))
+ getRollbackEventsMock.mockImplementation(() => track('rollback', { ok: true, events: [] }))
+
+ renderPage()
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: 'My Site' })).toBeInTheDocument()
+ })
+
+ expect(startedAtRelease).toBe(3)
+ expect([...started].sort()).toEqual(['deployments', 'rollback', 'site'])
+ })
+
it('shows branch info', async () => {
renderPage()
await waitFor(() => {
diff --git a/ui/src/pages/SiteDetailPage.tsx b/ui/src/pages/SiteDetailPage.tsx
index 266a5a61f2..2d0034a262 100644
--- a/ui/src/pages/SiteDetailPage.tsx
+++ b/ui/src/pages/SiteDetailPage.tsx
@@ -320,17 +320,22 @@ export default function SiteDetailPage() {
{ id: 'manifest', label: 'Manifest' },
]
- const load = useCallback(async () => {
- setLoading(true)
- const s = await getSite(siteId)
+ const load = useCallback(async (isBackground = false) => {
+ if (!isBackground) setLoading(true)
+ // One site request per load: the deployments lookup reuses this in-flight
+ // promise instead of issuing its own GET /api/sites/:id on every poll.
+ const sitePromise = getSite(siteId)
+ const [s, d, evRes] = await Promise.all([
+ sitePromise,
+ getSiteDeployments(siteId, sitePromise),
+ !isBackground ? getRollbackEvents(siteId) : Promise.resolve(null),
+ ])
setSite(s.data)
setPreviewMode(s.previewMode || isPreviewForced())
- const d = await getSiteDeployments(siteId)
setDeployments(d.data)
setPreviewMode(p => p || d.previewMode)
- const evRes = await getRollbackEvents(siteId)
- if (evRes.ok) setRollbackEvents(evRes.events)
- setLoading(false)
+ if (evRes && evRes.ok) setRollbackEvents(evRes.events)
+ if (!isBackground) setLoading(false)
}, [siteId])
/* eslint-disable react-hooks/set-state-in-effect */
@@ -340,7 +345,7 @@ export default function SiteDetailPage() {
useEffect(() => {
const active = deployments.some(d => d.status === 'pending' || d.status === 'building' || d.status === 'deploying')
if (!active || previewMode) return
- const t = setInterval(load, 2000)
+ const t = setInterval(() => { void load(true) }, 2000)
return () => clearInterval(t)
}, [deployments, previewMode, load])