mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
9a6bf692ca
* 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
88 lines
2.6 KiB
Go
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")
|
|
}
|
|
}
|