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
+80
View File
@@ -293,6 +293,86 @@ func (c *Client) ListContainers(ctx context.Context, labelFilters map[string]str
return result, nil
}
// ReconcileItem is a fat container summary aimed at the reconciler — it
// exposes the full label map so the caller can dispatch by workload labels,
// legacy labels, or compose labels without re-inspecting.
type ReconcileItem struct {
ID string
Name string
Image string
State string
Status string
Labels map[string]string
Ports []uint16
}
// ListAllForReconciler returns every container the daemon knows about whose
// labels mark it as Tinyforge-managed by ANY of the supported schemes:
// - tinyforge.managed (canonical, new)
// - tinyforge.project / tinyforge.instance-id (legacy project)
// - tinyforge.static-site (legacy site)
// - com.docker.compose.project starting with "tinyforge-" (stacks)
//
// The Docker API does not support OR'd label filters, so we list everything
// and filter in-process. On a small/medium daemon this is cheap; the
// reconciler runs on a 30s tick.
func (c *Client) ListAllForReconciler(ctx context.Context) ([]ReconcileItem, error) {
listResult, err := c.api.ContainerList(ctx, client.ContainerListOptions{All: true})
if err != nil {
return nil, fmt.Errorf("list containers: %w", err)
}
out := make([]ReconcileItem, 0, len(listResult.Items))
for _, ctr := range listResult.Items {
labels := ctr.Labels
if !isTinyforgeManaged(labels) {
continue
}
name := ""
if len(ctr.Names) > 0 {
name = strings.TrimPrefix(ctr.Names[0], "/")
}
var ports []uint16
for _, p := range ctr.Ports {
if p.PublicPort > 0 {
ports = append(ports, p.PublicPort)
}
}
out = append(out, ReconcileItem{
ID: ctr.ID,
Name: name,
Image: ctr.Image,
State: string(ctr.State),
Status: ctr.Status,
Labels: labels,
Ports: ports,
})
}
return out, nil
}
// isTinyforgeManaged returns true when a container's labels mark it as
// belonging to Tinyforge under any of the supported labelling schemes.
func isTinyforgeManaged(labels map[string]string) bool {
if labels == nil {
return false
}
if labels[LabelManaged] == "true" {
return true
}
if labels[LabelProject] != "" || labels[LabelInstanceID] != "" {
return true
}
if _, ok := labels["tinyforge.static-site"]; ok {
return true
}
if cp, ok := labels["com.docker.compose.project"]; ok && strings.HasPrefix(cp, "tinyforge-") {
return true
}
return false
}
// ContainerLogs returns a log stream for a container.
// If follow is true, the stream stays open for new log lines.
// tail specifies the number of lines from the end to return (e.g., "200").