feat(workload): emit workload labels + dual-write containers from deployer

Project deploys (both standard and blue-green) now stamp the new
workload labels on every container and dual-write a row into the
containers index alongside the existing instances row. The legacy
project/stage/instance-id labels stay for now so operator runbooks
don't break — they will be removed after the migration soaks.

New labels:
- tinyforge.managed       (every Tinyforge container)
- tinyforge.workload.id   (workload row primary key)
- tinyforge.workload.kind ('project' | 'stack' | 'site')
- tinyforge.role          (stage name for projects)

ContainerConfig grows WorkloadID/WorkloadKind/Role fields. The
deployer resolves the project's workload row (guaranteed to exist
by boot-time backfill) and passes the IDs through. Container row
ID matches instance ID by construction so removeInstance can drop
both records together.

Stack and static-site managers still need the same treatment;
those land in the next commit.
This commit is contained in:
2026-05-09 13:37:19 +03:00
parent db235c1412
commit abb1da903f
4 changed files with 103 additions and 0 deletions
+8
View File
@@ -71,6 +71,7 @@ func (d *Deployer) blueGreenDeploy(
instanceID := uuid.New().String()
subdomain := d.buildSubdomain(project, stage, settings, imageTag)
workloadID := d.resolveProjectWorkloadID(project.ID)
containerName := docker.ContainerName(project.Name, stage.Name, imageTag)
portStr := fmt.Sprintf("%d/tcp", project.Port)
envVars := d.mergeEnvVars(project, stage.ID)
@@ -86,6 +87,9 @@ func (d *Deployer) blueGreenDeploy(
Project: project.Name,
Stage: stage.Name,
InstanceID: instanceID,
WorkloadID: workloadID,
WorkloadKind: string(store.WorkloadKindProject),
Role: stage.Name,
Mounts: mounts,
CpuLimit: stage.CpuLimit,
MemoryLimit: stage.MemoryLimit,
@@ -125,6 +129,7 @@ func (d *Deployer) blueGreenDeploy(
return containerID, "", instanceID, fmt.Errorf("create instance record: %w", err)
}
instanceID = inst.ID
d.upsertContainerForInstance(project, stage, inst, workloadID)
if err := d.store.SetDeployInstanceID(deployID, instanceID); err != nil {
slog.Warn("link deploy to instance", "error", err)
@@ -138,6 +143,8 @@ func (d *Deployer) blueGreenDeploy(
if err := d.store.UpdateInstanceStatus(instanceID, "running"); err != nil {
slog.Warn("update instance status", "error", err)
}
inst.Status = "running"
d.upsertContainerForInstance(project, stage, inst, workloadID)
d.publishInstanceStatus(instanceID, project.ID, stage.ID, "running")
// Step 4: Health check the green container.
@@ -188,6 +195,7 @@ func (d *Deployer) blueGreenDeploy(
if err := d.store.UpdateInstance(inst); err != nil {
slog.Warn("update instance with proxy ID", "error", err)
}
d.upsertContainerForInstance(project, stage, inst, workloadID)
// Step 6: Stop the blue container.
if blueInstance != nil {
+57
View File
@@ -3,6 +3,7 @@ package deployer
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"sort"
@@ -357,6 +358,7 @@ func (d *Deployer) executeDeploy(
// Pre-generate instance ID so it can be set as a container label.
instanceID = uuid.New().String()
subdomain := d.buildSubdomain(project, stage, settings, imageTag)
workloadID := d.resolveProjectWorkloadID(project.ID)
containerName := docker.ContainerName(project.Name, stage.Name, imageTag)
@@ -377,6 +379,9 @@ func (d *Deployer) executeDeploy(
Project: project.Name,
Stage: stage.Name,
InstanceID: instanceID,
WorkloadID: workloadID,
WorkloadKind: string(store.WorkloadKindProject),
Role: stage.Name,
Mounts: mounts,
CpuLimit: stage.CpuLimit,
MemoryLimit: stage.MemoryLimit,
@@ -417,6 +422,7 @@ func (d *Deployer) executeDeploy(
return containerID, proxyRouteID, instanceID, fmt.Errorf("create instance record: %w", err)
}
instanceID = inst.ID
d.upsertContainerForInstance(project, stage, inst, workloadID)
// Link deploy to instance.
if err := d.store.SetDeployInstanceID(deployID, instanceID); err != nil {
@@ -434,6 +440,9 @@ func (d *Deployer) executeDeploy(
if err := d.store.UpdateLastAliveAt(instanceID); err != nil {
slog.Warn("update last_alive_at on deploy", "instance_id", instanceID, "error", err)
}
inst.Status = "running"
inst.LastAliveAt = store.Now()
d.upsertContainerForInstance(project, stage, inst, workloadID)
d.publishInstanceStatus(instanceID, project.ID, stage.ID, "running")
d.logDeploy(deployID, "Container started", "info")
@@ -460,6 +469,7 @@ func (d *Deployer) executeDeploy(
if err := d.store.UpdateInstance(inst); err != nil {
slog.Warn("update instance with proxy ID", "error", err)
}
d.upsertContainerForInstance(project, stage, inst, workloadID)
// Create DNS record for this instance.
fqdn := subdomain + "." + settings.Domain
@@ -470,6 +480,7 @@ func (d *Deployer) executeDeploy(
if err := d.store.UpdateInstance(inst); err != nil {
slog.Warn("update instance", "error", err)
}
d.upsertContainerForInstance(project, stage, inst, workloadID)
}
// Step 5: Health check.
@@ -621,6 +632,13 @@ func (d *Deployer) removeInstance(ctx context.Context, inst store.Instance, sett
return fmt.Errorf("delete instance record: %w", err)
}
// Drop the matching container index row. ID matches instance.ID by
// construction; ignore NotFound which is harmless if the row predates
// this refactor.
if err := d.store.DeleteContainer(inst.ID); err != nil && !errors.Is(err, store.ErrNotFound) {
slog.Warn("delete container row", "instance_id", inst.ID, "error", err)
}
return nil
}
@@ -885,3 +903,42 @@ func truncateID(id string) string {
return id
}
// upsertContainerForInstance keeps the normalized containers index in sync
// with the project-specific instance row. Same UUID is used for both rows so
// the reconciler can find them later. Best-effort: a sync failure is logged
// but does not abort the deploy — the container is still running and the
// reconciler will pick it up on the next tick (once that lands).
func (d *Deployer) upsertContainerForInstance(project store.Project, stage store.Stage, inst store.Instance, workloadID string) {
c := store.Container{
ID: inst.ID,
WorkloadID: workloadID,
WorkloadKind: string(store.WorkloadKindProject),
Role: stage.Name,
ContainerID: inst.ContainerID,
ImageRef: project.Image + ":" + inst.ImageTag,
ImageTag: inst.ImageTag,
Host: "local",
State: inst.Status,
Port: inst.Port,
Subdomain: inst.Subdomain,
ProxyRouteID: inst.ProxyRouteID,
NpmProxyID: inst.NpmProxyID,
LastSeenAt: inst.LastAliveAt,
}
if err := d.store.UpsertContainer(c); err != nil {
slog.Warn("upsert container row", "instance_id", inst.ID, "error", err)
}
}
// resolveProjectWorkloadID returns the workload ID paired with a project.
// Backfill-on-boot guarantees the row exists, so this is essentially a lookup.
// On miss (defensive), it logs and returns empty so the caller can decide.
func (d *Deployer) resolveProjectWorkloadID(projectID string) string {
w, err := d.store.GetWorkloadByRef(store.WorkloadKindProject, projectID)
if err != nil {
slog.Warn("resolve project workload", "project_id", projectID, "error", err)
return ""
}
return w.ID
}