mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-20 11:21:49 +00:00
dfd7cfaa2b
* fix: ECS StopTask reaps container before releasing capacity StopTask marked a task lastStatus=STOPPED and released its capacity/ENI in the control plane, but nothing ever told the ecs-agent to kill the container. The container kept holding its host ports, so DescribeTasks reported a STOPPED task whose container was still RUNNING, and a replacement task placed on the freed capacity that rebound the same host port hit EADDRINUSE. Wire the agent-driven cooperative stop the design intended (ecs-v1.md:165): - Add a per-instance stop inbox (StopsPrefix/StopKey) and bus.StopDirective. - requestStopTask marks DesiredStatus=STOPPED and posts a stop directive, leaving lastStatus/capacity/ENI/targets untouched. StopTask, DeleteService, and service scale-in use it; the heartbeat reaper and cluster/instance teardown keep the immediate forceStopTask since no live agent can reap. - PollAssignments returns pending stops; the agent lists containers by mulga.ecs.taskID label, Removes them (kill+delete, freeing ports/netns), then reports STOPPED. recordTaskState performs the single capacity release on the agent's STOPPED report and reclaims the stop inbox. - listServiceTasks excludes DesiredStatus=STOPPED tasks so a cooperatively stopped task drops out of the service's running total immediately. DescribeTasks now keeps the task RUNNING with desiredStatus=STOPPED until the container is actually reaped, matching AWS ordering and eliminating the port race. * test: cover StopTask reap paths — poll stop-inbox drain, unplaced force-stop, agent stopTask edges * ci: retrigger PR checks
136 lines
4.8 KiB
Go
136 lines
4.8 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go/aws"
|
|
"github.com/aws/aws-sdk-go/service/ecs"
|
|
|
|
"github.com/mulgadc/spinifex/cmd/ecs-agent/credentials"
|
|
"github.com/mulgadc/spinifex/internal/ecsgw"
|
|
handlers_ecs "github.com/mulgadc/spinifex/spinifex/handlers/ecs"
|
|
"github.com/mulgadc/spinifex/spinifex/handlers/ecs/bus"
|
|
)
|
|
|
|
// controlPlane is the agent's channel to the AWS gateway. It replaces the direct
|
|
// NATS publisher: every method is a SigV4-signed gateway request, so the NATS bus
|
|
// stays host-internal. Tests inject a fake; production uses gatewayControlPlane.
|
|
type controlPlane interface {
|
|
// Register (re-)registers the container instance. Called at boot and on every
|
|
// heartbeat tick — the gateway's RegisterContainerInstance is idempotent and
|
|
// refreshes LastSeen, folding liveness into registration (no bus.Heartbeat).
|
|
Register(id identity) error
|
|
// SubmitTaskState reports a task's RUNNING/STOPPED transition.
|
|
SubmitTaskState(st bus.TaskState) error
|
|
// PollAssignments drains this instance's assignment + stop inboxes, acking the
|
|
// assign taskIDs and stop taskIDs accepted on the previous poll. Returns the
|
|
// still-pending assignments and stop directives.
|
|
PollAssignments(cluster, instance string, ackAssigns, ackStops []string) ([]bus.Assign, []bus.StopDirective, error)
|
|
}
|
|
|
|
// gatewayControlPlane implements controlPlane over the SigV4 ECS gateway client.
|
|
type gatewayControlPlane struct {
|
|
client *ecsgw.Client
|
|
}
|
|
|
|
var _ controlPlane = (*gatewayControlPlane)(nil)
|
|
|
|
// pollTimeout bounds a single PollAssignments request; larger than the register
|
|
// timeout to leave room for a future server-side long-poll.
|
|
const pollTimeout = 30 * time.Second
|
|
|
|
// newGatewayControlPlane builds the SigV4 client signing with the instance-role
|
|
// credentials from IMDS (fetched per call so rotation is transparent) + pinned CA.
|
|
func newGatewayControlPlane(cfg config, creds credentials.CredentialsProvider) (*gatewayControlPlane, error) {
|
|
client, err := ecsgw.New(cfg.GatewayURL, cfg.GatewayCA, credentialsFunc(creds), cfg.Region, pollTimeout)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &gatewayControlPlane{client: client}, nil
|
|
}
|
|
|
|
// credentialsFunc adapts the agent's IMDS CredentialsProvider to the ecsgw
|
|
// per-call credentials hook.
|
|
func credentialsFunc(p credentials.CredentialsProvider) ecsgw.CredentialsFunc {
|
|
return func(ctx context.Context) (string, string, string, error) {
|
|
c, err := p.Retrieve(ctx)
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
return c.AccessKeyID, c.SecretAccessKey, c.SessionToken, nil
|
|
}
|
|
}
|
|
|
|
func (g *gatewayControlPlane) Register(id identity) error {
|
|
in := &ecs.RegisterContainerInstanceInput{
|
|
Cluster: aws.String(id.ClusterName),
|
|
InstanceIdentityDocument: aws.String(id.InstanceID),
|
|
TotalResources: []*ecs.Resource{
|
|
{Name: aws.String("CPU"), Type: aws.String("INTEGER"), IntegerValue: aws.Int64(int64(id.Capacity.CPU))},
|
|
{Name: aws.String("MEMORY"), Type: aws.String("INTEGER"), IntegerValue: aws.Int64(int64(id.Capacity.MemoryMiB))},
|
|
},
|
|
VersionInfo: &ecs.VersionInfo{AgentVersion: aws.String(id.AgentVersion)},
|
|
}
|
|
body, err := json.Marshal(in)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal register: %w", err)
|
|
}
|
|
if _, err := g.client.Call("RegisterContainerInstance", body); err != nil {
|
|
return fmt.Errorf("register: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (g *gatewayControlPlane) SubmitTaskState(st bus.TaskState) error {
|
|
in := &ecs.SubmitTaskStateChangeInput{
|
|
Cluster: aws.String(st.ClusterName),
|
|
Task: aws.String(st.TaskID),
|
|
Status: aws.String(st.LastStatus),
|
|
Reason: aws.String(st.Reason),
|
|
}
|
|
for _, c := range st.Containers {
|
|
csc := &ecs.ContainerStateChange{
|
|
ContainerName: aws.String(c.Name),
|
|
Status: aws.String(c.Status),
|
|
RuntimeId: aws.String(c.ContainerID),
|
|
}
|
|
if c.ExitCode != nil {
|
|
csc.ExitCode = aws.Int64(int64(*c.ExitCode))
|
|
}
|
|
in.Containers = append(in.Containers, csc)
|
|
}
|
|
body, err := json.Marshal(in)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal task-state: %w", err)
|
|
}
|
|
if _, err := g.client.Call("SubmitTaskStateChange", body); err != nil {
|
|
return fmt.Errorf("submit task-state: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (g *gatewayControlPlane) PollAssignments(cluster, instance string, ackAssigns, ackStops []string) ([]bus.Assign, []bus.StopDirective, error) {
|
|
in := &handlers_ecs.PollAssignmentsInput{
|
|
Cluster: cluster,
|
|
ContainerInstance: instance,
|
|
AckTaskIDs: ackAssigns,
|
|
AckStopIDs: ackStops,
|
|
}
|
|
body, err := json.Marshal(in)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("marshal poll: %w", err)
|
|
}
|
|
resp, err := g.client.Call("PollAssignments", body)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("poll: %w", err)
|
|
}
|
|
var out handlers_ecs.PollAssignmentsOutput
|
|
if err := json.Unmarshal(resp, &out); err != nil {
|
|
return nil, nil, fmt.Errorf("decode poll: %w", err)
|
|
}
|
|
return out.Assignments, out.Stops, nil
|
|
}
|