mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-20 11:21:49 +00:00
93db7e4d1b
* feat(ecs): ecs-agent skeleton + containerd image pull (Sprint 4c) Add the ecs-agent binary that runs inside an ECS container instance: it resolves host identity from IMDS, registers on the Layer-2 NATS bus, and heartbeats while alive. Image pulls go through a containerd-backed ImagePuller behind an interface, with an ECR resolver that mints GetAuthorizationToken creds from the instance role. Extract the IMDS-credential and ECR-auth flow out of ecr-credential-provider into internal/imdscreds and internal/ecrauth so the agent and the kubelet provider share one implementation. Add spinifex/handlers/ecs/bus as the single source of truth for Layer-2 subject builders and wire payloads, shared by the agent now and the scheduler in Sprint 4e. * build: pin genproto via replace to fix containerd/v2 ambiguous import go.opencensus.io -> grpc@v1.33.2 drags the pre-split monolithic genproto into the module graph; it still ships googleapis/rpc/status, which collides with the split genproto/googleapis/rpc module that containerd/ttrpc needs. The main module never imports the monolith, so a plain require bump is dropped by 'go mod tidy'. Pin it with a replace to a post-split version so the fix survives tidy and contributors can run 'go mod tidy' freely. * fix(ecs): agent boot failsafes from runtime verify - Build the ECR gateway HTTP client lazily on first image pull instead of at boot. A missing/malformed gateway CA no longer kills the agent before it can register + heartbeat, matching the best-effort containerd path. - Actively probe containerd with IsServing in runtime.New. The client dials lazily, so a dead socket previously slipped past the boot-time 'containerd unavailable' warn and only surfaced on the first pull. - Make the heartbeat interval configurable via ECS_HEARTBEAT_INTERVAL (default 30s) for test/verify and slow-link tuning.
108 lines
3.5 KiB
Go
108 lines
3.5 KiB
Go
package ecrauth
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestHostFromImage(t *testing.T) {
|
|
cases := map[string]string{
|
|
"acct.dkr.ecr.us-east-1.spinifex.internal/demo:latest": "acct.dkr.ecr.us-east-1.spinifex.internal",
|
|
"https://acct.dkr.ecr.us-east-1.spinifex.internal": "acct.dkr.ecr.us-east-1.spinifex.internal",
|
|
"host:9999/repo:tag": "host:9999",
|
|
"plainhost": "plainhost",
|
|
}
|
|
for in, want := range cases {
|
|
if got := HostFromImage(in); got != want {
|
|
t.Errorf("HostFromImage(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// gatewayStub serves the ECR GetAuthorizationToken X-Amz-Target dispatch,
|
|
// returning a base64 AWS:<jwt> token like the real gateway.
|
|
func gatewayStub(t *testing.T, token, proxy string, status int) *httptest.Server {
|
|
t.Helper()
|
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if !strings.Contains(r.Header.Get("X-Amz-Target"), "GetAuthorizationToken") {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
if status != http.StatusOK {
|
|
w.WriteHeader(status)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/x-amz-json-1.1")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"authorizationData": []map[string]any{{
|
|
"authorizationToken": base64.StdEncoding.EncodeToString([]byte(token)),
|
|
"proxyEndpoint": proxy,
|
|
}},
|
|
})
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
func clientTrusting(srv *httptest.Server) *http.Client {
|
|
pool := x509.NewCertPool()
|
|
pool.AddCert(srv.Certificate())
|
|
return &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}}}
|
|
}
|
|
|
|
func TestGetAuthorizationToken_OK(t *testing.T) {
|
|
srv := gatewayStub(t, "AWS:testjwt", "https://123456789012.dkr.ecr.us-east-1.spinifex.internal", http.StatusOK)
|
|
|
|
tok, err := GetAuthorizationToken("us-east-1", srv.URL, clientTrusting(srv), "AKIA", "secret", "session")
|
|
if err != nil {
|
|
t.Fatalf("GetAuthorizationToken: %v", err)
|
|
}
|
|
if tok.Username != "AWS" || tok.Password != "testjwt" {
|
|
t.Errorf("got %q:%q, want AWS:testjwt", tok.Username, tok.Password)
|
|
}
|
|
if tok.ProxyHost != "123456789012.dkr.ecr.us-east-1.spinifex.internal" {
|
|
t.Errorf("ProxyHost = %q", tok.ProxyHost)
|
|
}
|
|
}
|
|
|
|
func TestGetAuthorizationToken_BadTokenForm(t *testing.T) {
|
|
srv := gatewayStub(t, "no-colon-token", "https://x", http.StatusOK)
|
|
if _, err := GetAuthorizationToken("us-east-1", srv.URL, clientTrusting(srv), "AKIA", "secret", ""); err == nil {
|
|
t.Fatal("expected error for token not in user:password form")
|
|
}
|
|
}
|
|
|
|
func TestGetAuthorizationToken_ServerError(t *testing.T) {
|
|
srv := gatewayStub(t, "", "", http.StatusInternalServerError)
|
|
if _, err := GetAuthorizationToken("us-east-1", srv.URL, clientTrusting(srv), "AKIA", "secret", ""); err == nil {
|
|
t.Fatal("expected error on gateway 500")
|
|
}
|
|
}
|
|
|
|
func TestGatewayHTTPClient(t *testing.T) {
|
|
// Empty CA path: system trust store, no error.
|
|
if _, err := GatewayHTTPClient(""); err != nil {
|
|
t.Fatalf("empty CA path: %v", err)
|
|
}
|
|
// Missing file: error.
|
|
if _, err := GatewayHTTPClient(filepath.Join(t.TempDir(), "nope.pem")); err == nil {
|
|
t.Fatal("expected error for missing CA file")
|
|
}
|
|
// Garbage PEM: error.
|
|
bad := filepath.Join(t.TempDir(), "bad.pem")
|
|
if err := os.WriteFile(bad, []byte("not a cert"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := GatewayHTTPClient(bad); err == nil {
|
|
t.Fatal("expected error for CA file with no certificates")
|
|
}
|
|
}
|