mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-20 03:11:46 +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.
115 lines
4.1 KiB
Go
115 lines
4.1 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go/aws"
|
|
"github.com/aws/aws-sdk-go/service/ecs"
|
|
|
|
"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 inbox, acking the taskIDs
|
|
// accepted on the previous poll. Returns the still-pending assignments.
|
|
PollAssignments(cluster, instance string, ack []string) ([]bus.Assign, 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 from seeded creds + pinned CA.
|
|
func newGatewayControlPlane(cfg config) (*gatewayControlPlane, error) {
|
|
client, err := ecsgw.New(cfg.GatewayURL, cfg.GatewayCA, cfg.AccessKey, cfg.SecretKey, cfg.Region, pollTimeout)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &gatewayControlPlane{client: client}, 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, ack []string) ([]bus.Assign, error) {
|
|
in := &handlers_ecs.PollAssignmentsInput{Cluster: cluster, ContainerInstance: instance, AckTaskIDs: ack}
|
|
body, err := json.Marshal(in)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal poll: %w", err)
|
|
}
|
|
resp, err := g.client.Call("PollAssignments", body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("poll: %w", err)
|
|
}
|
|
var out handlers_ecs.PollAssignmentsOutput
|
|
if err := json.Unmarshal(resp, &out); err != nil {
|
|
return nil, fmt.Errorf("decode poll: %w", err)
|
|
}
|
|
return out.Assignments, nil
|
|
}
|