test(e2e): ECR end-to-end suite (control plane + docker/crane/skopeo) (#398)

* test(e2e): add ECR end-to-end suite (control plane + docker/crane/skopeo)

New tests/e2e/ecr/ suite plus harness support:
- harness: ECR SDK client, control-plane helpers (repo CRUD, get-login-password,
  describe-images), and docker/crane/skopeo CLI wrappers that skip when the
  binary or registry-host resolution is absent.
- suite: control-plane CRUD + policy + lifecycle round-trip; docker login/push/
  pull; crane append/pull and skopeo copy exercising the Bearer /v2/token flow.
- registered e2e-ecr in the Makefile + service manifest.

Predastore capacity/load leg is a skipped placeholder pending mulga-siv-359/356.

* test(e2e): carry awsgw port in ECR registry host for dev clusters

Dev clusters bind awsgw on a non-standard port (e.g. 9999); production uses
:443. ECRRegistryHost now appends SPINIFEX_ECR_PORT when set so docker/crane/
skopeo address the right endpoint, and RequireRegistryResolves strips the port
before DNS lookup.
This commit is contained in:
Josh Siviour
2026-06-19 11:34:38 +10:00
committed by GitHub
parent 188e7459e3
commit f9858d4e5d
10 changed files with 628 additions and 3 deletions
+8
View File
@@ -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]
+4 -3
View File
@@ -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/<suite>.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/...
+14
View File
@@ -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")
}
+114
View File
@@ -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")
}
+80
View File
@@ -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)
})
}
+106
View File
@@ -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:<jwt> 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:])
}
+71
View File
@@ -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
}
+4
View File
@@ -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
}
+112
View File
@@ -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
}
+115
View File
@@ -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 <NAME>_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)
}
}