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:
@@ -71,6 +71,7 @@ func (d *Deployer) blueGreenDeploy(
|
|||||||
|
|
||||||
instanceID := uuid.New().String()
|
instanceID := uuid.New().String()
|
||||||
subdomain := d.buildSubdomain(project, stage, settings, imageTag)
|
subdomain := d.buildSubdomain(project, stage, settings, imageTag)
|
||||||
|
workloadID := d.resolveProjectWorkloadID(project.ID)
|
||||||
containerName := docker.ContainerName(project.Name, stage.Name, imageTag)
|
containerName := docker.ContainerName(project.Name, stage.Name, imageTag)
|
||||||
portStr := fmt.Sprintf("%d/tcp", project.Port)
|
portStr := fmt.Sprintf("%d/tcp", project.Port)
|
||||||
envVars := d.mergeEnvVars(project, stage.ID)
|
envVars := d.mergeEnvVars(project, stage.ID)
|
||||||
@@ -86,6 +87,9 @@ func (d *Deployer) blueGreenDeploy(
|
|||||||
Project: project.Name,
|
Project: project.Name,
|
||||||
Stage: stage.Name,
|
Stage: stage.Name,
|
||||||
InstanceID: instanceID,
|
InstanceID: instanceID,
|
||||||
|
WorkloadID: workloadID,
|
||||||
|
WorkloadKind: string(store.WorkloadKindProject),
|
||||||
|
Role: stage.Name,
|
||||||
Mounts: mounts,
|
Mounts: mounts,
|
||||||
CpuLimit: stage.CpuLimit,
|
CpuLimit: stage.CpuLimit,
|
||||||
MemoryLimit: stage.MemoryLimit,
|
MemoryLimit: stage.MemoryLimit,
|
||||||
@@ -125,6 +129,7 @@ func (d *Deployer) blueGreenDeploy(
|
|||||||
return containerID, "", instanceID, fmt.Errorf("create instance record: %w", err)
|
return containerID, "", instanceID, fmt.Errorf("create instance record: %w", err)
|
||||||
}
|
}
|
||||||
instanceID = inst.ID
|
instanceID = inst.ID
|
||||||
|
d.upsertContainerForInstance(project, stage, inst, workloadID)
|
||||||
|
|
||||||
if err := d.store.SetDeployInstanceID(deployID, instanceID); err != nil {
|
if err := d.store.SetDeployInstanceID(deployID, instanceID); err != nil {
|
||||||
slog.Warn("link deploy to instance", "error", err)
|
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 {
|
if err := d.store.UpdateInstanceStatus(instanceID, "running"); err != nil {
|
||||||
slog.Warn("update instance status", "error", err)
|
slog.Warn("update instance status", "error", err)
|
||||||
}
|
}
|
||||||
|
inst.Status = "running"
|
||||||
|
d.upsertContainerForInstance(project, stage, inst, workloadID)
|
||||||
d.publishInstanceStatus(instanceID, project.ID, stage.ID, "running")
|
d.publishInstanceStatus(instanceID, project.ID, stage.ID, "running")
|
||||||
|
|
||||||
// Step 4: Health check the green container.
|
// Step 4: Health check the green container.
|
||||||
@@ -188,6 +195,7 @@ func (d *Deployer) blueGreenDeploy(
|
|||||||
if err := d.store.UpdateInstance(inst); err != nil {
|
if err := d.store.UpdateInstance(inst); err != nil {
|
||||||
slog.Warn("update instance with proxy ID", "error", err)
|
slog.Warn("update instance with proxy ID", "error", err)
|
||||||
}
|
}
|
||||||
|
d.upsertContainerForInstance(project, stage, inst, workloadID)
|
||||||
|
|
||||||
// Step 6: Stop the blue container.
|
// Step 6: Stop the blue container.
|
||||||
if blueInstance != nil {
|
if blueInstance != nil {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package deployer
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -357,6 +358,7 @@ func (d *Deployer) executeDeploy(
|
|||||||
// Pre-generate instance ID so it can be set as a container label.
|
// Pre-generate instance ID so it can be set as a container label.
|
||||||
instanceID = uuid.New().String()
|
instanceID = uuid.New().String()
|
||||||
subdomain := d.buildSubdomain(project, stage, settings, imageTag)
|
subdomain := d.buildSubdomain(project, stage, settings, imageTag)
|
||||||
|
workloadID := d.resolveProjectWorkloadID(project.ID)
|
||||||
|
|
||||||
containerName := docker.ContainerName(project.Name, stage.Name, imageTag)
|
containerName := docker.ContainerName(project.Name, stage.Name, imageTag)
|
||||||
|
|
||||||
@@ -377,6 +379,9 @@ func (d *Deployer) executeDeploy(
|
|||||||
Project: project.Name,
|
Project: project.Name,
|
||||||
Stage: stage.Name,
|
Stage: stage.Name,
|
||||||
InstanceID: instanceID,
|
InstanceID: instanceID,
|
||||||
|
WorkloadID: workloadID,
|
||||||
|
WorkloadKind: string(store.WorkloadKindProject),
|
||||||
|
Role: stage.Name,
|
||||||
Mounts: mounts,
|
Mounts: mounts,
|
||||||
CpuLimit: stage.CpuLimit,
|
CpuLimit: stage.CpuLimit,
|
||||||
MemoryLimit: stage.MemoryLimit,
|
MemoryLimit: stage.MemoryLimit,
|
||||||
@@ -417,6 +422,7 @@ func (d *Deployer) executeDeploy(
|
|||||||
return containerID, proxyRouteID, instanceID, fmt.Errorf("create instance record: %w", err)
|
return containerID, proxyRouteID, instanceID, fmt.Errorf("create instance record: %w", err)
|
||||||
}
|
}
|
||||||
instanceID = inst.ID
|
instanceID = inst.ID
|
||||||
|
d.upsertContainerForInstance(project, stage, inst, workloadID)
|
||||||
|
|
||||||
// Link deploy to instance.
|
// Link deploy to instance.
|
||||||
if err := d.store.SetDeployInstanceID(deployID, instanceID); err != nil {
|
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 {
|
if err := d.store.UpdateLastAliveAt(instanceID); err != nil {
|
||||||
slog.Warn("update last_alive_at on deploy", "instance_id", instanceID, "error", err)
|
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.publishInstanceStatus(instanceID, project.ID, stage.ID, "running")
|
||||||
d.logDeploy(deployID, "Container started", "info")
|
d.logDeploy(deployID, "Container started", "info")
|
||||||
|
|
||||||
@@ -460,6 +469,7 @@ func (d *Deployer) executeDeploy(
|
|||||||
if err := d.store.UpdateInstance(inst); err != nil {
|
if err := d.store.UpdateInstance(inst); err != nil {
|
||||||
slog.Warn("update instance with proxy ID", "error", err)
|
slog.Warn("update instance with proxy ID", "error", err)
|
||||||
}
|
}
|
||||||
|
d.upsertContainerForInstance(project, stage, inst, workloadID)
|
||||||
|
|
||||||
// Create DNS record for this instance.
|
// Create DNS record for this instance.
|
||||||
fqdn := subdomain + "." + settings.Domain
|
fqdn := subdomain + "." + settings.Domain
|
||||||
@@ -470,6 +480,7 @@ func (d *Deployer) executeDeploy(
|
|||||||
if err := d.store.UpdateInstance(inst); err != nil {
|
if err := d.store.UpdateInstance(inst); err != nil {
|
||||||
slog.Warn("update instance", "error", err)
|
slog.Warn("update instance", "error", err)
|
||||||
}
|
}
|
||||||
|
d.upsertContainerForInstance(project, stage, inst, workloadID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 5: Health check.
|
// 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)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -885,3 +903,42 @@ func truncateID(id string) string {
|
|||||||
return id
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,22 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Labels applied to all containers managed by Tinyforge.
|
// 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 (
|
const (
|
||||||
LabelProject = "tinyforge.project"
|
LabelProject = "tinyforge.project"
|
||||||
LabelStage = "tinyforge.stage"
|
LabelStage = "tinyforge.stage"
|
||||||
LabelInstanceID = "tinyforge.instance-id"
|
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.
|
// Client wraps the Docker Engine API client.
|
||||||
|
|||||||
@@ -48,6 +48,19 @@ type ContainerConfig struct {
|
|||||||
// InstanceID is the Tinyforge instance ID (used for labelling).
|
// InstanceID is the Tinyforge instance ID (used for labelling).
|
||||||
InstanceID string
|
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 is a list of bind mounts to attach to the container.
|
||||||
Mounts []mount.Mount
|
Mounts []mount.Mount
|
||||||
|
|
||||||
@@ -93,9 +106,22 @@ func (c *Client) CreateContainer(ctx context.Context, cfg ContainerConfig) (stri
|
|||||||
for k, v := range cfg.Labels {
|
for k, v := range cfg.Labels {
|
||||||
labels[k] = v
|
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[LabelProject] = cfg.Project
|
||||||
labels[LabelStage] = cfg.Stage
|
labels[LabelStage] = cfg.Stage
|
||||||
labels[LabelInstanceID] = cfg.InstanceID
|
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{
|
containerCfg := &container.Config{
|
||||||
Image: cfg.Image,
|
Image: cfg.Image,
|
||||||
|
|||||||
Reference in New Issue
Block a user