From abb1da903f0b36a79f354a803f3926b8a3dc5ab6 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 9 May 2026 13:37:19 +0300 Subject: [PATCH] feat(workload): emit workload labels + dual-write containers from deployer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/deployer/bluegreen.go | 8 +++++ internal/deployer/deployer.go | 57 ++++++++++++++++++++++++++++++++++ internal/docker/client.go | 12 +++++++ internal/docker/container.go | 26 ++++++++++++++++ 4 files changed, 103 insertions(+) diff --git a/internal/deployer/bluegreen.go b/internal/deployer/bluegreen.go index ff587d1..5435c0b 100644 --- a/internal/deployer/bluegreen.go +++ b/internal/deployer/bluegreen.go @@ -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 { diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index 585628d..109c71c 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -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 +} + diff --git a/internal/docker/client.go b/internal/docker/client.go index b4ab8ff..dda483b 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -8,10 +8,22 @@ import ( ) // Labels applied to all containers managed by Tinyforge. +// +// Workload-shaped labels (LabelWorkloadID, LabelWorkloadKind, LabelRole, +// LabelManaged) are the canonical set going forward and what the reconciler +// queries by. The legacy project/stage/instance-id labels are still emitted +// alongside them for back-compat with anything that selects on them +// (operator runbooks, monitoring scrape rules, ad-hoc shell debugging) — they +// will be removed once the migration soaks. const ( LabelProject = "tinyforge.project" LabelStage = "tinyforge.stage" LabelInstanceID = "tinyforge.instance-id" + + LabelManaged = "tinyforge.managed" // present on every Tinyforge-managed container + LabelWorkloadID = "tinyforge.workload.id" // workload row primary key + LabelWorkloadKind = "tinyforge.workload.kind" // 'project' | 'stack' | 'site' + LabelRole = "tinyforge.role" // stage name (project), service name (stack), '' (site) ) // Client wraps the Docker Engine API client. diff --git a/internal/docker/container.go b/internal/docker/container.go index 07aa1df..a40389e 100644 --- a/internal/docker/container.go +++ b/internal/docker/container.go @@ -48,6 +48,19 @@ type ContainerConfig struct { // InstanceID is the Tinyforge instance ID (used for labelling). InstanceID string + // WorkloadID is the unifying primitive's row ID (Workload.ID). Future + // reconciler / global views key off this label, so it must be set on + // every Tinyforge-managed container (project, stack, site). + WorkloadID string + + // WorkloadKind is 'project' | 'stack' | 'site'. Denormalized here so + // label-selector queries don't need to join through workloads. + WorkloadKind string + + // Role is the per-kind sub-identifier: stage name for projects, service + // name for stacks, empty for sites. Used by the reconciler to upsert. + Role string + // Mounts is a list of bind mounts to attach to the container. Mounts []mount.Mount @@ -93,9 +106,22 @@ func (c *Client) CreateContainer(ctx context.Context, cfg ContainerConfig) (stri for k, v := range cfg.Labels { labels[k] = v } + // Legacy labels (kept for back-compat with operator runbooks / + // monitoring scrape rules; will be removed after the workload soak). labels[LabelProject] = cfg.Project labels[LabelStage] = cfg.Stage labels[LabelInstanceID] = cfg.InstanceID + // Workload-shaped labels — canonical going forward. + labels[LabelManaged] = "true" + if cfg.WorkloadID != "" { + labels[LabelWorkloadID] = cfg.WorkloadID + } + if cfg.WorkloadKind != "" { + labels[LabelWorkloadKind] = cfg.WorkloadKind + } + if cfg.Role != "" { + labels[LabelRole] = cfg.Role + } containerCfg := &container.Config{ Image: cfg.Image,