feat(workload): add Workload/Container/App store foundation

Introduces the data layer for the Workload refactor (see
docs/plans/workload-refactor.md): three new tables and store
methods, no behavior changes elsewhere yet.

- workloads: unifying primitive over Project/Stack/StaticSite,
  paired via UNIQUE(kind, ref_id). Notification + webhook config
  hosted here so it lives in one place across kinds.
- containers: normalized index of every Tinyforge-managed
  container with first-class subdomain/proxy_route_id/npm_proxy_id
  columns (heavily queried by ListProxyRoutes / stale detection).
- apps: optional grouping of workloads; schema only, no UI in v1.

Foundation only — deployer surgery, reconciler, and consumer
switchover land in the next commit.
This commit is contained in:
2026-05-09 13:22:25 +03:00
parent 0f60a7a5db
commit f54a6ecee3
9 changed files with 1389 additions and 0 deletions
+197
View File
@@ -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 `<WorkloadContainers>` 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 `<WorkloadContainers workloadId={...}/>`.
- `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 ~75165)
- `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`
+110
View File
@@ -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()
}
+77
View File
@@ -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)
}
}
+307
View File
@@ -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, ", ")
}
+231
View File
@@ -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))
}
}
+62
View File
@@ -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"`
}
+63
View File
@@ -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 {
+192
View File
@@ -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
}
+150
View File
@@ -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)
}
}