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:
@@ -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 ~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`
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, ", ")
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user