mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-21 19:56:33 +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.
146 lines
3.2 KiB
Go
146 lines
3.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/mulgadc/spinifex/spinifex/handlers/ecs/bus"
|
|
)
|
|
|
|
// fakeCP is a test double for the gateway control-plane. It records registers and
|
|
// task-state reports, and replays a scripted queue of poll responses.
|
|
type fakeCP struct {
|
|
mu sync.Mutex
|
|
|
|
registers int
|
|
states []bus.TaskState
|
|
pollAcks [][]string
|
|
pollReplies [][]bus.Assign
|
|
pollCalls int
|
|
|
|
registerErr bool
|
|
submitErr bool
|
|
}
|
|
|
|
var _ controlPlane = (*fakeCP)(nil)
|
|
|
|
func (f *fakeCP) Register(_ identity) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.registerErr {
|
|
return errors.New("register boom")
|
|
}
|
|
f.registers++
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeCP) SubmitTaskState(st bus.TaskState) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.submitErr {
|
|
return errors.New("submit boom")
|
|
}
|
|
f.states = append(f.states, st)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeCP) PollAssignments(_, _ string, ack []string) ([]bus.Assign, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
cp := append([]string(nil), ack...)
|
|
f.pollAcks = append(f.pollAcks, cp)
|
|
var out []bus.Assign
|
|
if f.pollCalls < len(f.pollReplies) {
|
|
out = f.pollReplies[f.pollCalls]
|
|
}
|
|
f.pollCalls++
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeCP) registerCount() int {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.registers
|
|
}
|
|
|
|
func (f *fakeCP) taskStates() []bus.TaskState {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return append([]bus.TaskState(nil), f.states...)
|
|
}
|
|
|
|
func (f *fakeCP) acks() [][]string {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return append([][]string(nil), f.pollAcks...)
|
|
}
|
|
|
|
func testIdentity() identity {
|
|
return identity{
|
|
AccountID: "123456789012",
|
|
ClusterName: "default",
|
|
InstanceID: "i-abc123",
|
|
AZ: "us-east-1a",
|
|
Hostname: "ecs-host",
|
|
Capacity: bus.InstanceCapacity{CPU: 2048, MemoryMiB: 4096},
|
|
AgentVersion: "test",
|
|
}
|
|
}
|
|
|
|
func TestRegistrar_Register(t *testing.T) {
|
|
cp := &fakeCP{}
|
|
if err := newRegistrar(cp, testIdentity()).Register(); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
if cp.registerCount() != 1 {
|
|
t.Errorf("registers = %d, want 1", cp.registerCount())
|
|
}
|
|
}
|
|
|
|
func TestRegistrar_RegisterError(t *testing.T) {
|
|
cp := &fakeCP{registerErr: true}
|
|
if err := newRegistrar(cp, testIdentity()).Register(); err == nil {
|
|
t.Fatal("expected register error")
|
|
}
|
|
}
|
|
|
|
func TestHeartbeater_Beat(t *testing.T) {
|
|
cp := &fakeCP{}
|
|
if err := newHeartbeater(cp, testIdentity(), time.Second).beat(); err != nil {
|
|
t.Fatalf("beat: %v", err)
|
|
}
|
|
if cp.registerCount() != 1 {
|
|
t.Errorf("beat should re-register once, got %d", cp.registerCount())
|
|
}
|
|
}
|
|
|
|
func TestHeartbeater_DefaultInterval(t *testing.T) {
|
|
h := newHeartbeater(&fakeCP{}, testIdentity(), 0)
|
|
if h.interval != defaultHeartbeat {
|
|
t.Errorf("interval = %v, want default", h.interval)
|
|
}
|
|
}
|
|
|
|
func TestHeartbeater_RunStopsOnContext(t *testing.T) {
|
|
cp := &fakeCP{}
|
|
h := newHeartbeater(cp, testIdentity(), 5*time.Millisecond)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
done := make(chan struct{})
|
|
go func() { h.Run(ctx); close(done) }()
|
|
|
|
time.Sleep(25 * time.Millisecond)
|
|
cancel()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("Run did not stop on context cancel")
|
|
}
|
|
if cp.registerCount() == 0 {
|
|
t.Error("expected at least one re-register before cancel")
|
|
}
|
|
}
|