refactor(workload): extract Instance entirely; Container is canonical
Build / build (push) Successful in 10m41s

End-to-end extraction of the Instance concept. After this commit:

  * internal/store/instances.go — DELETED
  * internal/store/models.go — Instance struct gone, ProxyRoute moved here
  * containers table is the single source of truth for project/stack/site
    container state. instances table is dropped via DROP TABLE migration
    (idempotent; re-runnable on every boot).
  * Legacy tinyforge.project / tinyforge.stage / tinyforge.instance-id
    Docker labels are no longer emitted; only tinyforge.workload.{id,kind},
    tinyforge.role, and tinyforge.managed are stamped on new containers.

Backend rewrites:
  - internal/deployer:        executeDeploy + blueGreenDeploy + rollback +
                              promote use store.Container natively. New
                              removeContainer() replaces removeInstance().
                              enforceMaxInstances reads via
                              ListContainersByStageID.
  - internal/reconciler:      legacy tinyforge.instance-id dispatch removed;
                              upsertByWorkloadLabel now finds existing rows
                              by docker container ID first and falls back to
                              the deterministic workloadID:role key.
  - internal/stale/scanner:   Scan + new FindStaleContainers walk the
                              containers table; emit StaleContainer JSON.
  - internal/stats/collector: ListContainers replaces ListAllInstances.
  - internal/webhook/handler: workload-secret lookup tried first; falls back
                              to project / static_site secret column.
  - internal/api: instances.go, stale.go, stats.go, stats_history.go,
                  projects.go, settings.go, docker.go, dns.go all read /
                  write through Container.

Docker layer:
  - ManagedContainer exposes WorkloadID/Kind/Role from the canonical labels.
  - ListContainers filters by tinyforge.managed=true.
  - Network creation uses LabelManaged instead of LabelProject.

Frontend:
  - Instance type is now a Container alias; .status → .state,
    .last_alive_at → .last_seen_at.
  - InstanceCard takes stageId as a prop (no longer derived from Instance).
  - StaleContainer JSON shape rewritten: { container, workload_name, role,
    days_stale }. StaleContainerCard + /containers/stale page updated.
  - ProjectCard / homepage / SystemHealthCard filter by .state.

The migration loop now tolerates "no such table" alongside "duplicate
column" / "already exists" so obsolete ALTER TABLE entries targeting the
dropped instances table no-op cleanly on first boot.

Tests: store + deployer + reconciler + webhook + staticsite + notify all
still pass. Frontend svelte-check: zero errors.
This commit is contained in:
2026-05-09 14:43:12 +03:00
parent d516462750
commit d8ab22876f
32 changed files with 649 additions and 957 deletions
+21 -62
View File
@@ -5,15 +5,17 @@
// longer present are flipped to state='missing'.
//
// Dispatch precedence:
// 1. tinyforge.workload.id label (canonical)
// 2. tinyforge.instance-id label (legacy project — joins via instances row)
// 3. tinyforge.static-site label (legacy site)
// 4. com.docker.compose.project (stack — joins via Stack.ComposeProjectName)
// 1. tinyforge.workload.id label (canonical, new)
// 2. tinyforge.static-site label (legacy site — joins via static_sites)
// 3. com.docker.compose.project (stack — joins via Stack.ComposeProjectName)
//
// The legacy tinyforge.instance-id path was removed when the deployer was
// rewritten to use Container natively — every Tinyforge-managed project
// container now carries the workload labels at create time.
package reconciler
import (
"context"
"errors"
"log/slog"
"strings"
"sync"
@@ -123,9 +125,6 @@ func (r *Reconciler) upsertFromItem(ctx context.Context, item docker.ReconcileIt
if id := item.Labels[docker.LabelWorkloadID]; id != "" {
return r.upsertByWorkloadLabel(item, id)
}
if instanceID := item.Labels[docker.LabelInstanceID]; instanceID != "" {
return r.upsertByInstanceLabel(item, instanceID)
}
if siteID := item.Labels["tinyforge.static-site"]; siteID != "" {
return r.upsertBySiteLabel(item, siteID)
}
@@ -135,12 +134,17 @@ func (r *Reconciler) upsertFromItem(ctx context.Context, item docker.ReconcileIt
return ""
}
// upsertByWorkloadLabel — canonical path. WorkloadID + Role uniquely
// identifies the row. ID stays deterministic so re-deploys update in place.
// upsertByWorkloadLabel — canonical path. The row may already exist with a
// deployer-assigned UUID (project deploys do this so each blue-green slot
// has a stable handle); look it up by docker container ID first and fall
// back to the deterministic workloadID:role key.
func (r *Reconciler) upsertByWorkloadLabel(item docker.ReconcileItem, workloadID string) string {
role := item.Labels[docker.LabelRole]
kind := item.Labels[docker.LabelWorkloadKind]
rowID := workloadIDRow(workloadID, kind, role, item.Labels[docker.LabelInstanceID], item.ID)
rowID := workloadIDRow(workloadID, kind, role, item.ID)
if existing, err := r.store.GetContainerByDockerID(item.ID); err == nil {
rowID = existing.ID
}
port := 0
if len(item.Ports) > 0 {
@@ -164,49 +168,6 @@ func (r *Reconciler) upsertByWorkloadLabel(item docker.ReconcileItem, workloadID
return rowID
}
// upsertByInstanceLabel — legacy project path. Instance ID maps 1:1 to the
// container row ID by construction (deployer uses the same UUID for both),
// so we can update directly. We still need the workload ID for the row.
func (r *Reconciler) upsertByInstanceLabel(item docker.ReconcileItem, instanceID string) string {
inst, err := r.store.GetInstanceByID(instanceID)
if err != nil {
// Container with stale label — instance row gone. Skip silently.
if errors.Is(err, store.ErrNotFound) {
return ""
}
slog.Warn("reconciler: lookup instance", "instance_id", instanceID, "error", err)
return ""
}
w, err := r.store.GetWorkloadByRef(store.WorkloadKindProject, inst.ProjectID)
if err != nil {
return ""
}
port := inst.Port
if port == 0 && len(item.Ports) > 0 {
port = int(item.Ports[0])
}
if err := r.store.UpsertContainer(store.Container{
ID: inst.ID,
WorkloadID: w.ID,
WorkloadKind: string(store.WorkloadKindProject),
Role: item.Labels[docker.LabelStage],
ContainerID: item.ID,
ImageRef: item.Image,
ImageTag: inst.ImageTag,
Host: "local",
State: normalizeState(item.State),
Port: port,
Subdomain: inst.Subdomain,
ProxyRouteID: inst.ProxyRouteID,
NpmProxyID: inst.NpmProxyID,
LastSeenAt: store.Now(),
}); err != nil {
slog.Warn("reconciler: upsert by instance label", "container_id", item.ID, "error", err)
return ""
}
return inst.ID
}
func (r *Reconciler) upsertBySiteLabel(item docker.ReconcileItem, siteID string) string {
w, err := r.store.GetWorkloadByRef(store.WorkloadKindSite, siteID)
if err != nil {
@@ -313,20 +274,18 @@ func (r *Reconciler) markMissingRows(seen map[string]struct{}) {
}
// workloadIDRow picks the row ID for a workload-labelled container.
// For projects the deployer assigns instance ID = container row ID (via
// LabelInstanceID), so we honor that to keep IDs stable. For stack/site
// it's the deterministic workloadID:role pattern.
func workloadIDRow(workloadID, kind, role, instanceID, containerID string) string {
if instanceID != "" && kind == string(store.WorkloadKindProject) {
return instanceID
}
// Stack rows use the deterministic workloadID:role pattern; sites use
// workloadID:site. Project rows have a per-deploy UUID assigned by the
// deployer and ALSO carry the role label (= stage name), so the same
// pattern resolves to the same row across deployer + reconciler upserts.
func workloadIDRow(workloadID, kind, role, containerID string) string {
if role != "" {
return workloadID + ":" + role
}
if kind == string(store.WorkloadKindSite) {
return workloadID + ":site"
}
// Last-resort fallback: container ID. Better than ""; uncommon path.
// Last-resort fallback: container ID. Uncommon path.
return workloadID + ":" + containerID
}