diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d7e0c114..af5d26e4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,3 +36,6 @@ jobs: - name: Run tests run: make test + env: + DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_PASSWORD }} diff --git a/Makefile b/Makefile index 7f2eb87a..a9f1b98e 100644 --- a/Makefile +++ b/Makefile @@ -135,42 +135,15 @@ build-all: build build-exec dev: $(AIR) $(AIR) -c .air.toml -# Run tests -# Compile test binaries and grant network capabilities (runs as user, not root) +# Run tests (as root for network capabilities, enables caching and parallelism) # Usage: make test - runs all tests # make test TEST=TestCreateInstanceWithNetwork - runs specific test test: ensure-ch-binaries ensure-envoy-binaries lib/system/exec_agent/exec-agent - @echo "Building test binaries..." - @mkdir -p $(BIN_DIR)/tests - @for pkg in $$(go list -tags containers_image_openpgp ./...); do \ - pkg_name=$$(basename $$pkg); \ - go test -c -tags containers_image_openpgp -o $(BIN_DIR)/tests/$$pkg_name.test $$pkg 2>/dev/null || true; \ - done - @echo "Granting capabilities to test binaries..." - @for test in $(BIN_DIR)/tests/*.test; do \ - if [ -f "$$test" ]; then \ - sudo setcap 'cap_net_admin,cap_net_bind_service=+eip' $$test 2>/dev/null || true; \ - fi; \ - done - @echo "Running tests as current user with capabilities..." @if [ -n "$(TEST)" ]; then \ echo "Running specific test: $(TEST)"; \ - for test in $(BIN_DIR)/tests/*.test; do \ - if [ -f "$$test" ]; then \ - echo ""; \ - echo "Checking $$(basename $$test) for $(TEST)..."; \ - $$test -test.run=$(TEST) -test.v -test.timeout=60s 2>&1 | grep -q "PASS\|FAIL" && \ - $$test -test.run=$(TEST) -test.v -test.timeout=60s || true; \ - fi; \ - done; \ + sudo env "PATH=$$PATH" "DOCKER_USERNAME=$$DOCKER_USERNAME" "DOCKER_PASSWORD=$$DOCKER_PASSWORD" go test -tags containers_image_openpgp -run=$(TEST) -v -timeout=180s ./...; \ else \ - for test in $(BIN_DIR)/tests/*.test; do \ - if [ -f "$$test" ]; then \ - echo ""; \ - echo "Running $$(basename $$test)..."; \ - $$test -test.v -test.parallel=10 -test.timeout=60s || exit 1; \ - fi; \ - done; \ + sudo env "PATH=$$PATH" "DOCKER_USERNAME=$$DOCKER_USERNAME" "DOCKER_PASSWORD=$$DOCKER_PASSWORD" go test -tags containers_image_openpgp -v -timeout=180s ./...; \ fi # Generate JWT token for testing diff --git a/cmd/api/api/registry_test.go b/cmd/api/api/registry_test.go index 938ecd9f..e000869d 100644 --- a/cmd/api/api/registry_test.go +++ b/cmd/api/api/registry_test.go @@ -12,11 +12,11 @@ import ( "time" "github.com/go-chi/chi/v5" - "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/onkernel/hypeman/lib/images" "github.com/onkernel/hypeman/lib/oapi" "github.com/onkernel/hypeman/lib/paths" "github.com/onkernel/hypeman/lib/registry" @@ -54,7 +54,7 @@ func TestRegistryPushAndConvert(t *testing.T) { srcRef, err := name.ParseReference("docker.io/library/alpine:latest") require.NoError(t, err) - img, err := remote.Image(srcRef, remote.WithAuthFromKeychain(authn.DefaultKeychain)) + img, err := remote.Image(srcRef, remote.WithAuthFromKeychain(images.GetKeychain())) require.NoError(t, err) digest, err := img.Digest() @@ -109,7 +109,7 @@ func TestRegistryPushAndCreateInstance(t *testing.T) { srcRef, err := name.ParseReference("docker.io/library/alpine:latest") require.NoError(t, err) - img, err := remote.Image(srcRef, remote.WithAuthFromKeychain(authn.DefaultKeychain)) + img, err := remote.Image(srcRef, remote.WithAuthFromKeychain(images.GetKeychain())) require.NoError(t, err) digest, err := img.Digest() @@ -177,7 +177,7 @@ func TestRegistryLayerCaching(t *testing.T) { srcRef, err := name.ParseReference("docker.io/library/alpine:latest") require.NoError(t, err) - img, err := remote.Image(srcRef) + img, err := remote.Image(srcRef, remote.WithAuthFromKeychain(images.GetKeychain())) require.NoError(t, err) digest, err := img.Digest() @@ -259,7 +259,7 @@ func TestRegistrySharedLayerCaching(t *testing.T) { t.Log("Pulling alpine:latest...") alpineRef, err := name.ParseReference("docker.io/library/alpine:latest") require.NoError(t, err) - alpineImg, err := remote.Image(alpineRef, remote.WithAuthFromKeychain(authn.DefaultKeychain)) + alpineImg, err := remote.Image(alpineRef, remote.WithAuthFromKeychain(images.GetKeychain())) require.NoError(t, err) // Get alpine layers for comparison @@ -291,7 +291,7 @@ func TestRegistrySharedLayerCaching(t *testing.T) { t.Log("Pulling alpine:3.18 (shares base layer)...") alpine318Ref, err := name.ParseReference("docker.io/library/alpine:3.18") require.NoError(t, err) - alpine318Img, err := remote.Image(alpine318Ref, remote.WithAuthFromKeychain(authn.DefaultKeychain)) + alpine318Img, err := remote.Image(alpine318Ref, remote.WithAuthFromKeychain(images.GetKeychain())) require.NoError(t, err) alpine318Digest, _ := alpine318Img.Digest() @@ -341,7 +341,7 @@ func TestRegistryTagPush(t *testing.T) { srcRef, err := name.ParseReference("docker.io/library/alpine:latest") require.NoError(t, err) - img, err := remote.Image(srcRef, remote.WithAuthFromKeychain(authn.DefaultKeychain)) + img, err := remote.Image(srcRef, remote.WithAuthFromKeychain(images.GetKeychain())) require.NoError(t, err) digest, err := img.Digest() @@ -393,7 +393,7 @@ func TestRegistryDockerV2ManifestConversion(t *testing.T) { srcRef, err := name.ParseReference("docker.io/library/alpine:latest") require.NoError(t, err) - img, err := remote.Image(srcRef, remote.WithAuthFromKeychain(authn.DefaultKeychain)) + img, err := remote.Image(srcRef, remote.WithAuthFromKeychain(images.GetKeychain())) require.NoError(t, err) // Wrap the image to simulate Docker v2 format (Docker daemon returns this format) diff --git a/lib/images/oci.go b/lib/images/oci.go index 7063f1a1..c69c3d47 100644 --- a/lib/images/oci.go +++ b/lib/images/oci.go @@ -23,6 +23,38 @@ type ociClient struct { cacheDir string } +// envKeychain authenticates to Docker Hub using environment variables. +// This is useful in CI environments where credentials are passed via env vars +// rather than stored in ~/.docker/config.json. +type envKeychain struct { + username, password string +} + +func (k *envKeychain) Resolve(resource authn.Resource) (authn.Authenticator, error) { + // Only authenticate for Docker Hub + registry := resource.RegistryStr() + if registry == "index.docker.io" || registry == "registry-1.docker.io" { + return authn.FromConfig(authn.AuthConfig{ + Username: k.username, + Password: k.password, + }), nil + } + // Fall back to anonymous for other registries + return authn.Anonymous, nil +} + +// GetKeychain returns a keychain that tries env vars first, then default keychain. +// Set DOCKER_USERNAME and DOCKER_PASSWORD environment variables for Docker Hub auth. +func GetKeychain() authn.Keychain { + username := os.Getenv("DOCKER_USERNAME") + password := os.Getenv("DOCKER_PASSWORD") + + if username != "" && password != "" { + return &envKeychain{username: username, password: password} + } + return authn.DefaultKeychain +} + // digestToLayoutTag converts a digest to a valid OCI layout tag. // Uses just the hex portion without the algorithm prefix. // Example: "sha256:abc123..." -> "abc123..." @@ -68,11 +100,11 @@ func (c *ociClient) inspectManifest(ctx context.Context, imageRef string) (strin return "", fmt.Errorf("parse image reference: %w", err) } - // Use system authentication (reads from ~/.docker/config.json, etc.) + // Use env var authentication if available, otherwise fall back to ~/.docker/config.json // Default retry: only on network errors, max ~1.3s total descriptor, err := remote.Head(ref, remote.WithContext(ctx), - remote.WithAuthFromKeychain(authn.DefaultKeychain)) + remote.WithAuthFromKeychain(GetKeychain())) if err != nil { return "", fmt.Errorf("fetch manifest: %w", wrapRegistryError(err)) } @@ -124,11 +156,11 @@ func (c *ociClient) pullToOCILayout(ctx context.Context, imageRef, layoutTag str return fmt.Errorf("parse image reference: %w", err) } - // Use system authentication (reads from ~/.docker/config.json, etc.) + // Use env var authentication if available, otherwise fall back to ~/.docker/config.json // Default retry: only on network errors, max ~1.3s total img, err := remote.Image(ref, remote.WithContext(ctx), - remote.WithAuthFromKeychain(authn.DefaultKeychain)) + remote.WithAuthFromKeychain(GetKeychain())) if err != nil { // Rate limits fail here immediately (429 is not retried by default) return fmt.Errorf("fetch image manifest: %w", wrapRegistryError(err))