refactor(workload): extract Instance entirely; Container is canonical
Build / build (push) Successful in 10m41s
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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user