Files
spinifex/cmd/ecs-agent/reconcile.go
T
Josh Siviour 2638a792c5 fix(ecs): task-definition round-trip fidelity and a task that dies while the agent is down (#603)
Persist and echo requiresCompatibilities on task definitions, so a Terraform round trip no longer reads null as drift and force-replaces the definition on every apply.

Report a task whose containers all exited while the ecs-agent was down. reconcile only re-adopted running containers, so the gateway held such a task RUNNING forever, its service never replaced it, and the load balancer target stayed unhealthy.
2026-07-30 11:58:54 +10:00

111 lines
3.9 KiB
Go

package main
import (
"context"
"log/slog"
ctrruntime "github.com/mulgadc/spinifex/cmd/ecs-agent/runtime"
"github.com/mulgadc/spinifex/spinifex/handlers/ecs/bus"
)
// reconcile re-syncs the gateway with what this instance is actually running
// after an agent restart. It lists the runtime's containers and, for each task
// in this cluster, either re-adopts it (still running: re-register IAM
// credentials, re-attach the exit-wait, refresh RUNNING) or reports it STOPPED
// and clears its leftovers. The returned set seeds pollAssignments so the
// gateway's re-delivered assignments for these tasks are acked but not re-run.
//
// Reporting the dead ones is the load-bearing half. The exit-wait is cancelled
// when the agent shuts down, so a container that dies while the agent is down
// has nobody to report its exit; the gateway holds the task RUNNING forever,
// its service never replaces it, and the target never recovers.
//
// It is best-effort: a nil runner or a List error returns an empty set and the
// agent falls back to its pre-reconcile behaviour (poll + run).
func (a *Agent) reconcile(ctx context.Context) map[string]bool {
adopted := map[string]bool{}
if a.runner == nil {
return adopted
}
containers, err := a.runner.List(ctx)
if err != nil {
slog.Warn("ecs-agent: reconcile list failed", "err", err)
return adopted
}
// Group this cluster's containers back into their tasks, running or not. The
// Assign is reconstructed from labels — enough to re-register creds and tear
// down netns.
type task struct {
as *bus.Assign
running []ctrruntime.Container
dead []ctrruntime.Container
}
tasks := map[string]*task{}
for _, c := range containers {
if c.Labels[labelClusterName] != a.id.ClusterName {
continue
}
taskID := c.Labels[labelTaskID]
if taskID == "" {
continue
}
t := tasks[taskID]
if t == nil {
t = &task{as: &bus.Assign{
TaskID: taskID,
ClusterName: a.id.ClusterName,
CredID: c.Labels[labelCredID],
TaskRoleARN: c.Labels[labelTaskRoleARN],
ENIMacAddress: c.Labels[labelENIMac],
}}
tasks[taskID] = t
}
if c.Running {
t.running = append(t.running, c)
} else {
t.dead = append(t.dead, c)
}
}
for taskID, t := range tasks {
// A task with nothing running is over, whatever the gateway still believes.
// Mixed running/dead is left to the running path: the exit-wait re-attached
// below reports the individual container when it is reaped.
if len(t.running) == 0 {
a.reportStoppedTask(ctx, t.as, t.dead)
adopted[taskID] = true
continue
}
a.registerTaskCreds(t.as)
statuses := make([]bus.ContainerStatus, 0, len(t.running))
for _, c := range t.running {
name := c.Labels[labelContainerName]
go a.waitContainer(ctx, t.as, name, c.ID)
statuses = append(statuses, bus.ContainerStatus{Name: name, Status: bus.TaskStatusRunning, ContainerID: c.ID})
}
a.reportTaskState(t.as, bus.TaskStatusRunning, "", statuses)
adopted[taskID] = true
slog.Info("ecs-agent: re-adopted running task", "task", taskID, "containers", len(t.running))
}
return adopted
}
// reportStoppedTask clears a task's leftover containers and tells the gateway it
// is STOPPED, so the scheduler stops counting a task this instance is not
// running and its service can place a replacement.
func (a *Agent) reportStoppedTask(ctx context.Context, as *bus.Assign, dead []ctrruntime.Container) {
statuses := make([]bus.ContainerStatus, 0, len(dead))
for _, c := range dead {
if err := a.runner.Remove(ctx, c.ID); err != nil {
slog.Warn("ecs-agent: reconcile remove failed", "task", as.TaskID, "container", c.ID, "err", err)
}
statuses = append(statuses, bus.ContainerStatus{
Name: c.Labels[labelContainerName], Status: bus.TaskStatusStopped, ContainerID: c.ID,
})
}
a.reportTaskState(as, bus.TaskStatusStopped, "Container exited while the agent was not running", statuses)
slog.Info("ecs-agent: reported stopped task", "task", as.TaskID, "containers", len(dead))
}