feat(workload): container index reconciler

Background worker that keeps the containers table in sync with
docker ps. Runs one boot pass and ticks every 30s.

Dispatch precedence per container:
  1. tinyforge.workload.id label   (canonical, new)
  2. tinyforge.instance-id label   (legacy project — joins via instances)
  3. tinyforge.static-site label   (legacy site)
  4. com.docker.compose.project    (stacks — joins via ComposeProjectName)

Rows whose Docker container ID is no longer present are flipped
to state='missing'. Placeholder rows (empty container_id, e.g.
a deploy mid-flight) are left alone so a tick that races a
deploy doesn't mark them as missing.

DockerLister interface lets tests substitute a fake daemon —
6 unit tests cover the dispatch matrix, missing-sweep, and
state normalization.

Wired into cmd/server/main.go between docker.New and the
existing startup chain. Boot pass populates the containers
table from any pre-refactor running containers.
This commit is contained in:
2026-05-09 13:45:13 +03:00
parent b6f20599d7
commit af82be3fb8
4 changed files with 659 additions and 0 deletions
+219
View File
@@ -0,0 +1,219 @@
package reconciler
import (
"context"
"testing"
"github.com/alexei/tinyforge/internal/docker"
"github.com/alexei/tinyforge/internal/store"
)
// fakeDocker is a tiny stand-in for docker.Client. The reconciler depends on
// the DockerLister interface so we don't need a real daemon for unit tests.
type fakeDocker struct {
items []docker.ReconcileItem
}
func (f *fakeDocker) ListAllForReconciler(ctx context.Context) ([]docker.ReconcileItem, error) {
return f.items, nil
}
func newTestStore(t *testing.T) *store.Store {
t.Helper()
s, err := store.New(":memory:")
if err != nil {
t.Fatalf("create store: %v", err)
}
t.Cleanup(func() { s.Close() })
return s
}
func TestReconcileWorkloadLabelledStackContainer(t *testing.T) {
st := newTestStore(t)
// Set up a stack workload (no project/site interaction).
stack, err := st.CreateStack(store.Stack{
Name: "wf-stack", ComposeProjectName: "tinyforge-wf-stack",
})
if err != nil {
t.Fatalf("CreateStack: %v", err)
}
w, _ := st.GetWorkloadByRef(store.WorkloadKindStack, stack.ID)
// One container with the canonical workload labels stamped.
fake := &fakeDocker{items: []docker.ReconcileItem{{
ID: "docker-abc",
Name: "wf-stack-web-1",
Image: "nginx:1.27",
State: "running",
Labels: map[string]string{
docker.LabelManaged: "true",
docker.LabelWorkloadID: w.ID,
docker.LabelWorkloadKind: "stack",
docker.LabelRole: "web",
},
Ports: []uint16{8080},
}}}
r := New(st, fake, 0)
if err := r.ReconcileOnce(context.Background()); err != nil {
t.Fatalf("ReconcileOnce: %v", err)
}
rows, _ := st.ListContainersByWorkload(w.ID)
if len(rows) != 1 {
t.Fatalf("expected 1 container row, got %d", len(rows))
}
got := rows[0]
if got.ContainerID != "docker-abc" {
t.Fatalf("container_id not bound: got %q", got.ContainerID)
}
if got.Role != "web" || got.WorkloadKind != "stack" {
t.Fatalf("dispatch wrong: %+v", got)
}
if got.State != "running" || got.Port != 8080 {
t.Fatalf("state/port wrong: %+v", got)
}
}
func TestReconcileComposeOnlyStackContainer(t *testing.T) {
st := newTestStore(t)
stack, _ := st.CreateStack(store.Stack{
Name: "compose-stack", ComposeProjectName: "tinyforge-compose-stack",
})
w, _ := st.GetWorkloadByRef(store.WorkloadKindStack, stack.ID)
// Pre-existing compose container — only carries compose's own labels,
// no tinyforge.* labels at all.
fake := &fakeDocker{items: []docker.ReconcileItem{{
ID: "docker-xyz",
Name: "tinyforge-compose-stack-worker-1",
Image: "redis:7",
State: "running",
Labels: map[string]string{
"com.docker.compose.project": "tinyforge-compose-stack",
"com.docker.compose.service": "worker",
},
}}}
r := New(st, fake, 0)
if err := r.ReconcileOnce(context.Background()); err != nil {
t.Fatalf("ReconcileOnce: %v", err)
}
rows, _ := st.ListContainersByWorkload(w.ID)
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
if rows[0].Role != "worker" {
t.Fatalf("role from compose label wrong: %q", rows[0].Role)
}
if rows[0].ContainerID != "docker-xyz" {
t.Fatalf("container_id not bound: %q", rows[0].ContainerID)
}
}
func TestReconcileMarksMissingRows(t *testing.T) {
st := newTestStore(t)
stack, _ := st.CreateStack(store.Stack{
Name: "missing-stack", ComposeProjectName: "tinyforge-missing-stack",
})
w, _ := st.GetWorkloadByRef(store.WorkloadKindStack, stack.ID)
// Pre-existing row with a real container_id that no longer exists.
if err := st.UpsertContainer(store.Container{
ID: w.ID + ":web", WorkloadID: w.ID, WorkloadKind: "stack",
Role: "web", ContainerID: "docker-gone", State: "running",
}); err != nil {
t.Fatalf("seed: %v", err)
}
// Reconciler sees nothing.
r := New(st, &fakeDocker{}, 0)
if err := r.ReconcileOnce(context.Background()); err != nil {
t.Fatalf("ReconcileOnce: %v", err)
}
got, _ := st.GetContainerByID(w.ID + ":web")
if got.State != "missing" {
t.Fatalf("expected state=missing, got %q", got.State)
}
}
func TestReconcileSkipsRowsAwaitingDocker(t *testing.T) {
st := newTestStore(t)
stack, _ := st.CreateStack(store.Stack{
Name: "pending", ComposeProjectName: "tinyforge-pending",
})
w, _ := st.GetWorkloadByRef(store.WorkloadKindStack, stack.ID)
// A row with empty container_id (deployer placeholder, awaiting docker
// create). Reconciler must not mark this as missing.
if err := st.UpsertContainer(store.Container{
ID: w.ID + ":web", WorkloadID: w.ID, WorkloadKind: "stack",
Role: "web", ContainerID: "", State: "starting",
}); err != nil {
t.Fatalf("seed: %v", err)
}
r := New(st, &fakeDocker{}, 0)
if err := r.ReconcileOnce(context.Background()); err != nil {
t.Fatalf("ReconcileOnce: %v", err)
}
got, _ := st.GetContainerByID(w.ID + ":web")
if got.State != "starting" {
t.Fatalf("placeholder row should keep state, got %q", got.State)
}
}
func TestReconcileIgnoresUnmanagedContainers(t *testing.T) {
// A container without any tinyforge or compose labels would not even be
// returned by ListAllForReconciler in production; but the dispatch must
// be a no-op even if a stray item slips through.
st := newTestStore(t)
fake := &fakeDocker{items: []docker.ReconcileItem{{
ID: "docker-foreign", Labels: map[string]string{"app": "other"},
}}}
r := New(st, fake, 0)
if err := r.ReconcileOnce(context.Background()); err != nil {
t.Fatalf("ReconcileOnce: %v", err)
}
rows, _ := st.ListContainers(store.ContainerFilter{})
if len(rows) != 0 {
t.Fatalf("foreign container should not produce rows, got %d", len(rows))
}
}
func TestReconcileNormalizesState(t *testing.T) {
st := newTestStore(t)
stack, _ := st.CreateStack(store.Stack{
Name: "norm", ComposeProjectName: "tinyforge-norm",
})
w, _ := st.GetWorkloadByRef(store.WorkloadKindStack, stack.ID)
fake := &fakeDocker{items: []docker.ReconcileItem{{
ID: "docker-1",
Image: "nginx",
State: "exited",
Labels: map[string]string{
docker.LabelManaged: "true",
docker.LabelWorkloadID: w.ID,
docker.LabelWorkloadKind: "stack",
docker.LabelRole: "web",
},
}}}
r := New(st, fake, 0)
if err := r.ReconcileOnce(context.Background()); err != nil {
t.Fatalf("ReconcileOnce: %v", err)
}
got, _ := st.GetContainerByID(w.ID + ":web")
if got.State != "stopped" {
t.Fatalf("docker 'exited' should normalize to 'stopped', got %q", got.State)
}
}