mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-22 20:26:44 +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.
105 lines
2.9 KiB
Go
105 lines
2.9 KiB
Go
package credentials
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func imdsStub(t *testing.T, hits *int32, creds map[string]string) *httptest.Server {
|
|
t.Helper()
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/token", func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte("v2-token"))
|
|
})
|
|
mux.HandleFunc("/meta-data/iam/security-credentials/", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/meta-data/iam/security-credentials/" {
|
|
_, _ = w.Write([]byte("node-role"))
|
|
return
|
|
}
|
|
if hits != nil {
|
|
atomic.AddInt32(hits, 1)
|
|
}
|
|
_ = json.NewEncoder(w).Encode(creds)
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
func TestRetrieve_FetchesAndCaches(t *testing.T) {
|
|
var hits int32
|
|
exp := time.Now().Add(6 * time.Hour).UTC().Round(time.Second)
|
|
srv := imdsStub(t, &hits, map[string]string{
|
|
"AccessKeyId": "AKIA",
|
|
"SecretAccessKey": "secret",
|
|
"Token": "session",
|
|
"Expiration": exp.Format(time.RFC3339),
|
|
})
|
|
|
|
p := NewIMDSProvider(srv.Client(), srv.URL)
|
|
got, err := p.Retrieve(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Retrieve: %v", err)
|
|
}
|
|
if got.AccessKeyID != "AKIA" || !got.Expiration.Equal(exp) {
|
|
t.Errorf("creds mismatch: %+v", got)
|
|
}
|
|
|
|
// Second call within validity window must not re-hit IMDS.
|
|
if _, err := p.Retrieve(context.Background()); err != nil {
|
|
t.Fatalf("Retrieve 2: %v", err)
|
|
}
|
|
if hits != 1 {
|
|
t.Errorf("IMDS credential hits = %d, want 1 (cached)", hits)
|
|
}
|
|
}
|
|
|
|
func TestRetrieve_RefetchesWhenStale(t *testing.T) {
|
|
var hits int32
|
|
// Expiry inside the refresh margin → never cached, always refetched.
|
|
srv := imdsStub(t, &hits, map[string]string{
|
|
"AccessKeyId": "AKIA",
|
|
"SecretAccessKey": "secret",
|
|
"Expiration": time.Now().Add(time.Minute).UTC().Format(time.RFC3339),
|
|
})
|
|
p := NewIMDSProvider(srv.Client(), srv.URL)
|
|
if _, err := p.Retrieve(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := p.Retrieve(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if hits != 2 {
|
|
t.Errorf("hits = %d, want 2 (stale refetch)", hits)
|
|
}
|
|
}
|
|
|
|
func TestRetrieve_CancelledContext(t *testing.T) {
|
|
srv := imdsStub(t, nil, map[string]string{"AccessKeyId": "A", "SecretAccessKey": "B"})
|
|
p := NewIMDSProvider(srv.Client(), srv.URL)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if _, err := p.Retrieve(ctx); err == nil {
|
|
t.Fatal("expected error from cancelled context")
|
|
}
|
|
}
|
|
|
|
func TestCredentials_Valid(t *testing.T) {
|
|
if (Credentials{}).Valid(0) {
|
|
t.Error("empty creds should be invalid")
|
|
}
|
|
noExp := Credentials{AccessKeyID: "a", SecretAccessKey: "b"}
|
|
if !noExp.Valid(time.Hour) {
|
|
t.Error("zero expiry should be treated valid")
|
|
}
|
|
soon := Credentials{AccessKeyID: "a", SecretAccessKey: "b", Expiration: time.Now().Add(time.Minute)}
|
|
if soon.Valid(5 * time.Minute) {
|
|
t.Error("expiry inside margin should be invalid")
|
|
}
|
|
}
|