mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 09:01:31 +00:00
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:
@@ -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
@@ -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/...
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user