mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-22 12:16:52 +00:00
de64d6c63d
* feat(ecsgw): on-VM SigV4 client for the ECS agent→gateway channel
Adds internal/ecsgw, the twin of internal/eksgw: a TLS + SigV4 (service "ecs")
client the ecs-agent will use to reach the AWS gateway over HTTPS instead of
connecting to host NATS directly. Calls are AWS JSON 1.1 with an X-Amz-Target
action; the gateway relays onto ecs.* host-side, keeping NATS cluster-internal.
* feat(ecs): SubmitTaskStateChange gateway action → recordTaskState
Wires the AWS-API SubmitTaskStateChange action end to end (gateway action →
ecs.SubmitTaskStateChange NATS → daemon → Service.SubmitTaskStateChange) so a
gateway-routed agent reports task state without touching the NATS bus. The
handler maps the SDK input onto the same bus.TaskState shape and converges on
recordTaskState; the account is authoritative from the SigV4 caller, so an
instance cannot report state for another account's task.
* feat(ecs): durable assign KV inbox + PollAssignments gateway action
Scheduler writes task assigns into a per-instance KV inbox
(clusters/{cluster}/assignments/{instance}/{task}) instead of a
fire-and-forget core-NATS publish, and a new PollAssignments gateway
action lets a gateway-routed agent drain its inbox over TLS+SigV4.
At-least-once: an unacked assign is re-delivered on the next poll;
the agent acks accepted assigns (AckTaskIDs) and STOPPED transitions
reclaim stale inbox entries. The Poll response carries bus.Assign with
an RFC3339 time, so it is encoded with encoding/json (RawJSONActions)
rather than the jsonutil epoch-second marshaler.
* feat(ecs-agent): rewire control-plane off NATS onto the AWS gateway
The agent no longer connects to NATS. It registers, heartbeats, reports
task state, and receives assignments entirely through the AWS gateway
over TLS+SigV4, using seeded IAM credentials — keeping the NATS bus
host-internal and closing the trust-boundary gap (a tenant container
instance must not hold a shared bus token).
- controlPlane seam over ecsgw.Client replaces the NATS publisher:
Register (RegisterContainerInstance), SubmitTaskState
(SubmitTaskStateChange), PollAssignments.
- Heartbeat folds into a periodic idempotent re-register (refreshes
LastSeen); bus.Heartbeat is dropped from the agent.
- subscribeAssign (NATS subscribe) becomes a poll loop: drain the
inbox, dispatch each assign once, ack accepted taskIDs on the next
poll (at-least-once).
- config: ECS_ACCESS_KEY / ECS_SECRET_KEY / ECS_POLL_INTERVAL added,
ECS_NATS_URL removed; cloud-init initd seeds the IAM creds.
Tests move from the NATS publisher fake to a controlPlane fake.
* feat(ecs): cloud-init cred-seed + ecs-node bring-up script
ECS container instances are plain EC2 booted from the spinifex-ecs-node
AMI (AWS-faithful: no ECS-specific launch API). This renders the
cloud-init user-data that seeds /etc/spinifex-ecs/agent.env (gateway
URL/CA/region/cluster + IAM creds) and the gateway CA, then runs the
instance. The agent registers, heartbeats, polls assignments, and
reports task state entirely through the AWS gateway over TLS+SigV4 —
never NATS.
The guest dials the customer-facing gateway address (network advertise
IP), not the mgmt bridge, mirroring the EKS GatewayURL/AddonGatewayURL
split. Seeds the system key for now; scoped per-instance creds are a
follow-up (4g task-role endpoint).
* fix(ecs): reconcile dev merge — restore #418 ENI release + netns wiring
The 'Merge branch dev' resolution dropped code where Sprint 4d (#418,
awsvpc ENI-per-task) and the gateway control-plane rewrite (#419) touch
the same paths:
- recordTaskState STOPPED branch: reclaimTaskENI was clobbered by
reclaimAssignInbox; both are needed (release the task ENI AND drain the
KV inbox). Restores TestRecordTaskState_Awsvpc_ReleasesENIOnStopOnce.
- ecs-agent Agent: the netns field, its init, and the setupTaskNetns/
teardownTaskNetns wrappers were lost when the controlPlane Agent struct
won the merge, leaving taskrun.go calling undefined methods.
- eni_test assign assertion read the assign over NATS; #419 delivers it via
the durable KV inbox, so it now polls via PollAssignments.
- taskrun_test netns cases used the removed historyPub fake; switched to
fakeCP to match the controlPlane seam.
166 lines
5.8 KiB
Go
166 lines
5.8 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
ctrruntime "github.com/mulgadc/spinifex/cmd/ecs-agent/runtime"
|
|
"github.com/mulgadc/spinifex/spinifex/handlers/ecs/bus"
|
|
)
|
|
|
|
// pollAssignments drains the instance's assignment inbox on a ticker until ctx is
|
|
// cancelled. Each assign is dispatched to runTask exactly once; the taskIDs seen
|
|
// on a poll are acked on the next poll so the gateway can drop them — a crash
|
|
// before ack re-delivers (at-least-once), matching ACS. A failed poll is logged
|
|
// and retried on the next tick rather than killing the loop.
|
|
func (a *Agent) pollAssignments(ctx context.Context) {
|
|
ticker := time.NewTicker(a.cfg.PollInterval)
|
|
defer ticker.Stop()
|
|
|
|
dispatched := map[string]bool{}
|
|
var ackNext []string
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
assigns, err := a.cp.PollAssignments(a.id.ClusterName, a.id.InstanceID, ackNext)
|
|
if err != nil {
|
|
slog.Warn("ecs-agent: poll assignments failed", "err", err)
|
|
continue
|
|
}
|
|
ackNext = ackNext[:0]
|
|
for i := range assigns {
|
|
as := assigns[i]
|
|
if !dispatched[as.TaskID] {
|
|
dispatched[as.TaskID] = true
|
|
slog.Info("ecs-agent: task assigned", "task", as.TaskID, "containers", len(as.Containers))
|
|
go a.runTask(ctx, &as)
|
|
}
|
|
ackNext = append(ackNext, as.TaskID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// runTask pulls each container image, starts the containers, and reports task
|
|
// state on the bus. A missing runtime or any per-container failure reports the
|
|
// task STOPPED with a reason; success reports RUNNING and waits for exit.
|
|
func (a *Agent) runTask(ctx context.Context, as *bus.Assign) {
|
|
if a.puller == nil || a.runner == nil {
|
|
a.reportTaskState(as, bus.TaskStatusStopped, "containerd unavailable on agent", nil)
|
|
return
|
|
}
|
|
|
|
// awsvpc: build the task netns from the hot-plugged ENI before any container.
|
|
netnsPath, err := a.setupTaskNetns(as)
|
|
if err != nil {
|
|
slog.Error("ecs-agent: task netns setup failed", "task", as.TaskID, "err", err)
|
|
a.reportTaskState(as, bus.TaskStatusStopped, "network setup failed: "+err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
statuses := make([]bus.ContainerStatus, 0, len(as.Containers))
|
|
for _, c := range as.Containers {
|
|
if _, err := a.puller.Pull(ctx, ctrruntime.PullSpec{Ref: c.Image}, a.resolver); err != nil {
|
|
slog.Error("ecs-agent: pull failed", "task", as.TaskID, "image", c.Image, "err", err)
|
|
a.teardownTaskNetns(as)
|
|
a.reportTaskState(as, bus.TaskStatusStopped, "image pull failed: "+err.Error(), statuses)
|
|
return
|
|
}
|
|
|
|
cid := containerID(as.TaskID, c.Name)
|
|
spec := ctrruntime.RunSpec{
|
|
Image: c.Image,
|
|
Command: c.Command,
|
|
Env: c.Environment,
|
|
Labels: taskLabels(as, c.Name),
|
|
NetnsPath: netnsPath,
|
|
}
|
|
id, err := a.runner.Run(ctx, cid, spec)
|
|
if err != nil {
|
|
slog.Error("ecs-agent: run failed", "task", as.TaskID, "container", c.Name, "err", err)
|
|
a.teardownTaskNetns(as)
|
|
a.reportTaskState(as, bus.TaskStatusStopped, "container start failed: "+err.Error(), statuses)
|
|
return
|
|
}
|
|
statuses = append(statuses, bus.ContainerStatus{Name: c.Name, Status: bus.TaskStatusRunning, ContainerID: id})
|
|
go a.waitContainer(ctx, as, c.Name, id)
|
|
}
|
|
|
|
a.reportTaskState(as, bus.TaskStatusRunning, "", statuses)
|
|
slog.Info("ecs-agent: task running", "task", as.TaskID, "containers", len(statuses))
|
|
}
|
|
|
|
// waitContainer blocks until a container exits, then reports the task STOPPED
|
|
// with the exit code. v1 stops the whole task when its first container exits.
|
|
func (a *Agent) waitContainer(ctx context.Context, as *bus.Assign, name, containerID string) {
|
|
status, err := a.runner.Wait(ctx, containerID)
|
|
if err != nil {
|
|
slog.Warn("ecs-agent: wait container failed", "task", as.TaskID, "container", name, "err", err)
|
|
return
|
|
}
|
|
exit := status.ExitCode
|
|
a.teardownTaskNetns(as)
|
|
statuses := []bus.ContainerStatus{{Name: name, Status: bus.TaskStatusStopped, ContainerID: containerID, ExitCode: &exit}}
|
|
a.reportTaskState(as, bus.TaskStatusStopped, fmt.Sprintf("container %s exited (%d)", name, exit), statuses)
|
|
slog.Info("ecs-agent: task stopped", "task", as.TaskID, "container", name, "exitCode", exit)
|
|
}
|
|
|
|
// setupTaskNetns builds the awsvpc task netns from the hot-plugged ENI. Bridge/
|
|
// host tasks (no ENI MAC) and a nil controller are no-ops returning an empty path.
|
|
func (a *Agent) setupTaskNetns(as *bus.Assign) (string, error) {
|
|
if as.ENIMacAddress == "" || a.netns == nil {
|
|
return "", nil
|
|
}
|
|
return a.netns.Setup(as.TaskID, as.ENIMacAddress)
|
|
}
|
|
|
|
// teardownTaskNetns removes the awsvpc task netns; no-op for bridge/host tasks.
|
|
func (a *Agent) teardownTaskNetns(as *bus.Assign) {
|
|
if as.ENIMacAddress == "" || a.netns == nil {
|
|
return
|
|
}
|
|
if err := a.netns.Teardown(as.TaskID); err != nil {
|
|
slog.Warn("ecs-agent: task netns teardown", "task", as.TaskID, "err", err)
|
|
}
|
|
}
|
|
|
|
// reportTaskState reports a task transition through the gateway's
|
|
// SubmitTaskStateChange action.
|
|
func (a *Agent) reportTaskState(as *bus.Assign, status, reason string, containers []bus.ContainerStatus) {
|
|
if a.cp == nil {
|
|
return
|
|
}
|
|
msg := bus.TaskState{
|
|
AccountID: a.id.AccountID,
|
|
ClusterName: a.id.ClusterName,
|
|
InstanceID: a.id.InstanceID,
|
|
TaskID: as.TaskID,
|
|
LastStatus: status,
|
|
Containers: containers,
|
|
Reason: reason,
|
|
ReportedAt: time.Now().UTC(),
|
|
}
|
|
if err := a.cp.SubmitTaskState(msg); err != nil {
|
|
slog.Error("ecs-agent: report task-state failed", "task", as.TaskID, "err", err)
|
|
}
|
|
}
|
|
|
|
// containerID composes a containerd-valid container ID from a task + container.
|
|
func containerID(taskID, name string) string {
|
|
return fmt.Sprintf("%s-%s", taskID, name)
|
|
}
|
|
|
|
// taskLabels are the mulga.ecs.* labels stamped on every container so the reboot
|
|
// reconciler can re-associate running containers with their task on restart.
|
|
func taskLabels(as *bus.Assign, name string) map[string]string {
|
|
return map[string]string{
|
|
"mulga.ecs.taskID": as.TaskID,
|
|
"mulga.ecs.containerName": name,
|
|
"mulga.ecs.clusterName": as.ClusterName,
|
|
}
|
|
}
|