Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c7e9f03
fix(deploy,sites): treat deploying as an active state
Nov 4, 2026
7d35271
perf(ui): parallelize independent page bootstrap fetches
Nov 4, 2026
f8b11a5
docs(specs): audit hot paths and state machines, link follow-ups
Nov 4, 2026
5ded7ad
fix(deploy): atomic status transitions and canonical state predicates
Nov 4, 2026
450a280
fix(cici): explicit run state machine and boot reconciliation
Nov 4, 2026
a21e38b
fix(sites): fail stale cert provisioning and add manual retry
Nov 4, 2026
50635ba
perf(ui): dedupe site fetch and bound import concurrency
Nov 4, 2026
970b1f4
fix(deploy): reconcile orphaned in-flight deployments on boot
Nov 4, 2026
2954c7e
feat(monitoring): expose non-terminal state age gauge and alerts
Nov 4, 2026
ef81548
feat(mcp): validate site env var keys and cover e83 tools with tests
Nov 4, 2026
67ff0a6
chore(e90): consolidate gosec config and harden CI gates
Nov 4, 2026
4306598
feat(deploy): support rust, php, java, and ruby runtimes (e81)
Nov 4, 2026
9c05cca
docs(specs): record execution waves and reconcile epic status
Nov 4, 2026
0ad8090
docs(specs): update traceability matrix
Nov 4, 2026
d622873
fix(deploy,monitoring): exclude rolled_back from rollback candidates,…
Nov 4, 2026
212a1d8
fix(cici,sites): guard terminal state in finishRun and check duplicat…
Nov 4, 2026
bcf5993
fix(ui): quiet background polling in SiteDetailPage, guard boundedCon…
Nov 4, 2026
c5c2e7c
fix(deploy): define ErrInvalidTransition sentinel and log status hist…
Nov 4, 2026
7cb2bd2
fix(deploy): align ErrInvalidTransition message with legacy string match
Nov 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/test-build-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
26 changes: 25 additions & 1 deletion .gosec.json
Original file line number Diff line number Diff line change
@@ -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."
}
}
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
54 changes: 49 additions & 5 deletions components/cici/cici.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/http"
"regexp"
"strings"
"time"

"gopkg.in/yaml.v3"

Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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
}
Expand Down
145 changes: 145 additions & 0 deletions components/cici/reconcile_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading
Loading