diff --git a/docs/plans/workload-refactor.md b/docs/plans/workload-refactor.md new file mode 100644 index 0000000..fb70acf --- /dev/null +++ b/docs/plans/workload-refactor.md @@ -0,0 +1,197 @@ +# Workload Refactor — Compressed Plan + +Status: Draft, pre-implementation +Owner: alexei.dolgolyov +Date: 2026-05-07 + +## Goal + +Unify `Project`, `Stack`, and `StaticSite` under a single `Workload` primitive, and introduce a normalized `containers` index so every Tinyforge-managed container has one canonical row. This unblocks a global Containers view today and lets future workload kinds (cron jobs, one-shot tasks, databases-as-resource, functions) plug in without another tab/store/deployer branch. + +## Why this is the compressed plan + +The original 8-PR plan was designed for a live system with dual-writes and soak periods. Tinyforge has no production users yet, so all defenses against live runtime state collapse: no external label consumers, no third-party CI hitting webhook URLs, no orphaned containers to recover. Everything ships in 3 PRs against a clean slate. Solo-dev reversibility is preserved by branching, not by dual-write gymnastics. + +## Target architecture + +- `Workload` is the unifying primitive with `kind ∈ {project, stack, site, …}`. Each existing Project/Stack/StaticSite becomes a Workload row. +- `containers` is a normalized index: every Tinyforge-managed container has one row with `workload_id`, `workload_kind`, `role`, Docker container ID, host, state, last_seen. +- Optional `apps` table (thin nullable `app_id` on Workload) added empty; UI gated behind a feature flag, defer indefinitely until pull. +- Stable Docker labels: `tinyforge.workload.id`, `tinyforge.workload.kind`, `tinyforge.role`, `tinyforge.managed`. Legacy `tinyforge.project` / `tinyforge.stage` / `tinyforge.instance-id` are removed in the same wave. +- Global `/containers` UI route; per-workload container panel becomes a shared `` component reused by project, stack, and site detail pages. + +## Schema + +Appended to `internal/store/store.go::runMigrations()` as additive `CREATE TABLE` statements (idempotent via `CREATE TABLE IF NOT EXISTS`). + +```sql +CREATE TABLE IF NOT EXISTS workloads ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, -- 'project' | 'stack' | 'site' + ref_id TEXT NOT NULL, -- FK into projects/stacks/static_sites by kind + name TEXT NOT NULL, + app_id TEXT, -- nullable FK into apps.id + notification_url TEXT NOT NULL DEFAULT '', + notification_secret TEXT NOT NULL DEFAULT '', + webhook_secret TEXT NOT NULL DEFAULT '', + webhook_signing_secret TEXT NOT NULL DEFAULT '', + webhook_require_signature INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(kind, ref_id) +); +CREATE INDEX IF NOT EXISTS idx_workloads_app_id ON workloads(app_id); +CREATE INDEX IF NOT EXISTS idx_workloads_kind ON workloads(kind); + +CREATE TABLE IF NOT EXISTS containers ( + id TEXT PRIMARY KEY, + workload_id TEXT NOT NULL, + workload_kind TEXT NOT NULL, -- denormalized for filtered queries + role TEXT NOT NULL, -- stage name (project), service name (stack), '' (site) + container_id TEXT NOT NULL DEFAULT '', -- Docker ID, '' between create+start + image_ref TEXT NOT NULL DEFAULT '', + host TEXT NOT NULL DEFAULT 'local', + state TEXT NOT NULL DEFAULT '', -- running | stopped | failed | removing | missing + port INTEGER NOT NULL DEFAULT 0, + last_seen_at TEXT NOT NULL DEFAULT '', + extra_json TEXT NOT NULL DEFAULT '{}', -- {subdomain, npm_proxy_id, proxy_route_id, ...} + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_containers_workload ON containers(workload_id); +CREATE INDEX IF NOT EXISTS idx_containers_state ON containers(state); +CREATE INDEX IF NOT EXISTS idx_containers_container_id ON containers(container_id); + +CREATE TABLE IF NOT EXISTS apps ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +``` + +`extra_json` carries kind-specific fields (`subdomain`, `npm_proxy_id`, `proxy_route_id`) so the spine stays narrow. SQLite JSON1 is required for queries against `extra_json`; verify the driver in `go.mod` supports it before committing — fall back to dedicated columns if not. + +## PR 1 — Spine: schema, Workload package, reconciler + +Single PR, lands the data layer end-to-end. No dual-writes; project/stack/site CRUD writes directly to `workloads`. + +### New files + +- `internal/store/workloads.go` — `CreateWorkload`, `GetWorkloadByID`, `GetWorkloadByRef(kind, refID)`, `ListWorkloads`, `UpdateWorkload`, `DeleteWorkload`. +- `internal/store/containers.go` — `UpsertContainer`, `GetContainerByDockerID`, `ListContainersByWorkload`, `ListContainers(filter)`, `MarkContainerMissing`, new `ListProxyRoutes` (mirrors the join shape from `internal/store/instances.go::ListProxyRoutes`, reading `extra_json` via `json_extract`). +- `internal/store/apps.go` — minimal CRUD; not wired anywhere yet. +- `internal/workload/workload.go` — `Workload` interface (`ID`, `Kind`, `Name`, `Deploy`, `Stop`, `Start`, `Delete`, `Containers`). +- `internal/workload/adapters/project_adapter.go` — wraps `internal/deployer`. +- `internal/workload/adapters/stack_adapter.go` — wraps `internal/stack/manager.go`. +- `internal/workload/adapters/site_adapter.go` — wraps `internal/staticsite/manager.go`. +- `internal/reconciler/reconciler.go` — single writer to `containers`. Reads `docker ps --filter label=tinyforge.managed`, groups by `(workload.id, role)`, upserts rows, marks absent rows `state='missing'`. Boot-time one-shot run + 30s tick. +- `internal/reconciler/reconciler_test.go` — table-driven tests with a fake Docker client. + +### Modified files + +- `internal/store/store.go::runMigrations` — append the three `CREATE TABLE` statements (after line ~165 where the existing migrations end). +- `internal/store/models.go` — add `Workload`, `Container`, `App` structs. +- `internal/store/projects.go` — `CreateProject`, `UpdateProject`, `DeleteProject` wrap the write in `s.db.Begin()` and also write the matching `workloads` row. Webhook/notification secret setters update `workloads.webhook_secret` / `webhook_signing_secret` / `notification_secret` directly. +- `internal/store/stacks.go` — same Workload write on `CreateStack` / `UpdateStack` / `DeleteStack`. +- `internal/store/static_sites.go` — same. +- `internal/docker/client.go` — add label constants `LabelWorkloadID`, `LabelWorkloadKind`, `LabelRole`, `LabelManaged`. **Remove** the old `LabelProject`, `LabelStage`, `LabelInstanceID` writes from the deployer. +- `internal/deployer/deployer.go` (label injection ~line 388) — emit only the new labels. +- `internal/deployer/bluegreen.go` (~line 97) — same. +- `internal/stack/manager.go` — after `docker compose up`, stamp new labels on each compose-managed container via `docker container update --label-add`. Compose's own `com.docker.compose.service` becomes `role`. +- `internal/staticsite/manager.go` — stamp new labels at container start. +- `internal/store/instances.go` — **delete this file**. The deployer no longer creates instance rows; reconciler owns container state. +- `internal/api/instances.go` — **delete or alias** to `/api/containers` filtered by workload. Solo dev → delete is cleaner. +- `internal/api/proxies.go` — switch the `ListProxyRoutes` import to `containers.ListProxyRoutes`. +- `internal/api/docker.go::buildActiveImagesSet` (~line 251) — replace the `ListAllInstances` walk with a single `containers.image_ref` query. +- `internal/api/stale.go`, `internal/stale/scanner.go` — read from `containers` instead of `instances`. +- `internal/webhook/matcher.go` — query `workloads.webhook_secret` directly. +- `cmd/server/main.go` — start the reconciler goroutine after `store.New`. Drop any startup code that touched `instances`. + +### Tests + +- Extend `internal/store/store_test.go` with `TestCreateProjectAlsoCreatesWorkload`, `TestDeleteProjectCascadesWorkload`, `TestUpsertContainerIdempotent`, `TestListProxyRoutesShape`. +- New `internal/reconciler/reconciler_test.go` with a `dockerClient` interface and a fake — assert that a slice of `types.Container` produces the expected `containers` upserts. +- Run the existing test suite under `-race`. + +### Deliverable + +System builds, deploys a project end-to-end, deploys a stack end-to-end, deploys a static site end-to-end. `containers` table reflects reality after each deploy and after a 30s reconciler tick. The legacy `instances` table is gone. + +## PR 2 — API + frontend + +### New files + +- `internal/api/workloads.go` — `GET /api/workloads`, `GET /api/workloads/{id}`, `GET /api/workloads/{id}/containers`, `PATCH /api/workloads/{id}` (sets `app_id` and notification/webhook config). +- `internal/api/containers.go` — `GET /api/containers?workload_id=&kind=&state=&app_id=`, `GET /api/containers/{id}`. +- `internal/api/apps.go` — `GET /api/apps`, `POST /api/apps`, `PATCH /api/apps/{id}`, `DELETE /api/apps/{id}` (gated by settings flag `features.apps_grouping=true`). +- `web/src/routes/containers/+page.svelte` — global filterable table. Reuses table patterns from `web/src/routes/proxies/+page.svelte` and `web/src/routes/containers/stale/+page.svelte` (the existing `stale/` route stays untouched). +- `web/src/lib/components/WorkloadContainers.svelte` — shared container panel. Takes `workloadId` prop, hits `/api/workloads/{id}/containers`. Handles 1..N container rows. + +### Modified files + +- `internal/api/router.go` — register the new endpoints. Remove `/api/instances` registration. +- `web/src/routes/projects/[id]/+page.svelte` — replace the inline instance list with ``. +- `web/src/routes/stacks/[id]/+page.svelte` — same. +- `web/src/routes/sites/[id]/+page.svelte` — same. +- Top nav component (find under `web/src/lib/components/`) — insert a "Containers" tab between "Projects" and "Stacks". Existing tabs stay. +- `web/src/lib/api.ts` (or wherever API client functions live) — add `listWorkloads`, `getWorkload`, `listContainers`, `getContainer`, `listApps`. Remove instance-shaped helpers. +- `web/src/lib/types.ts` — add `Workload`, `Container`, `App` types. Remove `Instance` once unreferenced. + +### Deliverable + +User-visible: a `Containers` tab in the top nav showing every running container with kind/state/workload filters, links into the owning project/stack/site detail page, and a per-workload container panel that looks identical on all three detail pages. + +## PR 3 — Polish + optional Apps UI + +Defer indefinitely if no pull. Lands as a single PR when wanted. + +### Scope + +- Apps UI: `web/src/routes/apps/+page.svelte`, `[id]/+page.svelte`. Workload detail pages get an "App" dropdown to assign `app_id`. Gated by `features.apps_grouping=true` in settings. +- Drop any leftover dead code referencing `Instance` types. +- Documentation: update `CLAUDE.md` and `README.md` to describe the Workload model. +- Optional: consolidate `internal/deployer` and `internal/stack/manager` into a single orchestrator. **Out of scope for this refactor** — adapters wrap the existing kind-specific code and that's fine. Revisit only if the duplication starts hurting. + +## What's explicitly deferred + +- Deployer + stack-manager consolidation. +- Apps UI (schema added in PR 1, UI in PR 3 behind flag). +- Multi-host containers (`containers.host` exists but is always `'local'`). +- Workload-kind plugin model — the adapter registry has three hardcoded entries. +- Webhook secret handling for old per-project URLs that may already be in CI configs (no users yet → don't care). + +## Risks (compressed) + +- **SQLite JSON1 availability.** Verify the driver in `go.mod` supports `json_extract` before committing to `extra_json`. If not, hoist `subdomain`, `npm_proxy_id`, `proxy_route_id` to dedicated columns on `containers`. +- **`ListProxyRoutes` shape regression.** The new query reads from `containers` + `workloads` instead of `instances` + `projects` + `stages`. Worth a golden-output test before flipping `internal/api/proxies.go` over. +- **Stack containers and label stamping.** `docker container update --label-add` is required to label compose-managed containers post-up. If the local Docker engine version doesn't support it, fall back to relying on `com.docker.compose.project` + `com.docker.compose.service` for reconciler joins. +- **Boot-time backfill from `docker ps`.** First run needs to populate `containers` from currently-running containers using the legacy `tinyforge.instance-id` and `com.docker.compose.project` labels (since pre-refactor containers don't have the new labels). Solo-dev workaround: `docker compose down` test workloads, run the new binary against an empty Docker host, redeploy. + +## Concrete file paths + +Modified: +- `internal/store/store.go` (migrations at line ~75–165) +- `internal/store/projects.go`, `stacks.go`, `static_sites.go`, `models.go`, `store_test.go` +- `internal/docker/client.go` +- `internal/deployer/deployer.go` (~line 388), `internal/deployer/bluegreen.go` (~line 97) +- `internal/stack/manager.go`, `internal/staticsite/manager.go` +- `internal/api/router.go`, `proxies.go`, `docker.go` (`buildActiveImagesSet` at line 251), `stale.go` +- `internal/stale/scanner.go`, `internal/webhook/matcher.go` +- `cmd/server/main.go` +- `web/src/routes/projects/[id]/+page.svelte`, `stacks/[id]/+page.svelte`, `sites/[id]/+page.svelte` +- `web/src/lib/api.ts`, `web/src/lib/types.ts` +- Top nav component in `web/src/lib/components/` + +Created: +- `internal/store/workloads.go`, `containers.go`, `apps.go` +- `internal/workload/workload.go`, `adapters/project_adapter.go`, `adapters/stack_adapter.go`, `adapters/site_adapter.go` +- `internal/reconciler/reconciler.go`, `reconciler_test.go` +- `internal/api/workloads.go`, `containers.go`, `apps.go` +- `web/src/routes/containers/+page.svelte` +- `web/src/lib/components/WorkloadContainers.svelte` + +Deleted: +- `internal/store/instances.go` +- `internal/api/instances.go` diff --git a/internal/store/apps.go b/internal/store/apps.go new file mode 100644 index 0000000..b936359 --- /dev/null +++ b/internal/store/apps.go @@ -0,0 +1,110 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + + "github.com/google/uuid" +) + +const appColumns = `id, name, description, created_at, updated_at` + +func scanApp(scanner interface{ Scan(...any) error }) (App, error) { + var a App + err := scanner.Scan(&a.ID, &a.Name, &a.Description, &a.CreatedAt, &a.UpdatedAt) + return a, err +} + +// CreateApp inserts a new app row. Names must be unique. +func (s *Store) CreateApp(a App) (App, error) { + if a.ID == "" { + a.ID = uuid.New().String() + } + a.CreatedAt = Now() + a.UpdatedAt = a.CreatedAt + + _, err := s.db.Exec( + `INSERT INTO apps (`+appColumns+`) VALUES (?, ?, ?, ?, ?)`, + a.ID, a.Name, a.Description, a.CreatedAt, a.UpdatedAt, + ) + if err != nil { + return App{}, fmt.Errorf("insert app: %w", err) + } + return a, nil +} + +// GetAppByID returns an app by ID. +func (s *Store) GetAppByID(id string) (App, error) { + a, err := scanApp(s.db.QueryRow( + `SELECT `+appColumns+` FROM apps WHERE id = ?`, id, + )) + if errors.Is(err, sql.ErrNoRows) { + return App{}, fmt.Errorf("app %s: %w", id, ErrNotFound) + } + if err != nil { + return App{}, fmt.Errorf("query app: %w", err) + } + return a, nil +} + +// ListApps returns all apps, ordered by name. +func (s *Store) ListApps() ([]App, error) { + rows, err := s.db.Query( + `SELECT ` + appColumns + ` FROM apps ORDER BY name`, + ) + if err != nil { + return nil, fmt.Errorf("query apps: %w", err) + } + defer rows.Close() + + out := []App{} + for rows.Next() { + a, err := scanApp(rows) + if err != nil { + return nil, fmt.Errorf("scan app: %w", err) + } + out = append(out, a) + } + return out, rows.Err() +} + +// UpdateApp updates the mutable fields (name, description) of an app row. +func (s *Store) UpdateApp(a App) error { + a.UpdatedAt = Now() + result, err := s.db.Exec( + `UPDATE apps SET name=?, description=?, updated_at=? WHERE id=?`, + a.Name, a.Description, a.UpdatedAt, a.ID, + ) + if err != nil { + return fmt.Errorf("update app: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("app %s: %w", a.ID, ErrNotFound) + } + return nil +} + +// DeleteApp removes an app and clears its app_id from any workloads. +// Workloads survive — they just become unassigned. +func (s *Store) DeleteApp(id string) error { + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("begin delete app: %w", err) + } + defer tx.Rollback() + + if _, err := tx.Exec(`UPDATE workloads SET app_id = '' WHERE app_id = ?`, id); err != nil { + return fmt.Errorf("clear app_id on workloads: %w", err) + } + result, err := tx.Exec(`DELETE FROM apps WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete app: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("app %s: %w", id, ErrNotFound) + } + return tx.Commit() +} diff --git a/internal/store/apps_test.go b/internal/store/apps_test.go new file mode 100644 index 0000000..d632de8 --- /dev/null +++ b/internal/store/apps_test.go @@ -0,0 +1,77 @@ +package store + +import ( + "errors" + "testing" +) + +func TestCreateAndGetApp(t *testing.T) { + s := newTestStore(t) + + a, err := s.CreateApp(App{Name: "my-saas", Description: "All the things"}) + if err != nil { + t.Fatalf("CreateApp: %v", err) + } + if a.ID == "" { + t.Fatal("app ID should be set") + } + + got, err := s.GetAppByID(a.ID) + if err != nil { + t.Fatalf("GetAppByID: %v", err) + } + if got.Name != "my-saas" { + t.Fatalf("got name %q", got.Name) + } +} + +func TestUniqueAppName(t *testing.T) { + s := newTestStore(t) + + if _, err := s.CreateApp(App{Name: "dupe"}); err != nil { + t.Fatalf("first insert: %v", err) + } + if _, err := s.CreateApp(App{Name: "dupe"}); err == nil { + t.Fatal("expected UNIQUE name violation, got nil") + } +} + +func TestListApps(t *testing.T) { + s := newTestStore(t) + + s.CreateApp(App{Name: "bravo"}) + s.CreateApp(App{Name: "alpha"}) + + out, err := s.ListApps() + if err != nil { + t.Fatalf("ListApps: %v", err) + } + if len(out) != 2 { + t.Fatalf("expected 2 apps, got %d", len(out)) + } + if out[0].Name != "alpha" { + t.Fatalf("expected alpha first, got %q", out[0].Name) + } +} + +func TestDeleteAppClearsWorkloadAppID(t *testing.T) { + s := newTestStore(t) + + app, _ := s.CreateApp(App{Name: "doomed"}) + w, _ := s.CreateWorkload(Workload{ + Kind: "project", RefID: "p1", Name: "n", AppID: app.ID, + }) + + if err := s.DeleteApp(app.ID); err != nil { + t.Fatalf("DeleteApp: %v", err) + } + + got, _ := s.GetWorkloadByID(w.ID) + if got.AppID != "" { + t.Fatalf("expected workload app_id cleared, got %q", got.AppID) + } + + if _, err := s.GetAppByID(app.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected app NotFound after delete, got %v", err) + } +} diff --git a/internal/store/containers.go b/internal/store/containers.go new file mode 100644 index 0000000..47f5d73 --- /dev/null +++ b/internal/store/containers.go @@ -0,0 +1,307 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + "strings" + + "github.com/google/uuid" +) + +const containerColumns = `id, workload_id, workload_kind, role, container_id, + image_ref, image_tag, host, state, port, + subdomain, proxy_route_id, npm_proxy_id, + last_seen_at, extra_json, created_at, updated_at` + +func scanContainer(scanner interface{ Scan(...any) error }) (Container, error) { + var c Container + err := scanner.Scan( + &c.ID, &c.WorkloadID, &c.WorkloadKind, &c.Role, &c.ContainerID, + &c.ImageRef, &c.ImageTag, &c.Host, &c.State, &c.Port, + &c.Subdomain, &c.ProxyRouteID, &c.NpmProxyID, + &c.LastSeenAt, &c.ExtraJSON, &c.CreatedAt, &c.UpdatedAt, + ) + return c, err +} + +// CreateContainer inserts a new container row, generating an ID if none provided. +// Use this when scheduling a new container (before Docker create returns an ID). +func (s *Store) CreateContainer(c Container) (Container, error) { + if c.ID == "" { + c.ID = uuid.New().String() + } + if c.Host == "" { + c.Host = "local" + } + if c.ExtraJSON == "" { + c.ExtraJSON = "{}" + } + c.CreatedAt = Now() + c.UpdatedAt = c.CreatedAt + + _, err := s.db.Exec( + `INSERT INTO containers (`+containerColumns+`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + c.ID, c.WorkloadID, c.WorkloadKind, c.Role, c.ContainerID, + c.ImageRef, c.ImageTag, c.Host, c.State, c.Port, + c.Subdomain, c.ProxyRouteID, c.NpmProxyID, + c.LastSeenAt, c.ExtraJSON, c.CreatedAt, c.UpdatedAt, + ) + if err != nil { + return Container{}, fmt.Errorf("insert container: %w", err) + } + return c, nil +} + +// UpsertContainer is the reconciler's primary write path. It updates an +// existing row (matched by ID) or inserts a new one. Caller is responsible +// for setting ID — use container_id-based lookup before calling this. +func (s *Store) UpsertContainer(c Container) error { + if c.ID == "" { + return fmt.Errorf("UpsertContainer: ID is required") + } + if c.Host == "" { + c.Host = "local" + } + if c.ExtraJSON == "" { + c.ExtraJSON = "{}" + } + c.UpdatedAt = Now() + if c.CreatedAt == "" { + c.CreatedAt = c.UpdatedAt + } + + // SQLite UPSERT — INSERT...ON CONFLICT(id) DO UPDATE. + _, err := s.db.Exec( + `INSERT INTO containers (`+containerColumns+`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + workload_id=excluded.workload_id, + workload_kind=excluded.workload_kind, + role=excluded.role, + container_id=excluded.container_id, + image_ref=excluded.image_ref, + image_tag=excluded.image_tag, + host=excluded.host, + state=excluded.state, + port=excluded.port, + subdomain=excluded.subdomain, + proxy_route_id=excluded.proxy_route_id, + npm_proxy_id=excluded.npm_proxy_id, + last_seen_at=excluded.last_seen_at, + extra_json=excluded.extra_json, + updated_at=excluded.updated_at`, + c.ID, c.WorkloadID, c.WorkloadKind, c.Role, c.ContainerID, + c.ImageRef, c.ImageTag, c.Host, c.State, c.Port, + c.Subdomain, c.ProxyRouteID, c.NpmProxyID, + c.LastSeenAt, c.ExtraJSON, c.CreatedAt, c.UpdatedAt, + ) + if err != nil { + return fmt.Errorf("upsert container: %w", err) + } + return nil +} + +// GetContainerByID returns a single container row. +func (s *Store) GetContainerByID(id string) (Container, error) { + c, err := scanContainer(s.db.QueryRow( + `SELECT `+containerColumns+` FROM containers WHERE id = ?`, id, + )) + if errors.Is(err, sql.ErrNoRows) { + return Container{}, fmt.Errorf("container %s: %w", id, ErrNotFound) + } + if err != nil { + return Container{}, fmt.Errorf("query container: %w", err) + } + return c, nil +} + +// GetContainerByDockerID looks up a container row by its Docker container ID. +// The reconciler uses this to decide between insert (new container) and update. +func (s *Store) GetContainerByDockerID(dockerID string) (Container, error) { + if dockerID == "" { + return Container{}, fmt.Errorf("empty container_id: %w", ErrNotFound) + } + c, err := scanContainer(s.db.QueryRow( + `SELECT `+containerColumns+` FROM containers WHERE container_id = ?`, dockerID, + )) + if errors.Is(err, sql.ErrNoRows) { + return Container{}, ErrNotFound + } + if err != nil { + return Container{}, fmt.Errorf("query container by docker id: %w", err) + } + return c, nil +} + +// ListContainersByWorkload returns all containers for a given workload, newest first. +func (s *Store) ListContainersByWorkload(workloadID string) ([]Container, error) { + rows, err := s.db.Query( + `SELECT `+containerColumns+` FROM containers + WHERE workload_id = ? ORDER BY created_at DESC`, + workloadID, + ) + if err != nil { + return nil, fmt.Errorf("query containers by workload: %w", err) + } + defer rows.Close() + + out := []Container{} + for rows.Next() { + c, err := scanContainer(rows) + if err != nil { + return nil, fmt.Errorf("scan container: %w", err) + } + out = append(out, c) + } + return out, rows.Err() +} + +// ContainerFilter narrows ListContainers. Empty fields mean "no filter". +type ContainerFilter struct { + WorkloadID string + WorkloadKind string + State string + AppID string +} + +// ListContainers returns all containers matching the filter, newest first. +// AppID joins through workloads. +func (s *Store) ListContainers(f ContainerFilter) ([]Container, error) { + var ( + where []string + args []any + ) + if f.WorkloadID != "" { + where = append(where, "c.workload_id = ?") + args = append(args, f.WorkloadID) + } + if f.WorkloadKind != "" { + where = append(where, "c.workload_kind = ?") + args = append(args, f.WorkloadKind) + } + if f.State != "" { + where = append(where, "c.state = ?") + args = append(args, f.State) + } + var query string + if f.AppID != "" { + query = `SELECT ` + prefixCols(containerColumns, "c.") + ` + FROM containers c JOIN workloads w ON w.id = c.workload_id + WHERE w.app_id = ?` + args = append([]any{f.AppID}, args...) + if len(where) > 0 { + query += " AND " + strings.Join(where, " AND ") + } + query += " ORDER BY c.created_at DESC" + } else { + query = `SELECT ` + prefixCols(containerColumns, "c.") + ` FROM containers c` + if len(where) > 0 { + query += " WHERE " + strings.Join(where, " AND ") + } + query += " ORDER BY c.created_at DESC" + } + + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("query containers: %w", err) + } + defer rows.Close() + + out := []Container{} + for rows.Next() { + c, err := scanContainer(rows) + if err != nil { + return nil, fmt.Errorf("scan container: %w", err) + } + out = append(out, c) + } + return out, rows.Err() +} + +// UpdateContainer replaces all mutable fields on an existing container row. +// Use this from the deployer when proxy / subdomain assignments change. +func (s *Store) UpdateContainer(c Container) error { + c.UpdatedAt = Now() + if c.ExtraJSON == "" { + c.ExtraJSON = "{}" + } + result, err := s.db.Exec( + `UPDATE containers SET workload_id=?, workload_kind=?, role=?, container_id=?, + image_ref=?, image_tag=?, host=?, state=?, port=?, + subdomain=?, proxy_route_id=?, npm_proxy_id=?, + last_seen_at=?, extra_json=?, updated_at=? + WHERE id=?`, + c.WorkloadID, c.WorkloadKind, c.Role, c.ContainerID, + c.ImageRef, c.ImageTag, c.Host, c.State, c.Port, + c.Subdomain, c.ProxyRouteID, c.NpmProxyID, + c.LastSeenAt, c.ExtraJSON, c.UpdatedAt, c.ID, + ) + if err != nil { + return fmt.Errorf("update container: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("container %s: %w", c.ID, ErrNotFound) + } + return nil +} + +// UpdateContainerState sets only the state and last_seen_at fields. +// Used by the reconciler for rapid status flips without a full row read. +func (s *Store) UpdateContainerState(id, state string) error { + ts := Now() + result, err := s.db.Exec( + `UPDATE containers SET state=?, last_seen_at=?, updated_at=? WHERE id=?`, + state, ts, ts, id, + ) + if err != nil { + return fmt.Errorf("update container state: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("container %s: %w", id, ErrNotFound) + } + return nil +} + +// MarkContainerMissing flips state to 'missing' for a container that the +// reconciler can no longer find in `docker ps`. Idempotent. +func (s *Store) MarkContainerMissing(id string) error { + return s.UpdateContainerState(id, "missing") +} + +// DeleteContainer removes a container row. Use when the underlying Docker +// container has been deleted and we want to forget it entirely (vs. marking missing). +func (s *Store) DeleteContainer(id string) error { + result, err := s.db.Exec(`DELETE FROM containers WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete container: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("container %s: %w", id, ErrNotFound) + } + return nil +} + +// DeleteContainersByWorkload removes every container row for a workload. +// Used when a workload is deleted and we want to drop its container index entries. +func (s *Store) DeleteContainersByWorkload(workloadID string) error { + _, err := s.db.Exec(`DELETE FROM containers WHERE workload_id = ?`, workloadID) + if err != nil { + return fmt.Errorf("delete containers by workload: %w", err) + } + return nil +} + +// prefixCols rewrites a comma-separated column list to use a table alias prefix. +// Used by ListContainers when joining containers (alias `c`) to workloads. +func prefixCols(cols, prefix string) string { + parts := strings.Split(cols, ",") + for i, p := range parts { + parts[i] = prefix + strings.TrimSpace(p) + } + return strings.Join(parts, ", ") +} diff --git a/internal/store/containers_test.go b/internal/store/containers_test.go new file mode 100644 index 0000000..75ffb9b --- /dev/null +++ b/internal/store/containers_test.go @@ -0,0 +1,231 @@ +package store + +import ( + "errors" + "testing" +) + +func TestCreateAndGetContainer(t *testing.T) { + s := newTestStore(t) + + c, err := s.CreateContainer(Container{ + WorkloadID: "wl-1", WorkloadKind: "project", Role: "prod", + ContainerID: "abc123", ImageRef: "nginx:1", ImageTag: "1", + State: "running", Port: 80, Subdomain: "prod-app", + }) + if err != nil { + t.Fatalf("CreateContainer: %v", err) + } + if c.ID == "" { + t.Fatal("container ID should be set") + } + if c.Host != "local" { + t.Fatalf("default host should be 'local', got %q", c.Host) + } + if c.ExtraJSON != "{}" { + t.Fatalf("default extra_json should be '{}', got %q", c.ExtraJSON) + } + + got, err := s.GetContainerByID(c.ID) + if err != nil { + t.Fatalf("GetContainerByID: %v", err) + } + if got.ContainerID != "abc123" || got.Subdomain != "prod-app" { + t.Fatalf("got %+v", got) + } +} + +func TestUpsertContainerInsert(t *testing.T) { + s := newTestStore(t) + + if err := s.UpsertContainer(Container{ + ID: "fixed-id", WorkloadID: "wl-1", WorkloadKind: "project", + Role: "dev", State: "running", + }); err != nil { + t.Fatalf("UpsertContainer insert: %v", err) + } + + got, err := s.GetContainerByID("fixed-id") + if err != nil { + t.Fatalf("GetContainerByID: %v", err) + } + if got.State != "running" { + t.Fatalf("got state %q", got.State) + } +} + +func TestUpsertContainerUpdate(t *testing.T) { + s := newTestStore(t) + + _ = s.UpsertContainer(Container{ + ID: "fixed-id", WorkloadID: "wl-1", WorkloadKind: "project", + Role: "dev", State: "starting", + }) + + if err := s.UpsertContainer(Container{ + ID: "fixed-id", WorkloadID: "wl-1", WorkloadKind: "project", + Role: "dev", State: "running", Port: 8080, + }); err != nil { + t.Fatalf("UpsertContainer update: %v", err) + } + + got, _ := s.GetContainerByID("fixed-id") + if got.State != "running" || got.Port != 8080 { + t.Fatalf("upsert did not update fields: %+v", got) + } +} + +func TestUpsertContainerRequiresID(t *testing.T) { + s := newTestStore(t) + if err := s.UpsertContainer(Container{WorkloadID: "wl-1"}); err == nil { + t.Fatal("UpsertContainer without ID should fail") + } +} + +func TestGetContainerByDockerID(t *testing.T) { + s := newTestStore(t) + + c, _ := s.CreateContainer(Container{ + WorkloadID: "wl-1", WorkloadKind: "project", Role: "prod", + ContainerID: "docker-xyz", State: "running", + }) + + got, err := s.GetContainerByDockerID("docker-xyz") + if err != nil { + t.Fatalf("GetContainerByDockerID: %v", err) + } + if got.ID != c.ID { + t.Fatalf("got container %s, want %s", got.ID, c.ID) + } + + if _, err := s.GetContainerByDockerID(""); !errors.Is(err, ErrNotFound) { + t.Fatalf("empty docker id should be NotFound, got %v", err) + } + if _, err := s.GetContainerByDockerID("ghost"); !errors.Is(err, ErrNotFound) { + t.Fatalf("unknown docker id should be NotFound, got %v", err) + } +} + +func TestListContainersByWorkload(t *testing.T) { + s := newTestStore(t) + + s.CreateContainer(Container{WorkloadID: "wl-1", WorkloadKind: "project", Role: "a"}) + s.CreateContainer(Container{WorkloadID: "wl-1", WorkloadKind: "project", Role: "b"}) + s.CreateContainer(Container{WorkloadID: "wl-2", WorkloadKind: "project", Role: "c"}) + + out, err := s.ListContainersByWorkload("wl-1") + if err != nil { + t.Fatalf("ListContainersByWorkload: %v", err) + } + if len(out) != 2 { + t.Fatalf("expected 2 containers, got %d", len(out)) + } +} + +func TestListContainersWithFilter(t *testing.T) { + s := newTestStore(t) + + s.CreateContainer(Container{WorkloadID: "wl-1", WorkloadKind: "project", State: "running"}) + s.CreateContainer(Container{WorkloadID: "wl-2", WorkloadKind: "stack", State: "running"}) + s.CreateContainer(Container{WorkloadID: "wl-3", WorkloadKind: "site", State: "stopped"}) + + out, err := s.ListContainers(ContainerFilter{WorkloadKind: "project"}) + if err != nil { + t.Fatalf("ListContainers kind filter: %v", err) + } + if len(out) != 1 || out[0].WorkloadKind != "project" { + t.Fatalf("kind filter wrong: %+v", out) + } + + out, err = s.ListContainers(ContainerFilter{State: "running"}) + if err != nil { + t.Fatalf("ListContainers state filter: %v", err) + } + if len(out) != 2 { + t.Fatalf("expected 2 running, got %d", len(out)) + } + + out, err = s.ListContainers(ContainerFilter{}) + if err != nil { + t.Fatalf("ListContainers no filter: %v", err) + } + if len(out) != 3 { + t.Fatalf("expected 3 with no filter, got %d", len(out)) + } +} + +func TestListContainersByApp(t *testing.T) { + s := newTestStore(t) + + app, _ := s.CreateApp(App{Name: "my-saas"}) + w1, _ := s.CreateWorkload(Workload{Kind: "project", RefID: "p1", Name: "web", AppID: app.ID}) + w2, _ := s.CreateWorkload(Workload{Kind: "stack", RefID: "s1", Name: "worker", AppID: app.ID}) + wOther, _ := s.CreateWorkload(Workload{Kind: "project", RefID: "p2", Name: "other"}) + + s.CreateContainer(Container{WorkloadID: w1.ID, WorkloadKind: "project"}) + s.CreateContainer(Container{WorkloadID: w2.ID, WorkloadKind: "stack"}) + s.CreateContainer(Container{WorkloadID: wOther.ID, WorkloadKind: "project"}) + + out, err := s.ListContainers(ContainerFilter{AppID: app.ID}) + if err != nil { + t.Fatalf("ListContainers AppID: %v", err) + } + if len(out) != 2 { + t.Fatalf("expected 2 containers in app, got %d", len(out)) + } +} + +func TestUpdateContainerState(t *testing.T) { + s := newTestStore(t) + + c, _ := s.CreateContainer(Container{ + WorkloadID: "wl-1", WorkloadKind: "project", State: "starting", + }) + + if err := s.UpdateContainerState(c.ID, "running"); err != nil { + t.Fatalf("UpdateContainerState: %v", err) + } + got, _ := s.GetContainerByID(c.ID) + if got.State != "running" { + t.Fatalf("got state %q", got.State) + } + if got.LastSeenAt == "" { + t.Fatal("last_seen_at should have been bumped") + } +} + +func TestMarkContainerMissing(t *testing.T) { + s := newTestStore(t) + + c, _ := s.CreateContainer(Container{ + WorkloadID: "wl-1", WorkloadKind: "project", State: "running", + }) + if err := s.MarkContainerMissing(c.ID); err != nil { + t.Fatalf("MarkContainerMissing: %v", err) + } + got, _ := s.GetContainerByID(c.ID) + if got.State != "missing" { + t.Fatalf("got state %q, want missing", got.State) + } +} + +func TestDeleteContainersByWorkload(t *testing.T) { + s := newTestStore(t) + + s.CreateContainer(Container{WorkloadID: "wl-1", WorkloadKind: "project"}) + s.CreateContainer(Container{WorkloadID: "wl-1", WorkloadKind: "project"}) + s.CreateContainer(Container{WorkloadID: "wl-2", WorkloadKind: "project"}) + + if err := s.DeleteContainersByWorkload("wl-1"); err != nil { + t.Fatalf("DeleteContainersByWorkload: %v", err) + } + + out, _ := s.ListContainersByWorkload("wl-1") + if len(out) != 0 { + t.Fatalf("expected 0 after delete, got %d", len(out)) + } + out, _ = s.ListContainersByWorkload("wl-2") + if len(out) != 1 { + t.Fatalf("untouched workload should still have 1, got %d", len(out)) + } +} diff --git a/internal/store/models.go b/internal/store/models.go index 6b81551..7b9fe49 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -328,3 +328,65 @@ type EventLog struct { CreatedAt string `json:"created_at"` } +// WorkloadKind enumerates the kinds of things that own containers. +// Each kind has a corresponding row in projects/stacks/static_sites referenced via Workload.RefID. +type WorkloadKind string + +const ( + WorkloadKindProject WorkloadKind = "project" + WorkloadKindStack WorkloadKind = "stack" + WorkloadKindSite WorkloadKind = "site" +) + +// Workload is the unifying primitive that abstracts Project, Stack, and StaticSite. +// Each row is paired with exactly one project/stack/site via (Kind, RefID). +// Notification + webhook config moves here so it lives in one place across kinds. +type Workload struct { + ID string `json:"id"` + Kind string `json:"kind"` // project | stack | site + RefID string `json:"ref_id"` + Name string `json:"name"` + AppID string `json:"app_id"` // nullable; "" = unassigned + NotificationURL string `json:"notification_url"` + NotificationSecret string `json:"-"` // never serialized + WebhookSecret string `json:"-"` // URL-identifier secret; never serialized + WebhookSigningSecret string `json:"-"` // HMAC key; never serialized + WebhookRequireSignature bool `json:"webhook_require_signature"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// Container is the normalized index of every Tinyforge-managed container. +// Replaces the project-specific Instance table after migration. Subdomain/ +// proxy fields are hoisted as first-class columns because ListProxyRoutes, +// stale detection, and dashboard queries filter on them frequently. +type Container struct { + ID string `json:"id"` + WorkloadID string `json:"workload_id"` + WorkloadKind string `json:"workload_kind"` // denormalized for filtered queries + Role string `json:"role"` // stage name (project), service name (stack), '' (site) + ContainerID string `json:"container_id"` // Docker container ID; '' between create+start + ImageRef string `json:"image_ref"` // "image:tag" as scheduled + ImageTag string `json:"image_tag"` // just the tag, for ListProxyRoutes + Host string `json:"host"` + State string `json:"state"` // running | stopped | failed | removing | missing + Port int `json:"port"` + Subdomain string `json:"subdomain"` + ProxyRouteID string `json:"proxy_route_id"` + NpmProxyID int `json:"npm_proxy_id"` + LastSeenAt string `json:"last_seen_at"` + ExtraJSON string `json:"extra_json"` // {} default; reserved for kind-specific forward-compat + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// App is an optional grouping of workloads (e.g., "my-saas" = web project + worker stack + redis stack). +// Schema lives here from day one so future UI work is unblocked, but no UI is wired in v1. +type App struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + diff --git a/internal/store/store.go b/internal/store/store.go index 8913233..498d45e 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -179,6 +179,60 @@ func (s *Store) runMigrations() error { `CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_received_at ON webhook_deliveries(received_at)`, } + // Workload refactor tables (2026-05-09). Workload is the unifying primitive + // over Project / Stack / StaticSite; Container is the normalized index of + // every Tinyforge-managed container; Apps is an optional grouping. These + // live alongside (not inside) the schema constant so existing databases + // pick them up on restart. + workloadTables := []string{ + `CREATE TABLE IF NOT EXISTS workloads ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + ref_id TEXT NOT NULL, + name TEXT NOT NULL, + app_id TEXT NOT NULL DEFAULT '', + notification_url TEXT NOT NULL DEFAULT '', + notification_secret TEXT NOT NULL DEFAULT '', + webhook_secret TEXT NOT NULL DEFAULT '', + webhook_signing_secret TEXT NOT NULL DEFAULT '', + webhook_require_signature INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(kind, ref_id) + )`, + `CREATE TABLE IF NOT EXISTS containers ( + id TEXT PRIMARY KEY, + workload_id TEXT NOT NULL, + workload_kind TEXT NOT NULL, + role TEXT NOT NULL DEFAULT '', + container_id TEXT NOT NULL DEFAULT '', + image_ref TEXT NOT NULL DEFAULT '', + image_tag TEXT NOT NULL DEFAULT '', + host TEXT NOT NULL DEFAULT 'local', + state TEXT NOT NULL DEFAULT '', + port INTEGER NOT NULL DEFAULT 0, + subdomain TEXT NOT NULL DEFAULT '', + proxy_route_id TEXT NOT NULL DEFAULT '', + npm_proxy_id INTEGER NOT NULL DEFAULT 0, + last_seen_at TEXT NOT NULL DEFAULT '', + extra_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )`, + `CREATE TABLE IF NOT EXISTS apps ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )`, + } + for _, t := range workloadTables { + if _, err := s.db.Exec(t); err != nil { + return fmt.Errorf("create workload table: %w", err) + } + } + // Additive stack tables (2026-04-16). Created here rather than in the // schema constant so older databases pick them up on restart. statsTables := []string{ @@ -290,6 +344,15 @@ func (s *Store) runMigrations() error { `CREATE INDEX IF NOT EXISTS idx_container_stats_container_ts ON container_stats_samples(container_id, ts)`, `CREATE INDEX IF NOT EXISTS idx_container_stats_ts ON container_stats_samples(ts)`, `CREATE INDEX IF NOT EXISTS idx_system_stats_ts ON system_stats_samples(ts)`, + // Workload refactor indexes (2026-05-09). + `CREATE INDEX IF NOT EXISTS idx_workloads_kind ON workloads(kind)`, + `CREATE INDEX IF NOT EXISTS idx_workloads_app_id ON workloads(app_id) WHERE app_id != ''`, + `CREATE INDEX IF NOT EXISTS idx_workloads_ref ON workloads(kind, ref_id)`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_workloads_webhook_secret ON workloads(webhook_secret) WHERE webhook_secret != ''`, + `CREATE INDEX IF NOT EXISTS idx_containers_workload ON containers(workload_id)`, + `CREATE INDEX IF NOT EXISTS idx_containers_state ON containers(state)`, + `CREATE INDEX IF NOT EXISTS idx_containers_container_id ON containers(container_id) WHERE container_id != ''`, + `CREATE INDEX IF NOT EXISTS idx_containers_kind ON containers(workload_kind)`, } for _, idx := range indexes { if _, err := s.db.Exec(idx); err != nil { diff --git a/internal/store/workloads.go b/internal/store/workloads.go new file mode 100644 index 0000000..ed2a27a --- /dev/null +++ b/internal/store/workloads.go @@ -0,0 +1,192 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + + "github.com/google/uuid" +) + +const workloadColumns = `id, kind, ref_id, name, app_id, + notification_url, notification_secret, + webhook_secret, webhook_signing_secret, webhook_require_signature, + created_at, updated_at` + +func scanWorkload(scanner interface{ Scan(...any) error }) (Workload, error) { + var w Workload + err := scanner.Scan( + &w.ID, &w.Kind, &w.RefID, &w.Name, &w.AppID, + &w.NotificationURL, &w.NotificationSecret, + &w.WebhookSecret, &w.WebhookSigningSecret, &w.WebhookRequireSignature, + &w.CreatedAt, &w.UpdatedAt, + ) + return w, err +} + +// CreateWorkload inserts a new workload row. The (Kind, RefID) pair must be +// unique; the caller is responsible for matching this to a project/stack/site. +func (s *Store) CreateWorkload(w Workload) (Workload, error) { + if w.ID == "" { + w.ID = uuid.New().String() + } + if w.AppID == "" { + w.AppID = "" + } + w.CreatedAt = Now() + w.UpdatedAt = w.CreatedAt + + _, err := s.db.Exec( + `INSERT INTO workloads (`+workloadColumns+`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + w.ID, w.Kind, w.RefID, w.Name, w.AppID, + w.NotificationURL, w.NotificationSecret, + w.WebhookSecret, w.WebhookSigningSecret, boolToInt(w.WebhookRequireSignature), + w.CreatedAt, w.UpdatedAt, + ) + if err != nil { + return Workload{}, fmt.Errorf("insert workload: %w", err) + } + return w, nil +} + +// GetWorkloadByID returns a single workload by its ID. +func (s *Store) GetWorkloadByID(id string) (Workload, error) { + w, err := scanWorkload(s.db.QueryRow( + `SELECT `+workloadColumns+` FROM workloads WHERE id = ?`, id, + )) + if errors.Is(err, sql.ErrNoRows) { + return Workload{}, fmt.Errorf("workload %s: %w", id, ErrNotFound) + } + if err != nil { + return Workload{}, fmt.Errorf("query workload: %w", err) + } + return w, nil +} + +// GetWorkloadByRef returns the workload paired with a given (kind, ref_id). +// Returns ErrNotFound if the project/stack/site has no workload row yet +// (which means the boot-time backfill hasn't run, or the kind/ref pair is wrong). +func (s *Store) GetWorkloadByRef(kind WorkloadKind, refID string) (Workload, error) { + w, err := scanWorkload(s.db.QueryRow( + `SELECT `+workloadColumns+` FROM workloads WHERE kind = ? AND ref_id = ?`, + string(kind), refID, + )) + if errors.Is(err, sql.ErrNoRows) { + return Workload{}, fmt.Errorf("workload (%s,%s): %w", kind, refID, ErrNotFound) + } + if err != nil { + return Workload{}, fmt.Errorf("query workload by ref: %w", err) + } + return w, nil +} + +// GetWorkloadByWebhookSecret looks up a workload by its inbound webhook URL secret. +// Returns ErrNotFound when no match — used by the webhook router. +func (s *Store) GetWorkloadByWebhookSecret(secret string) (Workload, error) { + if secret == "" { + return Workload{}, fmt.Errorf("empty secret: %w", ErrNotFound) + } + w, err := scanWorkload(s.db.QueryRow( + `SELECT `+workloadColumns+` FROM workloads WHERE webhook_secret = ?`, secret, + )) + if errors.Is(err, sql.ErrNoRows) { + return Workload{}, ErrNotFound + } + if err != nil { + return Workload{}, fmt.Errorf("query workload by webhook secret: %w", err) + } + return w, nil +} + +// ListWorkloads returns all workloads, optionally filtered by kind. Pass +// empty string to get every workload regardless of kind. +func (s *Store) ListWorkloads(kind WorkloadKind) ([]Workload, error) { + var rows *sql.Rows + var err error + if kind == "" { + rows, err = s.db.Query( + `SELECT ` + workloadColumns + ` FROM workloads ORDER BY name`, + ) + } else { + rows, err = s.db.Query( + `SELECT `+workloadColumns+` FROM workloads WHERE kind = ? ORDER BY name`, + string(kind), + ) + } + if err != nil { + return nil, fmt.Errorf("query workloads: %w", err) + } + defer rows.Close() + + out := []Workload{} + for rows.Next() { + w, err := scanWorkload(rows) + if err != nil { + return nil, fmt.Errorf("scan workload: %w", err) + } + out = append(out, w) + } + return out, rows.Err() +} + +// UpdateWorkload updates the mutable fields of a workload (name, app_id, +// notification config, webhook config). Kind and RefID are immutable post-create. +func (s *Store) UpdateWorkload(w Workload) error { + w.UpdatedAt = Now() + result, err := s.db.Exec( + `UPDATE workloads SET name=?, app_id=?, + notification_url=?, notification_secret=?, + webhook_secret=?, webhook_signing_secret=?, webhook_require_signature=?, + updated_at=? + WHERE id=?`, + w.Name, w.AppID, + w.NotificationURL, w.NotificationSecret, + w.WebhookSecret, w.WebhookSigningSecret, boolToInt(w.WebhookRequireSignature), + w.UpdatedAt, w.ID, + ) + if err != nil { + return fmt.Errorf("update workload: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("workload %s: %w", w.ID, ErrNotFound) + } + return nil +} + +// DeleteWorkload removes a workload row. Cascading deletes for the matching +// project/stack/site row stay with the kind-specific Delete functions; this +// only removes the workload entry. +func (s *Store) DeleteWorkload(id string) error { + result, err := s.db.Exec(`DELETE FROM workloads WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete workload: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("workload %s: %w", id, ErrNotFound) + } + return nil +} + +// DeleteWorkloadByRef removes the workload paired with a given (kind, ref_id). +// Idempotent — returns nil if no row exists, since the kind-specific Delete +// callers don't always know whether a workload row was created. +func (s *Store) DeleteWorkloadByRef(kind WorkloadKind, refID string) error { + _, err := s.db.Exec( + `DELETE FROM workloads WHERE kind = ? AND ref_id = ?`, + string(kind), refID, + ) + if err != nil { + return fmt.Errorf("delete workload by ref: %w", err) + } + return nil +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} diff --git a/internal/store/workloads_test.go b/internal/store/workloads_test.go new file mode 100644 index 0000000..2a53b13 --- /dev/null +++ b/internal/store/workloads_test.go @@ -0,0 +1,150 @@ +package store + +import ( + "errors" + "testing" +) + +func TestCreateAndGetWorkload(t *testing.T) { + s := newTestStore(t) + + w, err := s.CreateWorkload(Workload{ + Kind: string(WorkloadKindProject), RefID: "proj-1", Name: "test-workload", + }) + if err != nil { + t.Fatalf("CreateWorkload: %v", err) + } + if w.ID == "" { + t.Fatal("workload ID should be set") + } + + got, err := s.GetWorkloadByID(w.ID) + if err != nil { + t.Fatalf("GetWorkloadByID: %v", err) + } + if got.Name != "test-workload" || got.Kind != "project" || got.RefID != "proj-1" { + t.Fatalf("got %+v, want kind=project ref=proj-1 name=test-workload", got) + } +} + +func TestGetWorkloadByRef(t *testing.T) { + s := newTestStore(t) + + _, err := s.CreateWorkload(Workload{ + Kind: string(WorkloadKindStack), RefID: "stack-42", Name: "compose-app", + }) + if err != nil { + t.Fatalf("CreateWorkload: %v", err) + } + + got, err := s.GetWorkloadByRef(WorkloadKindStack, "stack-42") + if err != nil { + t.Fatalf("GetWorkloadByRef: %v", err) + } + if got.Name != "compose-app" { + t.Fatalf("got name %q, want compose-app", got.Name) + } + + if _, err := s.GetWorkloadByRef(WorkloadKindProject, "stack-42"); !errors.Is(err, ErrNotFound) { + t.Fatalf("wrong-kind lookup should be NotFound, got %v", err) + } +} + +func TestUniqueKindRefID(t *testing.T) { + s := newTestStore(t) + + if _, err := s.CreateWorkload(Workload{Kind: "project", RefID: "p1", Name: "a"}); err != nil { + t.Fatalf("first insert: %v", err) + } + if _, err := s.CreateWorkload(Workload{Kind: "project", RefID: "p1", Name: "dupe"}); err == nil { + t.Fatal("expected UNIQUE(kind, ref_id) violation, got nil") + } +} + +func TestUpdateWorkload(t *testing.T) { + s := newTestStore(t) + + w, _ := s.CreateWorkload(Workload{Kind: "project", RefID: "p1", Name: "original"}) + w.Name = "renamed" + w.AppID = "app-1" + w.WebhookRequireSignature = true + if err := s.UpdateWorkload(w); err != nil { + t.Fatalf("UpdateWorkload: %v", err) + } + + got, _ := s.GetWorkloadByID(w.ID) + if got.Name != "renamed" { + t.Fatalf("name not updated: got %q", got.Name) + } + if got.AppID != "app-1" { + t.Fatalf("app_id not updated: got %q", got.AppID) + } + if !got.WebhookRequireSignature { + t.Fatalf("webhook_require_signature not updated") + } +} + +func TestGetWorkloadByWebhookSecret(t *testing.T) { + s := newTestStore(t) + + w, _ := s.CreateWorkload(Workload{ + Kind: "project", RefID: "p1", Name: "n", WebhookSecret: "deadbeef", + }) + + got, err := s.GetWorkloadByWebhookSecret("deadbeef") + if err != nil { + t.Fatalf("GetWorkloadByWebhookSecret: %v", err) + } + if got.ID != w.ID { + t.Fatalf("got workload %s, want %s", got.ID, w.ID) + } + + if _, err := s.GetWorkloadByWebhookSecret(""); !errors.Is(err, ErrNotFound) { + t.Fatalf("empty secret should be NotFound, got %v", err) + } + if _, err := s.GetWorkloadByWebhookSecret("nope"); !errors.Is(err, ErrNotFound) { + t.Fatalf("unknown secret should be NotFound, got %v", err) + } +} + +func TestListWorkloads(t *testing.T) { + s := newTestStore(t) + + s.CreateWorkload(Workload{Kind: "project", RefID: "p1", Name: "alpha"}) + s.CreateWorkload(Workload{Kind: "stack", RefID: "s1", Name: "bravo"}) + s.CreateWorkload(Workload{Kind: "project", RefID: "p2", Name: "charlie"}) + + all, err := s.ListWorkloads("") + if err != nil { + t.Fatalf("ListWorkloads all: %v", err) + } + if len(all) != 3 { + t.Fatalf("expected 3 workloads, got %d", len(all)) + } + + projects, err := s.ListWorkloads(WorkloadKindProject) + if err != nil { + t.Fatalf("ListWorkloads project: %v", err) + } + if len(projects) != 2 { + t.Fatalf("expected 2 project workloads, got %d", len(projects)) + } +} + +func TestDeleteWorkloadByRef(t *testing.T) { + s := newTestStore(t) + + w, _ := s.CreateWorkload(Workload{Kind: "project", RefID: "p1", Name: "n"}) + + if err := s.DeleteWorkloadByRef(WorkloadKindProject, "p1"); err != nil { + t.Fatalf("DeleteWorkloadByRef: %v", err) + } + if _, err := s.GetWorkloadByID(w.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected NotFound after delete, got %v", err) + } + + // Idempotent — deleting non-existent ref is fine. + if err := s.DeleteWorkloadByRef(WorkloadKindProject, "ghost"); err != nil { + t.Fatalf("DeleteWorkloadByRef should be idempotent: %v", err) + } +}