Files
spinifex/cmd/ecs-agent/controlplane.go
T
Josh Siviour 98fc01764b feat(ecs): GPU passthrough to task containers (Epic C) (#512)
* feat(ecs): thread resourceRequirements GPU count through conv, ContainerDef, AssignContainer and RunSpec

* feat(ecs): add GPU placement dimension — InstanceRecord capacity, fits/reserve/release, STRINGSET resource reporting

* feat(ecs): agent GPU discovery via nvidia-smi, pinning ledger, device-UUID report-back and DescribeTasks gpuIds

* feat(ecs): inject assigned GPU devices into containerd OCI spec via CDI (oci.WithCDIDevices)

* test(ecs): add GPU task e2e (nvidia-smi container, gpuIds assertion) to gpu e2e suite

* test(gpu): decouple base-AMI requirement from GPU fixture so ECS e2e runs without the ubuntu EC2 image

* test(gpu): pick smallest advertised GPU instance type to fit a single host

* fix(ecs): preserve pinned GPU UUIDs across STOPPED state change so DescribeTasks reports gpuIds for one-shot tasks
2026-07-13 10:54:55 +10:00

156 lines
5.7 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)
// ReportTaskGPU reports the local ledger's pinned device UUIDs for a task's
// containers, so the control plane can populate DescribeTasks gpuIds.
ReportTaskGPU(cluster, task string, containers []handlers_ecs.ContainerGPUReport) 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)},
}
if len(id.Capacity.GPUIDs) > 0 {
in.TotalResources = append(in.TotalResources, &ecs.Resource{
Name: aws.String("GPU"), Type: aws.String("STRINGSET"), StringSetValue: aws.StringSlice(id.Capacity.GPUIDs),
})
}
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
}
func (g *gatewayControlPlane) ReportTaskGPU(cluster, task string, containers []handlers_ecs.ContainerGPUReport) error {
in := &handlers_ecs.ReportTaskGPUInput{Cluster: cluster, Task: task, Containers: containers}
body, err := json.Marshal(in)
if err != nil {
return fmt.Errorf("marshal report-task-gpu: %w", err)
}
if _, err := g.client.Call("ReportTaskGPU", body); err != nil {
return fmt.Errorf("report task gpu: %w", err)
}
return nil
}