Files
Josh Siviour 9a6bf692ca feat(ecs): EC2 capacity provisioning + auto-EIP service tasks + task/service detail pages (#431)
* feat(ecs): provision EC2 container-instance capacity via IMDS instance-role

Adds a ProvisionCapacity gateway action that launches spinifex-ecs-node
EC2 instances which register as ECS container instances, fixing
RESOURCE:placement when a cluster has no capacity.

Agent control-plane now SigV4-signs gateway calls with instance-role
credentials read from IMDS (no static keys seeded in user-data). The
gateway already verifies STS/temp creds, so the instance role carries an
ecs:* policy + ec2 trust; ensureECSInstanceProfile find-or-creates the
ecsInstanceRole instance profile attached at launch.

Backend resolves the spinifex-ecs-node AMI by spinifex:managed-by=ecs
tag, renders keyless user-data, and launches via the customer
RunInstances path. Frontend adds a hand-signed JSON-1.1 ProvisionCapacity
call, a provision-capacity dialog on the container-instances tab, and the
ECS console section nav + system-image gating.

* feat: auto-assign EIPs to awsvpc service tasks; add ECS task/service detail pages

Wire AssignPublicIp=ENABLED through the service reconciler so awsvpc service
tasks automatically allocate and associate an Elastic IP on the RUNNING
transition, and release it on STOP. assignTaskPublicIP/releaseTaskPublicIP
mirror the target-group registration hooks in recordTaskState and forceStopTask;
the public IP and allocation ID are persisted on the task record and surfaced in
DescribeTasks attachments as publicIPv4Address.

Add AWS-parity ECS task and service detail pages showing private and public
endpoints, container state, and networking configuration, reachable by clicking
a task or service in the cluster tabs.

* test: cover natsEIPManager allocate/associate/release over NATS
2026-06-26 14:58:31 +10:00

88 lines
2.6 KiB
Go

package ecsgw
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// testCreds returns a fixed temporary credential set (with a session token, as
// IMDS instance-role creds carry).
func testCreds(context.Context) (string, string, string, error) {
return "ASIATEST", "secret", "session-token-xyz", nil
}
func newTestClient(t *testing.T, srv *httptest.Server) *Client {
t.Helper()
c, err := New(srv.URL, "", testCreds, "ap-southeast-2", time.Second)
if err != nil {
t.Fatalf("New: %v", err)
}
c.httpClient = srv.Client()
return c
}
// Call signs for service "ecs", sets the X-Amz-Target action, and returns the
// 2xx body.
func TestClient_CallSignsAndTargets(t *testing.T) {
var gotTarget, gotAuth, gotBody, gotToken string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotTarget = r.Header.Get("X-Amz-Target")
gotAuth = r.Header.Get("Authorization")
gotToken = r.Header.Get("X-Amz-Security-Token")
b, _ := io.ReadAll(r.Body)
gotBody = string(b)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
c := newTestClient(t, srv)
out, err := c.Call("RegisterContainerInstance", []byte(`{"instanceId":"i-1"}`))
if err != nil {
t.Fatalf("Call: %v", err)
}
if string(out) != `{"ok":true}` {
t.Errorf("body = %q", out)
}
if gotTarget != targetPrefix+"RegisterContainerInstance" {
t.Errorf("target = %q", gotTarget)
}
if gotBody != `{"instanceId":"i-1"}` {
t.Errorf("relayed body = %q", gotBody)
}
if !strings.Contains(gotAuth, "AWS4-HMAC-SHA256") || !strings.Contains(gotAuth, "/ecs/aws4_request") {
t.Errorf("auth header not SigV4 for ecs: %q", gotAuth)
}
if gotToken != "session-token-xyz" {
t.Errorf("X-Amz-Security-Token = %q, want the IMDS session token", gotToken)
}
}
// A non-2xx gateway response surfaces as an error carrying status + body.
func TestClient_CallErrorStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"__type":"AccessDenied"}`))
}))
defer srv.Close()
c := newTestClient(t, srv)
if _, err := c.Call("Heartbeat", []byte(`{}`)); err == nil ||
!strings.Contains(err.Error(), "403") || !strings.Contains(err.Error(), "AccessDenied") {
t.Fatalf("want 403 AccessDenied error, got %v", err)
}
}
func TestNew_Validation(t *testing.T) {
if _, err := New("", "", testCreds, "r", 0); err == nil {
t.Error("want error for empty baseURL")
}
if _, err := New("https://gw:9999", "", nil, "r", 0); err == nil {
t.Error("want error for nil credentials func")
}
}