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
+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))
}
}