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 @@ [![Site](https://img.shields.io/badge/Site-bigbase.click-4f46e5)](https://bigbase.click) ![License](https://img.shields.io/badge/License-MIT-yellow.svg) -![Version](https://img.shields.io/badge/version-2.89.0-blue.svg) +[![Version](https://img.shields.io/github/v/release/danielvm-git/bigbase?label=version&color=blue)](https://github.com/danielvm-git/bigbase/releases) ![Go](https://img.shields.io/badge/Go-1.26.3-00ADD8?logo=go) > 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])