cba2149aa9
Wraps up the workload refactor with the fixes that came out of the multi-agent code review (see docs/plans/workload-refactor.md "What actually shipped"). Backend: - store.ReconcileContainer: separate write path so the 30s reconciler tick no longer overwrites deployer-owned fields (subdomain, proxy_route_id, npm_proxy_id, image_tag). - Container.stage_id column + index; ListProxyRoutes / ListContainersByStageID join via stage_id (survives stage rename), with legacy fallback to (project_id, role=stage_name). - Reconciler: workload-existence check (rejects forged tinyforge.workload.id labels), skips inventing project-kind rows, child-context cancel before wg.Wait() on shutdown. - Transactional CRUD across projects / stacks / static_sites: parent UPDATE and workload sync land in one transaction so secret rotations are durable. - Webhook routing reads exclusively through workloads.webhook_secret; legacy GetProjectByWebhookSecret / GetStaticSiteByWebhookSecret fallback removed. - store.GetStackByComposeProjectName + indexed lookup (no more full-table stack scan per compose container per tick). - store.ListMissingSweepRows: filtered query for the missing-sweep. - /api/instances/* handlers verify (workload_id, role) match URL (project_id, stage_name) before mutating — closes the cross-project hijack the security review flagged. - extra_json no longer referenced from Go (column kept on disk for now). Frontend: - WorkloadContainers.svelte: generic detail-page panel reusable by stack and site detail pages. - Containers page polish: client-side kind/state filters over an unfiltered fetch, URL-synced filters, race-safe loads via sequence number, EN+RU i18n, sidebar counter via navCounts.containers. Misc: - scripts/dev-server.sh: tolerate empty netstat grep result. - .gitignore: ignore docker-watcher binaries, .claude/worktrees/, .facts-sync.json.
229 lines
6.4 KiB
Go
229 lines
6.4 KiB
Go
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)
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|