diff --git a/docs/service-interfaces.yaml b/docs/service-interfaces.yaml index eea6c10cb..a92305ec8 100644 --- a/docs/service-interfaces.yaml +++ b/docs/service-interfaces.yaml @@ -292,6 +292,14 @@ suites: # via vpcd, AMI via predastore, root volume via viperblockd, lifecycle via # spinifex_daemon. Single-mode, VM-booting leg. + e2e-ecr: + path: tests/e2e/ecr + covers: [awsgw, spinifex_daemon, predastore] + # ECR control plane (handlers/ecr, gateway/ecr*) + OCI data plane: blobs and + # manifests in predastore, control + auth bridge in awsgw, KV via + # spinifex_daemon. docker/crane/skopeo data-plane subtests skip when the + # client binary or registry-host resolution is absent. + e2e-ddil: path: tests/e2e/ddil covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore] diff --git a/tests/e2e/Makefile b/tests/e2e/Makefile index 055336de5..cd5ee24c4 100644 --- a/tests/e2e/Makefile +++ b/tests/e2e/Makefile @@ -1,4 +1,4 @@ -PKG ?= ./cert/... ./lb/... ./eks/... ./iam/... ./multinode/... ./baremetal/... ./reboot/... ./tofu/... +PKG ?= ./cert/... ./lb/... ./eks/... ./iam/... ./ecr/... ./multinode/... ./baremetal/... ./reboot/... ./tofu/... ARTIFACT_DIR ?= /tmp/spinifex-e2e-artifacts TIMEOUT ?= 30m MODE ?= single @@ -11,9 +11,9 @@ SUITE_TAG ?= $(notdir $(patsubst %/...,%,$(firstword $(PKG)))) # named suite into BIN_DIR/.test so CI can ship binaries (not source + # go toolchain) to the remote VM. BIN_DIR ?= $(CURDIR)/_bin -SUITES_BUILD ?= cert lb eks single iam gpu +SUITES_BUILD ?= cert lb eks single iam ecr gpu -.PHONY: e2e e2e-cert e2e-lb e2e-eks e2e-single e2e-iam e2e-multinode e2e-bm e2e-reboot e2e-tofu e2e-gpu e2e-all clean e2e-build +.PHONY: e2e e2e-cert e2e-lb e2e-eks e2e-single e2e-iam e2e-ecr e2e-multinode e2e-bm e2e-reboot e2e-tofu e2e-gpu e2e-all clean e2e-build ifeq ($(GOTESTSUM),1) E2E_CMD = gotestsum \ @@ -37,6 +37,7 @@ e2e-lb: ; $(MAKE) e2e PKG=./lb/... e2e-eks: ; $(MAKE) e2e PKG=./eks/... e2e-single: ; $(MAKE) e2e PKG=./single/... e2e-iam: ; $(MAKE) e2e PKG=./iam/... +e2e-ecr: ; $(MAKE) e2e PKG=./ecr/... e2e-multinode: ; $(MAKE) e2e PKG=./multinode/... MODE=multinode e2e-bm: ; $(MAKE) e2e PKG=./baremetal/... MODE=baremetal e2e-reboot: ; $(MAKE) e2e PKG=./reboot/... diff --git a/tests/e2e/ecr/capacity_test.go b/tests/e2e/ecr/capacity_test.go new file mode 100644 index 000000000..b63a0b46b --- /dev/null +++ b/tests/e2e/ecr/capacity_test.go @@ -0,0 +1,14 @@ +//go:build e2e + +package ecr + +import "testing" + +// TestECRCapacityLoad is the predastore capacity/load leg of Sprint 2f (parallel +// crane push of many large images, asserting predastore throughput with no +// manifest corruption). Deferred: it depends on predastore fixes mulga-siv-359 +// (':'-in-key SigV4 canonicalization) and mulga-siv-356 (404 NoSuchBucket), +// which are owned elsewhere. Unskip once those land. +func TestECRCapacityLoad(t *testing.T) { + t.Skip("deferred: blocked on predastore mulga-siv-359 / mulga-siv-356") +} diff --git a/tests/e2e/ecr/crane_skopeo_test.go b/tests/e2e/ecr/crane_skopeo_test.go new file mode 100644 index 000000000..3eb5fb40a --- /dev/null +++ b/tests/e2e/ecr/crane_skopeo_test.go @@ -0,0 +1,114 @@ +//go:build e2e + +package ecr + +import ( + "archive/tar" + "crypto/rand" + "os" + "path/filepath" + "testing" + "time" + + "github.com/mulgadc/spinifex/tests/e2e/harness" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ociCmdTimeout = 2 * time.Minute + +// writeLayerTar writes a single-file tar (an OCI layer) with random contents and +// returns its path. +func writeLayerTar(t *testing.T, dir string) string { + t.Helper() + path := filepath.Join(dir, "layer.tar") + fh, err := os.Create(path) //nolint:gosec // test-controlled path under TmpDir + require.NoError(t, err) + defer fh.Close() + + payload := make([]byte, 8192) + _, err = rand.Read(payload) + require.NoError(t, err) + + tw := tar.NewWriter(fh) + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: "payload", Mode: 0o600, Size: int64(len(payload)), + })) + _, err = tw.Write(payload) + require.NoError(t, err) + require.NoError(t, tw.Close()) + return path +} + +// craneLogin authenticates crane against host, writing the auth file under +// dockerConfig so subsequent crane calls reuse it. +func craneLogin(t *testing.T, crane *harness.ExternalCLI, host, pass string) { + t.Helper() + out, err := crane.Run(ociCmdTimeout, "auth", "login", host, "-u", "AWS", "-p", pass) + require.NoErrorf(t, err, "crane auth login: %s", out) +} + +// TestCranePushPull pushes a scratch image with `crane append` and pulls it +// back, exercising the Bearer /v2/token flow crane follows. +func TestCranePushPull(t *testing.T) { + f := requireECRFixture(t) + host := harness.ECRRegistryHost(f.Account) + harness.RequireRegistryResolves(t, host) + + dockerConfig := filepath.Join(f.TmpDir, "crane") + require.NoError(t, os.MkdirAll(dockerConfig, 0o700)) + crane := harness.NewCrane(t, dockerConfig) + craneLogin(t, crane, host, harness.ECRGetLoginPassword(t, f.AWS)) + + repo := uniqueRepo("crane") + harness.CreateECRRepository(t, f.AWS, repo) + ref := harness.ECRRepositoryURI(f.Account, repo) + ":v1" + + layer := writeLayerTar(t, f.TmpDir) + out, err := crane.Run(ociCmdTimeout, "append", "-f", layer, "-t", ref) + require.NoErrorf(t, err, "crane append: %s", out) + + require.True(t, harness.ECRWaitImageTag(t, f.AWS, repo, "v1", 30*time.Second), + "crane-pushed tag v1 not visible via DescribeImages") + + digest, err := crane.Run(ociCmdTimeout, "digest", ref) + require.NoErrorf(t, err, "crane digest: %s", digest) + assert.Contains(t, digest, "sha256:") + + out, err = crane.Run(ociCmdTimeout, "pull", ref, filepath.Join(f.TmpDir, "pulled.tar")) + require.NoErrorf(t, err, "crane pull: %s", out) +} + +// TestSkopeoCopy seeds an image with crane, then `skopeo copy` retags it within +// the registry — exercising skopeo's Bearer auth on both read and write. +func TestSkopeoCopy(t *testing.T) { + f := requireECRFixture(t) + host := harness.ECRRegistryHost(f.Account) + harness.RequireRegistryResolves(t, host) + + dockerConfig := filepath.Join(f.TmpDir, "skopeo") + require.NoError(t, os.MkdirAll(dockerConfig, 0o700)) + crane := harness.NewCrane(t, dockerConfig) + skopeo := harness.NewSkopeo(t, dockerConfig) + + pass := harness.ECRGetLoginPassword(t, f.AWS) + craneLogin(t, crane, host, pass) + + repo := uniqueRepo("skopeo") + harness.CreateECRRepository(t, f.AWS, repo) + srcRef := harness.ECRRepositoryURI(f.Account, repo) + ":src" + dstRef := harness.ECRRepositoryURI(f.Account, repo) + ":dst" + + layer := writeLayerTar(t, f.TmpDir) + out, err := crane.Run(ociCmdTimeout, "append", "-f", layer, "-t", srcRef) + require.NoErrorf(t, err, "crane append: %s", out) + + creds := "AWS:" + pass + out, err = skopeo.Run(ociCmdTimeout, "copy", + "--src-creds", creds, "--dest-creds", creds, + "docker://"+srcRef, "docker://"+dstRef) + require.NoErrorf(t, err, "skopeo copy: %s", out) + + require.True(t, harness.ECRWaitImageTag(t, f.AWS, repo, "dst", 30*time.Second), + "skopeo-copied tag dst not visible via DescribeImages") +} diff --git a/tests/e2e/ecr/docker_push_test.go b/tests/e2e/ecr/docker_push_test.go new file mode 100644 index 000000000..166678b1c --- /dev/null +++ b/tests/e2e/ecr/docker_push_test.go @@ -0,0 +1,80 @@ +//go:build e2e + +package ecr + +import ( + "crypto/rand" + "os" + "path/filepath" + "testing" + "time" + + "github.com/mulgadc/spinifex/tests/e2e/harness" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const dockerCmdTimeout = 2 * time.Minute + +// buildScratchContext writes a hermetic FROM-scratch build context (no network +// pulls) with a random payload layer, returning the context dir. +func buildScratchContext(t *testing.T, dir string) string { + t.Helper() + payload := make([]byte, 4096) + _, err := rand.Read(payload) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "payload"), payload, 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "Dockerfile"), + []byte("FROM scratch\nCOPY payload /payload\n"), 0o600)) + return dir +} + +// TestDockerPushPull drives the canonical workflow: aws ecr get-login-password +// -> docker login -> docker push -> DescribeImages -> docker pull (clean daemon) +// against the cluster registry. +func TestDockerPushPull(t *testing.T) { + f := requireECRFixture(t) + host := harness.ECRRegistryHost(f.Account) + harness.RequireRegistryResolves(t, host) + + dockerConfig := filepath.Join(f.TmpDir, "docker") + require.NoError(t, os.MkdirAll(dockerConfig, 0o700)) + docker := harness.NewDocker(t, dockerConfig) + + repo := uniqueRepo("docker") + harness.CreateECRRepository(t, f.AWS, repo) + ref := harness.ECRRepositoryURI(f.Account, repo) + ":v1" + + t.Run("docker login", func(t *testing.T) { + pass := harness.ECRGetLoginPassword(t, f.AWS) + out, err := docker.RunStdin(dockerCmdTimeout, pass, + "login", host, "-u", "AWS", "--password-stdin") + require.NoErrorf(t, err, "docker login: %s", out) + assert.Contains(t, out, "Login Succeeded") + }) + + t.Run("docker build + push", func(t *testing.T) { + buildDir := filepath.Join(f.TmpDir, "build") + require.NoError(t, os.MkdirAll(buildDir, 0o700)) + ctxDir := buildScratchContext(t, buildDir) + + out, err := docker.Run(dockerCmdTimeout, "build", "-t", ref, ctxDir) + require.NoErrorf(t, err, "docker build: %s", out) + + out, err = docker.Run(dockerCmdTimeout, "push", ref) + require.NoErrorf(t, err, "docker push: %s", out) + }) + + t.Run("DescribeImages shows the pushed tag", func(t *testing.T) { + require.True(t, harness.ECRWaitImageTag(t, f.AWS, repo, "v1", 30*time.Second), + "pushed tag v1 not visible via DescribeImages") + }) + + t.Run("docker pull round-trips from a clean daemon", func(t *testing.T) { + out, err := docker.Run(dockerCmdTimeout, "rmi", "-f", ref) + require.NoErrorf(t, err, "docker rmi: %s", out) + + out, err = docker.Run(dockerCmdTimeout, "pull", ref) + require.NoErrorf(t, err, "docker pull: %s", out) + }) +} diff --git a/tests/e2e/ecr/ecr_test.go b/tests/e2e/ecr/ecr_test.go new file mode 100644 index 000000000..d0081aa29 --- /dev/null +++ b/tests/e2e/ecr/ecr_test.go @@ -0,0 +1,106 @@ +//go:build e2e + +package ecr + +import ( + "fmt" + "time" + + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ecr" + "github.com/mulgadc/spinifex/tests/e2e/harness" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// uniqueRepo returns a collision-resistant repo name scoped to a test. +func uniqueRepo(prefix string) string { + return fmt.Sprintf("e2e/%s-%d", prefix, time.Now().UnixNano()) +} + +// TestECRControlPlane round-trips the ECR control-plane surface: repository +// CRUD, repository policy, and lifecycle policy. Zero VMs, pure SDK. +func TestECRControlPlane(t *testing.T) { + f := requireECRFixture(t) + c := f.AWS + repo := uniqueRepo("ctl") + harness.CreateECRRepository(t, c, repo) + + t.Run("DescribeRepositories lists the new repo", func(t *testing.T) { + out, err := c.ECR.DescribeRepositories(&ecr.DescribeRepositoriesInput{ + RepositoryNames: []*string{aws.String(repo)}, + }) + require.NoError(t, err) + require.Len(t, out.Repositories, 1) + assert.Equal(t, repo, aws.StringValue(out.Repositories[0].RepositoryName)) + assert.Contains(t, aws.StringValue(out.Repositories[0].RepositoryUri), repo) + }) + + t.Run("repository policy round-trips", func(t *testing.T) { + policy := `{"Version":"2012-10-17","Statement":[{"Sid":"allow-pull","Effect":"Allow","Principal":"*","Action":["ecr:GetDownloadUrlForLayer","ecr:BatchGetImage"]}]}` + _, err := c.ECR.SetRepositoryPolicy(&ecr.SetRepositoryPolicyInput{ + RepositoryName: aws.String(repo), + PolicyText: aws.String(policy), + }) + require.NoError(t, err) + + got, err := c.ECR.GetRepositoryPolicy(&ecr.GetRepositoryPolicyInput{ + RepositoryName: aws.String(repo), + }) + require.NoError(t, err) + assert.JSONEq(t, policy, aws.StringValue(got.PolicyText)) + }) + + t.Run("lifecycle policy round-trips", func(t *testing.T) { + policy := `{"rules":[{"rulePriority":1,"selection":{"tagStatus":"untagged","countType":"sinceImagePushed","countUnit":"days","countNumber":14},"action":{"type":"expire"}}]}` + _, err := c.ECR.PutLifecyclePolicy(&ecr.PutLifecyclePolicyInput{ + RepositoryName: aws.String(repo), + LifecyclePolicyText: aws.String(policy), + }) + require.NoError(t, err) + + got, err := c.ECR.GetLifecyclePolicy(&ecr.GetLifecyclePolicyInput{ + RepositoryName: aws.String(repo), + }) + require.NoError(t, err) + assert.JSONEq(t, policy, aws.StringValue(got.LifecyclePolicyText)) + }) + + t.Run("DeleteRepository removes it", func(t *testing.T) { + _, err := c.ECR.DeleteRepository(&ecr.DeleteRepositoryInput{ + RepositoryName: aws.String(repo), + Force: aws.Bool(true), + }) + require.NoError(t, err) + + _, err = c.ECR.DescribeRepositories(&ecr.DescribeRepositoriesInput{ + RepositoryNames: []*string{aws.String(repo)}, + }) + require.Error(t, err, "deleted repo must not be describable") + assert.Contains(t, err.Error(), "RepositoryNotFoundException") + }) +} + +// TestECRGetLoginPassword confirms GetAuthorizationToken returns a usable +// AWS: credential — the input to docker login. +func TestECRGetLoginPassword(t *testing.T) { + f := requireECRFixture(t) + pass := harness.ECRGetLoginPassword(t, f.AWS) + assert.NotEmpty(t, pass) + // JWT shape: three dot-separated segments. + assert.Len(t, splitDots(pass), 3, "authorization password should be a JWT") +} + +func splitDots(s string) []string { + var out []string + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == '.' { + out = append(out, s[start:i]) + start = i + 1 + } + } + return append(out, s[start:]) +} diff --git a/tests/e2e/ecr/main_test.go b/tests/e2e/ecr/main_test.go new file mode 100644 index 000000000..f1bad3cfc --- /dev/null +++ b/tests/e2e/ecr/main_test.go @@ -0,0 +1,71 @@ +//go:build e2e + +// Package ecr is the standalone ECR E2E suite: control-plane CRUD over the +// awsgw ECR surface plus the OCI data plane (docker/crane/skopeo push-pull) +// against a running cluster. The data-plane subtests skip when the client +// binary is missing or the registry host does not resolve, so the suite still +// runs its control-plane leg on a bare runner. +package ecr + +import ( + "os" + "sync" + "testing" + + "github.com/mulgadc/spinifex/tests/e2e/harness" +) + +// Package-scoped singleton fixture, built lazily by requireECRFixture and torn +// down by TestMain after the run. +var ( + pkgFixOnce sync.Once + pkgFix *Fixture + pkgFixErr error +) + +// Fixture carries per-process state shared across every Test* in this package. +type Fixture struct { + Env *harness.Env + AWS *harness.AWSClient + Account string + TmpDir string +} + +func TestMain(m *testing.M) { + code := m.Run() + if pkgFix != nil && pkgFix.TmpDir != "" { + _ = os.RemoveAll(pkgFix.TmpDir) + } + os.Exit(code) +} + +// requireECRFixture returns the package-scoped Fixture, building it on first +// call. Skips the calling test if SPINIFEX_E2E is unset. +func requireECRFixture(t *testing.T) *Fixture { + t.Helper() + pkgFixOnce.Do(func() { + if os.Getenv("SPINIFEX_E2E") == "" { + return + } + env := harness.LoadEnv(t) + awsCli := harness.NewAWSClient(t, env) + tmpDir, terr := os.MkdirTemp("", "ecr-pkgfix-*") + if terr != nil { + pkgFixErr = terr + return + } + pkgFix = &Fixture{ + Env: env, + AWS: awsCli, + Account: harness.IAMAccountID(t, awsCli), + TmpDir: tmpDir, + } + }) + if pkgFixErr != nil { + t.Fatalf("ecr fixture init failed: %v", pkgFixErr) + } + if pkgFix == nil { + t.Skip("ecr fixture unavailable (SPINIFEX_E2E unset)") + } + return pkgFix +} diff --git a/tests/e2e/harness/aws.go b/tests/e2e/harness/aws.go index 0774cc5a4..17a5e9680 100644 --- a/tests/e2e/harness/aws.go +++ b/tests/e2e/harness/aws.go @@ -16,6 +16,7 @@ import ( "github.com/aws/aws-sdk-go/service/acm" "github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/ec2/ec2iface" + "github.com/aws/aws-sdk-go/service/ecr" "github.com/aws/aws-sdk-go/service/eks" "github.com/aws/aws-sdk-go/service/elbv2" "github.com/aws/aws-sdk-go/service/iam" @@ -35,6 +36,7 @@ type AWSClient struct { STS *sts.STS EKS *eks.EKS ACM *acm.ACM + ECR *ecr.ECR } // NewAWSClient builds clients pointed at the spinifex gateway using the @@ -131,6 +133,7 @@ func newAWSClient(t *testing.T, env *Env, accessKey, secretKey, sessionToken str STS: sts.New(sess), EKS: eks.New(sess), ACM: acm.New(sess), + ECR: ecr.New(sess), } } @@ -145,5 +148,6 @@ func (c *AWSClient) IgnoreCertErrors() { c.IAM.Config.HTTPClient = hc c.STS.Config.HTTPClient = hc c.ACM.Config.HTTPClient = hc + c.ECR.Config.HTTPClient = hc _ = (*x509.CertPool)(nil) // silence unused-import on hardened paths } diff --git a/tests/e2e/harness/ecr.go b/tests/e2e/harness/ecr.go new file mode 100644 index 000000000..9be92a22a --- /dev/null +++ b/tests/e2e/harness/ecr.go @@ -0,0 +1,112 @@ +//go:build e2e + +package harness + +import ( + "encoding/base64" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ecr" + "github.com/stretchr/testify/require" +) + +// ECRRegistrySuffix is the internal DNS suffix the registry host is built from. +// Defaults to the gateway's DefaultAWSInternalSuffix; override per cluster with +// SPINIFEX_ECR_SUFFIX. +func ECRRegistrySuffix() string { return getenv("SPINIFEX_ECR_SUFFIX", "mulga.internal") } + +// ECRRegistryRegion is the region label in the registry host. Mirrors the AWS +// client region. +func ECRRegistryRegion() string { return getenv("SPINIFEX_AWS_REGION", "ap-southeast-2") } + +// ECRRegistryPort is the awsgw port appended to the registry host for OCI +// clients. Empty by default (production parity, :443); set SPINIFEX_ECR_PORT on +// dev clusters where awsgw binds a non-standard port (e.g. 9999). +func ECRRegistryPort() string { return getenv("SPINIFEX_ECR_PORT", "") } + +// ECRRegistryHost returns {account}.dkr.ecr.{region}.{suffix}[:port] — the OCI +// registry endpoint docker/crane/skopeo address. The bare host must resolve to +// the awsgw bind IP (DNS or /etc/hosts) for the data-plane subtests to run. +func ECRRegistryHost(account string) string { + host := account + ".dkr.ecr." + ECRRegistryRegion() + "." + ECRRegistrySuffix() + if p := ECRRegistryPort(); p != "" { + host += ":" + p + } + return host +} + +// ECRRepositoryURI returns the full push/pull reference for repo under account. +func ECRRepositoryURI(account, repo string) string { + return ECRRegistryHost(account) + "/" + repo +} + +// CreateECRRepository creates repo (idempotent: an existing repo is treated as +// success) and registers best-effort teardown. +func CreateECRRepository(t *testing.T, c *AWSClient, repo string) { + t.Helper() + _, err := c.ECR.CreateRepository(&ecr.CreateRepositoryInput{ + RepositoryName: aws.String(repo), + }) + if err != nil && !strings.Contains(err.Error(), "RepositoryAlreadyExistsException") { + require.NoError(t, err) + } + t.Cleanup(func() { DeleteECRRepositoryBestEffort(c, repo) }) +} + +// DeleteECRRepositoryBestEffort force-deletes repo and all its images, swallowing +// errors — used for cleanup. +func DeleteECRRepositoryBestEffort(c *AWSClient, repo string) { + _, _ = c.ECR.DeleteRepository(&ecr.DeleteRepositoryInput{ + RepositoryName: aws.String(repo), + Force: aws.Bool(true), + }) +} + +// ECRGetLoginPassword returns the decoded registry password (the JWT half of the +// authorization token) for docker login -u AWS --password-stdin. +func ECRGetLoginPassword(t *testing.T, c *AWSClient) string { + t.Helper() + out, err := c.ECR.GetAuthorizationToken(&ecr.GetAuthorizationTokenInput{}) + require.NoError(t, err) + require.NotEmpty(t, out.AuthorizationData, "no authorization data returned") + raw, err := base64.StdEncoding.DecodeString(aws.StringValue(out.AuthorizationData[0].AuthorizationToken)) + require.NoError(t, err) + user, pass, found := strings.Cut(string(raw), ":") + require.True(t, found && user == "AWS", "unexpected authorization token shape") + return pass +} + +// ECRDescribeImageTags returns the set of image tags currently in repo. +func ECRDescribeImageTags(t *testing.T, c *AWSClient, repo string) []string { + t.Helper() + out, err := c.ECR.DescribeImages(&ecr.DescribeImagesInput{ + RepositoryName: aws.String(repo), + }) + require.NoError(t, err) + var tags []string + for _, img := range out.ImageDetails { + for _, tag := range img.ImageTags { + tags = append(tags, aws.StringValue(tag)) + } + } + return tags +} + +// ECRWaitImageTag polls DescribeImages until tag appears in repo or the deadline +// passes. +func ECRWaitImageTag(t *testing.T, c *AWSClient, repo, tag string, timeout time.Duration) bool { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + for _, got := range ECRDescribeImageTags(t, c, repo) { + if got == tag { + return true + } + } + time.Sleep(500 * time.Millisecond) + } + return false +} diff --git a/tests/e2e/harness/registry.go b/tests/e2e/harness/registry.go new file mode 100644 index 000000000..0d1acfd1c --- /dev/null +++ b/tests/e2e/harness/registry.go @@ -0,0 +1,115 @@ +//go:build e2e + +package harness + +import ( + "bytes" + "net" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +// ExternalCLI wraps an OCI client binary (docker/crane/skopeo) for the ECR +// data-plane subtests. Env is appended to the process environment (e.g. +// DOCKER_CONFIG to isolate the auth file). +type ExternalCLI struct { + Name string + Bin string + Env []string +} + +// newExternalCLI locates bin on PATH (overridable via _BIN, upper-cased) +// and SKIPS the calling test when absent — the data-plane subtests are optional +// on runners without the client installed. +func newExternalCLI(t *testing.T, name string, env []string) *ExternalCLI { + t.Helper() + bin := os.Getenv(strings.ToUpper(name) + "_BIN") + if bin == "" { + bin = name + } + resolved, err := exec.LookPath(bin) + if err != nil { + t.Skipf("%s not found on PATH (%v); skipping data-plane subtest", name, err) + } + return &ExternalCLI{Name: name, Bin: resolved, Env: env} +} + +// NewDocker / NewCrane / NewSkopeo build wrappers for each client, skipping the +// test if the binary is missing. dockerConfig isolates the docker auth file so +// crane/skopeo can reuse it via DOCKER_CONFIG without touching ~/.docker. +func NewDocker(t *testing.T, dockerConfig string) *ExternalCLI { + return newExternalCLI(t, "docker", []string{"DOCKER_CONFIG=" + dockerConfig}) +} + +func NewCrane(t *testing.T, dockerConfig string) *ExternalCLI { + return newExternalCLI(t, "crane", []string{"DOCKER_CONFIG=" + dockerConfig}) +} + +func NewSkopeo(t *testing.T, dockerConfig string) *ExternalCLI { + return newExternalCLI(t, "skopeo", []string{"REGISTRY_AUTH_FILE=" + dockerConfig + "/config.json"}) +} + +// Run executes the client with args and returns combined output. Errors are +// returned (not fatal) so callers can assert on output or poll. +func (c *ExternalCLI) Run(timeout time.Duration, args ...string) (string, error) { + cmd := exec.Command(c.Bin, args...) //nolint:gosec // bin is LookPath-resolved, args test-controlled + cmd.Env = append(os.Environ(), c.Env...) + var buf bytes.Buffer + cmd.Stdout = &buf + cmd.Stderr = &buf + if err := cmd.Start(); err != nil { + return "", err + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case err := <-done: + return buf.String(), err + case <-time.After(timeout): + _ = cmd.Process.Kill() + <-done + return buf.String(), &timeoutError{} + } +} + +// RunStdin is Run with data piped to the process stdin (docker login +// --password-stdin). +func (c *ExternalCLI) RunStdin(timeout time.Duration, stdin string, args ...string) (string, error) { + cmd := exec.Command(c.Bin, args...) //nolint:gosec // bin is LookPath-resolved, args test-controlled + cmd.Env = append(os.Environ(), c.Env...) + cmd.Stdin = strings.NewReader(stdin) + var buf bytes.Buffer + cmd.Stdout = &buf + cmd.Stderr = &buf + if err := cmd.Start(); err != nil { + return "", err + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case err := <-done: + return buf.String(), err + case <-time.After(timeout): + _ = cmd.Process.Kill() + <-done + return buf.String(), &timeoutError{} + } +} + +// RequireRegistryResolves SKIPS the calling test unless host resolves to an +// address. host may carry a :port suffix (stripped before lookup). The +// data-plane subtests need {account}.dkr.ecr.{region}.{suffix} to reach the +// awsgw bind IP (Route53 in prod, /etc/hosts in dev); without it the client +// cannot connect. +func RequireRegistryResolves(t *testing.T, host string) { + t.Helper() + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + if _, err := net.LookupHost(host); err != nil { + t.Skipf("registry host %q does not resolve (%v); seed /etc/hosts or DNS", host, err) + } +}