From 08686f3f54fa7f2ceb37f83c950dd9b54902d394 Mon Sep 17 00:00:00 2001 From: hiroTamada Date: Fri, 6 Feb 2026 11:56:33 -0500 Subject: [PATCH 1/7] fix: add image-manifest=true to BuildKit cache export for ephemeral VMs Without image-manifest=true, BuildKit's registry cache stores layer references pointing to external registries (e.g., docker.io) rather than copying the actual layer blobs into the cache image. This causes cache misses in ephemeral BuildKit instances (like our builder VMs) because the layers aren't available locally. With image-manifest=true, BuildKit creates a proper OCI image manifest with all layer blobs stored in the registry, enabling cache hits even in fresh BuildKit instances. This fixes the issue where the global cache (populated by admin builds) wasn't providing cache hits for tenant builds - the first deployment for each tenant was re-downloading all base image layers from Docker Hub. Co-authored-by: Cursor --- lib/builds/builder_agent/main.go | 7 +++++-- lib/builds/cache.go | 4 +++- lib/builds/cache_test.go | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/builds/builder_agent/main.go b/lib/builds/builder_agent/main.go index beb5b18..045b300 100644 --- a/lib/builds/builder_agent/main.go +++ b/lib/builds/builder_agent/main.go @@ -728,11 +728,14 @@ func runBuild(ctx context.Context, config *BuildConfig, logWriter io.Writer) (st } // Export cache based on build type + // Note: image-manifest=true ensures layer blobs are stored in the registry cache image + // rather than as references to external registries (e.g., docker.io). This is critical + // for cache hits in ephemeral BuildKit instances that don't have local layer storage. if config.IsAdminBuild { // Admin build: export to global cache if config.GlobalCacheKey != "" { globalCacheRef := fmt.Sprintf("%s/cache/global/%s", registryHost, config.GlobalCacheKey) - cacheOpts := "type=registry,ref=" + globalCacheRef + ",mode=max" + cacheOpts := "type=registry,ref=" + globalCacheRef + ",mode=max,image-manifest=true,oci-mediatypes=true" if useInsecureFlag { cacheOpts += ",registry.insecure=true" } @@ -743,7 +746,7 @@ func runBuild(ctx context.Context, config *BuildConfig, logWriter io.Writer) (st // Regular build: export to tenant cache if config.CacheScope != "" { tenantCacheRef := fmt.Sprintf("%s/cache/%s", registryHost, config.CacheScope) - cacheOpts := "type=registry,ref=" + tenantCacheRef + ",mode=max" + cacheOpts := "type=registry,ref=" + tenantCacheRef + ",mode=max,image-manifest=true,oci-mediatypes=true" if useInsecureFlag { cacheOpts += ",registry.insecure=true" } diff --git a/lib/builds/cache.go b/lib/builds/cache.go index ff3e26a..f47e331 100644 --- a/lib/builds/cache.go +++ b/lib/builds/cache.go @@ -100,8 +100,10 @@ func (k *CacheKey) ImportCacheArg() string { } // ExportCacheArg returns the BuildKit --export-cache argument +// Uses image-manifest=true to ensure layer blobs are stored in the cache image +// rather than as external references, enabling cache hits in ephemeral BuildKit instances. func (k *CacheKey) ExportCacheArg() string { - return fmt.Sprintf("type=registry,ref=%s,mode=max", k.Reference) + return fmt.Sprintf("type=registry,ref=%s,mode=max,image-manifest=true,oci-mediatypes=true", k.Reference) } // normalizeCacheScope normalizes a cache scope to only contain safe characters diff --git a/lib/builds/cache_test.go b/lib/builds/cache_test.go index d51fb7c..7f3637b 100644 --- a/lib/builds/cache_test.go +++ b/lib/builds/cache_test.go @@ -103,7 +103,7 @@ func TestCacheKey_Args(t *testing.T) { assert.Equal(t, "type=registry,ref=localhost:8080/cache/tenant/nodejs/abc123", importArg) exportArg := key.ExportCacheArg() - assert.Equal(t, "type=registry,ref=localhost:8080/cache/tenant/nodejs/abc123,mode=max", exportArg) + assert.Equal(t, "type=registry,ref=localhost:8080/cache/tenant/nodejs/abc123,mode=max,image-manifest=true,oci-mediatypes=true", exportArg) } func TestValidateCacheScope(t *testing.T) { From 195e0020c751babc8be9a0630a4184a4967fcf1a Mon Sep 17 00:00:00 2001 From: hiroTamada Date: Fri, 6 Feb 2026 15:13:53 -0500 Subject: [PATCH 2/7] test: add unit test reproducing BuildKit cache mediatype issue Adds a unit test that reproduces the production issue where hypeman fails to pre-pull BuildKit cache images. The test creates a mock OCI layout with BuildKit's cache config mediatype (application/vnd.buildkit.cacheconfig.v0) and verifies that unpackLayers fails with the expected error. This test documents the root cause: umoci expects standard OCI config mediatype but BuildKit cache exports use a custom mediatype. Co-authored-by: Cursor --- lib/images/oci_test.go | 190 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 lib/images/oci_test.go diff --git a/lib/images/oci_test.go b/lib/images/oci_test.go new file mode 100644 index 0000000..592da9a --- /dev/null +++ b/lib/images/oci_test.go @@ -0,0 +1,190 @@ +package images + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// BuildKit cache config mediatype - this is what BuildKit uses when exporting +// cache with image-manifest=true +const buildKitCacheConfigMediaType = "application/vnd.buildkit.cacheconfig.v0" + +// TestUnpackLayersFailsOnBuildKitCacheMediatype verifies that hypeman's image +// unpacker fails when encountering BuildKit cache images. This reproduces the +// production issue where global cache images exported by BuildKit cannot be +// pre-pulled by hypeman because they use a non-standard config mediatype. +// +// The error occurs because: +// 1. BuildKit exports cache with --export-cache type=registry,image-manifest=true +// 2. The exported manifest uses "application/vnd.buildkit.cacheconfig.v0" as config mediatype +// 3. hypeman's unpackLayers expects "application/vnd.oci.image.config.v1+json" +// 4. umoci.UnpackRootfs fails with "config blob is not correct mediatype" +func TestUnpackLayersFailsOnBuildKitCacheMediatype(t *testing.T) { + // Create a temp directory for the OCI layout + cacheDir := t.TempDir() + + // Create OCI layout structure with BuildKit cache mediatype + err := createBuildKitCacheLayout(cacheDir, "test-cache") + require.NoError(t, err, "failed to create mock BuildKit cache layout") + + // Create OCI client and try to unpack + client, err := newOCIClient(cacheDir) + require.NoError(t, err) + + targetDir := t.TempDir() + err = client.unpackLayers(context.Background(), "test-cache", targetDir) + + // This should fail with a mediatype error + require.Error(t, err, "unpackLayers should fail on BuildKit cache mediatype") + assert.Contains(t, err.Error(), "config", "error should mention config") + + t.Logf("Got expected error: %v", err) +} + +// TestExtractMetadataSucceedsOnBuildKitCache verifies that extractOCIMetadata +// does NOT fail on BuildKit cache images - it's go-containerregistry which is +// lenient about mediatypes. The failure only happens during unpackLayers when +// umoci tries to unpack the rootfs. +func TestExtractMetadataSucceedsOnBuildKitCache(t *testing.T) { + cacheDir := t.TempDir() + + err := createBuildKitCacheLayout(cacheDir, "test-cache") + require.NoError(t, err) + + client, err := newOCIClient(cacheDir) + require.NoError(t, err) + + // This succeeds because go-containerregistry doesn't validate config mediatype + // The failure only happens in unpackLayers when umoci validates the config + meta, err := client.extractOCIMetadata("test-cache") + require.NoError(t, err, "extractOCIMetadata succeeds - go-containerregistry is lenient") + + // But the metadata will be empty/invalid since it's not a real OCI config + t.Logf("Got metadata (likely empty): %+v", meta) +} + +// createBuildKitCacheLayout creates an OCI layout that mimics what BuildKit +// exports when using --export-cache type=registry,image-manifest=true +// +// Layout structure: +// cacheDir/ +// ├── oci-layout (OCI layout version marker) +// ├── index.json (points to manifest) +// └── blobs/sha256/ +// ├── (image manifest with buildkit config mediatype) +// ├── (buildkit cache config blob) +// └── (dummy layer) +func createBuildKitCacheLayout(cacheDir, layoutTag string) error { + // Create directory structure + blobsDir := filepath.Join(cacheDir, "blobs", "sha256") + if err := os.MkdirAll(blobsDir, 0755); err != nil { + return err + } + + // 1. Create oci-layout file + ociLayout := map[string]string{"imageLayoutVersion": "1.0.0"} + ociLayoutBytes, _ := json.Marshal(ociLayout) + if err := os.WriteFile(filepath.Join(cacheDir, "oci-layout"), ociLayoutBytes, 0644); err != nil { + return err + } + + // 2. Create a dummy layer blob (gzipped tar with a single file) + // This is a minimal valid gzipped tar + layerContent := []byte{ + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, // gzip header + 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // empty tar + } + layerDigest := sha256Hash(layerContent) + if err := os.WriteFile(filepath.Join(blobsDir, layerDigest), layerContent, 0644); err != nil { + return err + } + + // 3. Create BuildKit cache config blob + // This is what BuildKit puts in the config - NOT a standard OCI config + cacheConfig := map[string]interface{}{ + "layers": []map[string]interface{}{ + { + "blob": "sha256:" + layerDigest, + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + }, + }, + } + configBytes, _ := json.Marshal(cacheConfig) + configDigest := sha256Hash(configBytes) + if err := os.WriteFile(filepath.Join(blobsDir, configDigest), configBytes, 0644); err != nil { + return err + } + + // 4. Create image manifest with BuildKit's cache config mediatype + manifest := map[string]interface{}{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": map[string]interface{}{ + "mediaType": buildKitCacheConfigMediaType, // This is the problem! + "digest": "sha256:" + configDigest, + "size": len(configBytes), + }, + "layers": []map[string]interface{}{ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:" + layerDigest, + "size": len(layerContent), + }, + }, + } + manifestBytes, _ := json.Marshal(manifest) + manifestDigest := sha256Hash(manifestBytes) + if err := os.WriteFile(filepath.Join(blobsDir, manifestDigest), manifestBytes, 0644); err != nil { + return err + } + + // 5. Create index.json pointing to the manifest with our layout tag + index := map[string]interface{}{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": []map[string]interface{}{ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:" + manifestDigest, + "size": len(manifestBytes), + "annotations": map[string]string{ + "org.opencontainers.image.ref.name": layoutTag, + }, + }, + }, + } + indexBytes, _ := json.Marshal(index) + if err := os.WriteFile(filepath.Join(cacheDir, "index.json"), indexBytes, 0644); err != nil { + return err + } + + return nil +} + +// sha256Hash computes the SHA256 hash of data and returns the hex string +func sha256Hash(data []byte) string { + h := sha256.Sum256(data) + return hex.EncodeToString(h[:]) +} + +// TestConvertToOCIMediaTypePassesThroughBuildKitType verifies that the +// mediatype conversion function doesn't handle BuildKit's cache config type, +// which is the root cause of the unpack failure. +func TestConvertToOCIMediaTypePassesThroughBuildKitType(t *testing.T) { + // Verify that BuildKit's mediatype passes through unchanged + result := convertToOCIMediaType(buildKitCacheConfigMediaType) + assert.Equal(t, buildKitCacheConfigMediaType, result, + "BuildKit cache config mediatype should pass through unchanged (this is the bug)") + + // Standard Docker types should be converted + assert.Equal(t, "application/vnd.oci.image.config.v1+json", + convertToOCIMediaType("application/vnd.docker.container.image.v1+json")) +} From 353aac75831c48830cbf7b66f1951784de37e9c6 Mon Sep 17 00:00:00 2001 From: hiroTamada Date: Fri, 6 Feb 2026 15:27:55 -0500 Subject: [PATCH 3/7] fix: skip conversion for BuildKit cache images BuildKit exports cache with a custom mediatype (application/vnd.buildkit.cacheconfig.v0) that can't be unpacked by standard OCI tools like umoci. This caused errors when pushing cache images to the registry: config blob is not correct mediatype application/vnd.oci.image.config.v1+json: application/vnd.buildkit.cacheconfig.v0 The fix skips the ext4 conversion step for cache/* repos since: 1. Cache images are not runnable containers 2. BuildKit imports them directly from the registry 3. There's no need to unpack or convert them locally Co-authored-by: Cursor --- lib/registry/registry.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/registry/registry.go b/lib/registry/registry.go index 44535f4..fed7274 100644 --- a/lib/registry/registry.go +++ b/lib/registry/registry.go @@ -138,7 +138,15 @@ func (w *responseWrapper) WriteHeader(code int) { } // triggerConversion queues the image for conversion to ext4 disk format. +// Skips BuildKit cache images (cache/*) since they're not runnable containers. func (r *Registry) triggerConversion(repo, reference, dockerDigest string) { + // Skip BuildKit cache images - they use a custom mediatype that can't be + // unpacked as a standard OCI image. BuildKit imports them directly from + // the registry without needing local conversion. + if strings.HasPrefix(repo, "cache/") { + return + } + imageRef := repo + ":" + reference if strings.HasPrefix(reference, "sha256:") { imageRef = repo + "@" + reference From 1460df94ce38190c69d343fbb5affe9a648371a1 Mon Sep 17 00:00:00 2001 From: hiroTamada Date: Fri, 6 Feb 2026 15:54:43 -0500 Subject: [PATCH 4/7] fix: handle host prefix in cache repo check The repo parameter passed to triggerConversion includes the Host header prefix (e.g., "10.102.0.1:8083/cache/global/node"). The previous check only used HasPrefix("cache/") which would never match. Now checks for both patterns: - HasPrefix("cache/") for edge case without host - Contains("/cache/") for normal case with host prefix Co-authored-by: Cursor --- lib/registry/registry.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/registry/registry.go b/lib/registry/registry.go index fed7274..651baf9 100644 --- a/lib/registry/registry.go +++ b/lib/registry/registry.go @@ -143,7 +143,8 @@ func (r *Registry) triggerConversion(repo, reference, dockerDigest string) { // Skip BuildKit cache images - they use a custom mediatype that can't be // unpacked as a standard OCI image. BuildKit imports them directly from // the registry without needing local conversion. - if strings.HasPrefix(repo, "cache/") { + // Note: repo may include host prefix (e.g., "10.102.0.1:8083/cache/global/node") + if strings.HasPrefix(repo, "cache/") || strings.Contains(repo, "/cache/") { return } From 15efbbaa4f4d8d47f5b13fccbfd6cf952f4b0672 Mon Sep 17 00:00:00 2001 From: hiroTamada Date: Mon, 9 Feb 2026 15:44:36 -0500 Subject: [PATCH 5/7] feat: mirror base images to local registry and rewrite Dockerfile FROMs Add auto-mirroring of base images for admin builds and Dockerfile FROM rewriting so builder VMs pull base image layers from the local registry instead of Docker Hub. Key changes: - Add mirror infrastructure (lib/images/mirror.go, lib/builds/mirror.go) to push base images to the local registry during admin builds - Add Dockerfile FROM parser (lib/builds/dockerfile.go) to extract base image references for both mirroring and token scope - Add builder agent FROM rewriting (builder_agent/main.go) to detect mirrored images and rewrite FROM instructions to use local refs - Fix auth on HEAD request: checkImageExistsInRegistry now sends Bearer auth header so the registry doesn't return 401 - Fix token scope: builder tokens now include pull access for base image repos so the agent can detect mirrored images - Add admin /mirror-base-image endpoint for manual mirroring - Add registry middleware support for base image pull auth Co-Authored-By: Claude Opus 4.6 --- cmd/api/api/admin.go | 86 +++ cmd/api/main.go | 13 + go.mod | 25 +- go.sum | 70 +- .../builder_agent/dockerfile_rewrite_test.go | 231 +++++++ lib/builds/builder_agent/main.go | 211 ++++++- lib/builds/cache_integration_test.go | 597 ++++++++++++++++++ lib/builds/dockerfile.go | 138 ++++ lib/builds/dockerfile_test.go | 230 +++++++ lib/builds/manager.go | 55 ++ lib/builds/mirror.go | 86 +++ lib/images/mirror.go | 130 ++++ lib/images/mirror_test.go | 91 +++ lib/middleware/oapi_auth.go | 67 ++ 14 files changed, 1999 insertions(+), 31 deletions(-) create mode 100644 cmd/api/api/admin.go create mode 100644 lib/builds/builder_agent/dockerfile_rewrite_test.go create mode 100644 lib/builds/cache_integration_test.go create mode 100644 lib/builds/dockerfile.go create mode 100644 lib/builds/dockerfile_test.go create mode 100644 lib/builds/mirror.go create mode 100644 lib/images/mirror.go create mode 100644 lib/images/mirror_test.go diff --git a/cmd/api/api/admin.go b/cmd/api/api/admin.go new file mode 100644 index 0000000..ebf7058 --- /dev/null +++ b/cmd/api/api/admin.go @@ -0,0 +1,86 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/kernel/hypeman/lib/images" + "github.com/kernel/hypeman/lib/logger" +) + +// MirrorBaseImageRequest is the request body for POST /admin/mirror-base-image +type MirrorBaseImageRequest struct { + // SourceImage is the full image reference to pull from (e.g., "docker.io/onkernel/nodejs22-base:0.1.1") + SourceImage string `json:"source_image"` +} + +// MirrorBaseImageResponse is the response body for POST /admin/mirror-base-image +type MirrorBaseImageResponse struct { + // SourceImage is the original image reference + SourceImage string `json:"source_image"` + // LocalRef is the local registry reference (e.g., "onkernel/nodejs22-base:0.1.1") + LocalRef string `json:"local_ref"` + // Digest is the image digest + Digest string `json:"digest"` +} + +// MirrorBaseImageHandler returns an HTTP handler for mirroring base images. +// This is an admin endpoint that pulls images from external registries and +// pushes them to the local registry with the same normalized name. +// +// Example usage: +// +// POST /admin/mirror-base-image +// {"source_image": "docker.io/onkernel/nodejs22-base:0.1.1"} +// +// Response: +// +// {"source_image": "...", "local_ref": "onkernel/nodejs22-base:0.1.1", "digest": "sha256:..."} +func MirrorBaseImageHandler(registryURL string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + log := logger.FromContext(r.Context()) + + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Parse request body + var req MirrorBaseImageRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.ErrorContext(r.Context(), "failed to decode request body", "error", err) + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + if req.SourceImage == "" { + http.Error(w, "source_image is required", http.StatusBadRequest) + return + } + + log.InfoContext(r.Context(), "mirroring base image", "source", req.SourceImage) + + // Mirror the image + result, err := images.MirrorBaseImage(r.Context(), registryURL, images.MirrorRequest{ + SourceImage: req.SourceImage, + }, nil) // No auth config for local insecure registry + if err != nil { + log.ErrorContext(r.Context(), "failed to mirror base image", "error", err, "source", req.SourceImage) + http.Error(w, "failed to mirror image: "+err.Error(), http.StatusInternalServerError) + return + } + + log.InfoContext(r.Context(), "base image mirrored successfully", + "source", result.SourceImage, + "local_ref", result.LocalRef, + "digest", result.Digest) + + // Return response + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(MirrorBaseImageResponse{ + SourceImage: result.SourceImage, + LocalRef: result.LocalRef, + Digest: result.Digest, + }) + } +} diff --git a/cmd/api/main.go b/cmd/api/main.go index 7f5e426..06b3a92 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -369,6 +369,19 @@ func run() error { }) }) + // Admin endpoints (authenticated but outside OpenAPI spec) + r.Route("/admin", func(r chi.Router) { + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Recoverer) + r.Use(mw.InjectLogger(logger)) + r.Use(mw.AccessLogger(accessLogger)) + r.Use(mw.JWTAuthMiddleware(cfg.JwtSecret)) + + // Mirror base images to local registry + r.Post("/mirror-base-image", api.MirrorBaseImageHandler(cfg.RegistryURL)) + }) + // Unauthenticated endpoints (outside group) r.Get("/spec.yaml", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/vnd.oai.openapi") diff --git a/go.mod b/go.mod index 16a40d3..00a535e 100644 --- a/go.mod +++ b/go.mod @@ -32,6 +32,7 @@ require ( github.com/riandyrn/otelchi v0.12.2 github.com/samber/lo v1.52.0 github.com/stretchr/testify v1.11.1 + github.com/testcontainers/testcontainers-go v0.40.0 github.com/u-root/u-root v0.15.0 github.com/vishvananda/netlink v1.3.1 go.opentelemetry.io/contrib/bridges/otelslog v0.13.0 @@ -54,56 +55,73 @@ require ( ) require ( + dario.cat/mergo v1.0.2 // indirect github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/apex/log v1.9.0 // indirect github.com/blang/semver/v4 v4.0.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/digitalocean/go-libvirt v0.0.0-20220804181439-8648fbde413e // indirect github.com/docker/cli v28.2.2+incompatible // indirect - github.com/docker/docker v28.2.2+incompatible // indirect + github.com/docker/docker v28.5.1+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.3 // indirect - github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/ebitengine/purego v0.8.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/go-test/deep v1.1.1 // indirect - github.com/gogo/protobuf v1.3.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/pgzip v1.2.6 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mdlayher/socket v0.5.1 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.1.0 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect github.com/moby/sys/sequential v0.6.0 // indirect github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.0 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/morikuni/aec v1.0.0 // indirect github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/rootless-containers/proto/go-proto v0.0.0-20230421021042-4cd87ebadd67 // indirect + github.com/shirou/gopsutil/v4 v4.25.6 // indirect github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect + github.com/tklauser/go-sysconf v0.3.14 // indirect + github.com/tklauser/numcpus v0.8.0 // indirect github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect github.com/vbatts/go-mtree v0.6.1-0.20250911112631-8307d76bc1b9 // indirect github.com/vbatts/tar-split v0.12.1 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect @@ -118,5 +136,4 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - gotest.tools/v3 v3.5.2 // indirect ) diff --git a/go.sum b/go.sum index 6fd5278..62eed1d 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA= al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= @@ -21,9 +23,8 @@ github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2y github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500 h1:6lhrsTEnloDPXyeZBvSYvQf8u86jbKehZPVDDlkgDl4= github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500/go.mod h1:S/7n9copUssQ56c7aAgHqftWO4LTf4xY6CGWt8Bc+3M= -github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= -github.com/cenkalti/backoff/v4 v4.1.3 h1:cFAlzYUlVYDysBEH2T5hyJZMh3+5+WCBvSnK6Q8UtC4= -github.com/cenkalti/backoff/v4 v4.1.3/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -32,8 +33,12 @@ github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151X github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8= github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= @@ -52,16 +57,18 @@ github.com/docker/cli v28.2.2+incompatible h1:qzx5BNUDFqlvyq4AHzdNB7gSyVTmU4cgsy github.com/docker/cli v28.2.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= -github.com/docker/docker v28.2.2+incompatible h1:CjwRSksz8Yo4+RmQ339Dp/D2tGO5JxwYeqtMOEe0LDw= -github.com/docker/docker v28.2.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM= +github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= -github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= -github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= +github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= @@ -78,14 +85,14 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -94,6 +101,7 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.20.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB31qAwjAohdSTU= @@ -119,8 +127,6 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7/go.mod h1:2iMrUgbbvHEiQClaW2NsSzMyGHqN+rDFqY705q49KG0= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= @@ -133,6 +139,10 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= @@ -150,6 +160,10 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= +github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= @@ -158,8 +172,8 @@ github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= -github.com/moby/term v0.0.0-20221205130635-1aeaba878587 h1:HfkjXDfhgVaN5rmueG8cL8KKeFNecRCXFhaJ2qZ5SKA= -github.com/moby/term v0.0.0-20221205130635-1aeaba878587/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= @@ -194,6 +208,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/riandyrn/otelchi v0.12.2 h1:6QhGv0LVw/dwjtPd12mnNrl0oEQF4ZAlmHcnlTYbeAg= github.com/riandyrn/otelchi v0.12.2/go.mod h1:weZZeUJURvtCcbWsdb7Y6F8KFZGedJlSrgUjq9VirV8= github.com/rogpeppe/fastuuid v1.1.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= @@ -204,6 +220,8 @@ github.com/rootless-containers/proto/go-proto v0.0.0-20230421021042-4cd87ebadd67 github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= +github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af h1:Sp5TG9f7K39yfB+If0vjp97vuT74F72r8hfRpP8jLU0= github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/smartystreets/assertions v1.0.0/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= @@ -211,11 +229,15 @@ github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h github.com/smartystreets/gunit v1.0.0/go.mod h1:qwPWnhz6pn0NnRBP++URONOVyNkPyr4SauJk4cUOwJs= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= +github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= github.com/tj/assert v0.0.0-20171129193455-018094318fb0/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0= github.com/tj/assert v0.0.3 h1:Df/BlaZ20mq6kuai7f5z2TvPFiwC3xaWJSDQNiIS3Rk= github.com/tj/assert v0.0.3/go.mod h1:Ne6X72Q+TB1AteidzQncjw9PabbMp4PBMZ1k+vd1Pvk= @@ -223,6 +245,10 @@ github.com/tj/go-buffer v1.1.0/go.mod h1:iyiJpfFcR2B9sXu7KvjbT9fpM4mOelRSDTbntVj github.com/tj/go-elastic v0.0.0-20171221160941-36157cbbebc2/go.mod h1:WjeM0Oo1eNAjXGDx2yma7uG2XoyRZTq1uv3M/o7imD0= github.com/tj/go-kinesis v0.0.0-20171128231115-08b17f58cb1b/go.mod h1:/yhzCV0xPfx6jb1bBgRFjl5lytqVqZXEaeqWP8lTEao= github.com/tj/go-spin v1.1.0/go.mod h1:Mg1mzmePZm4dva8Qz60H2lHwmJ2loum4VIrLgVnKwh4= +github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU= +github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY= +github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY= +github.com/tklauser/numcpus v0.8.0/go.mod h1:ZJZlAY+dmR4eut8epnzf0u/VwodKmryxR8txiloSqBE= github.com/u-root/u-root v0.15.0 h1:8JXfjAA/Vs8EXfZUA2ftvoHbiYYLdaU8umJ461aq+Jw= github.com/u-root/u-root v0.15.0/go.mod h1:/0Qr7qJeDwWxoKku2xKQ4Szc+SwBE3g9VE8jNiamsmc= github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= @@ -239,9 +265,9 @@ github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zd github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/bridges/otelslog v0.13.0 h1:bwnLpizECbPr1RrQ27waeY2SPIPeccCx/xLuoYADZ9s= @@ -283,26 +309,19 @@ go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.46.1-0.20251013234738-63d1a5100f82 h1:6/3JGEh1C88g7m+qzzTbl3A0FtsLguXieqofVLU/JAo= golang.org/x/net v0.46.1-0.20251013234738-63d1a5100f82/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= @@ -310,8 +329,9 @@ golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -321,6 +341,8 @@ golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -330,8 +352,6 @@ golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= diff --git a/lib/builds/builder_agent/dockerfile_rewrite_test.go b/lib/builds/builder_agent/dockerfile_rewrite_test.go new file mode 100644 index 0000000..c2147b0 --- /dev/null +++ b/lib/builds/builder_agent/dockerfile_rewrite_test.go @@ -0,0 +1,231 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockRegistryServer creates a test server that responds to manifest HEAD requests. +// The availableImages map determines which images return 200 (found) vs 404 (not found). +func mockRegistryServer(availableImages map[string]bool) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Parse the path to extract repo and tag + // Expected format: /v2/{repo}/manifests/{tag} + path := r.URL.Path + if !strings.HasPrefix(path, "/v2/") || !strings.Contains(path, "/manifests/") { + http.NotFound(w, r) + return + } + + // Extract repo and tag + parts := strings.SplitN(strings.TrimPrefix(path, "/v2/"), "/manifests/", 2) + if len(parts) != 2 { + http.NotFound(w, r) + return + } + + imageRef := parts[0] + ":" + parts[1] + + if availableImages[imageRef] { + w.WriteHeader(http.StatusOK) + } else { + http.NotFound(w, r) + } + })) +} + +func TestRewriteDockerfileFROMs(t *testing.T) { + // Create a mock registry with specific images available + availableImages := map[string]bool{ + "onkernel/nodejs22-base:0.1.1": true, + "onkernel/python311-base:0.1.1": true, + } + server := mockRegistryServer(availableImages) + defer server.Close() + + // Extract host from server URL (remove http://) + registryURL := strings.TrimPrefix(server.URL, "http://") + + tests := []struct { + name string + dockerfile string + expectedCount int + expected string + }{ + { + name: "simple FROM rewrite when image exists locally", + dockerfile: `FROM onkernel/nodejs22-base:0.1.1 +RUN echo hello`, + expectedCount: 1, + expected: `FROM ` + registryURL + `/onkernel/nodejs22-base:0.1.1 +RUN echo hello`, + }, + { + name: "FROM with docker.io prefix", + dockerfile: `FROM docker.io/onkernel/nodejs22-base:0.1.1 +RUN echo hello`, + expectedCount: 1, + expected: `FROM ` + registryURL + `/onkernel/nodejs22-base:0.1.1 +RUN echo hello`, + }, + { + name: "FROM with AS alias", + dockerfile: `FROM onkernel/nodejs22-base:0.1.1 AS builder +RUN npm install +FROM onkernel/nodejs22-base:0.1.1 AS runtime +COPY --from=builder /app /app`, + expectedCount: 2, + expected: `FROM ` + registryURL + `/onkernel/nodejs22-base:0.1.1 AS builder +RUN npm install +FROM ` + registryURL + `/onkernel/nodejs22-base:0.1.1 AS runtime +COPY --from=builder /app /app`, + }, + { + name: "FROM with --platform flag", + dockerfile: `FROM --platform=linux/amd64 onkernel/nodejs22-base:0.1.1 +RUN echo hello`, + expectedCount: 1, + expected: `FROM --platform=linux/amd64 ` + registryURL + `/onkernel/nodejs22-base:0.1.1 +RUN echo hello`, + }, + { + name: "no rewrite when image not in local registry", + dockerfile: `FROM alpine:3.21 +RUN echo hello`, + expectedCount: 0, + expected: `FROM alpine:3.21 +RUN echo hello`, + }, + { + name: "preserves comments and whitespace", + dockerfile: `# This is a comment +FROM onkernel/nodejs22-base:0.1.1 + +# Another comment +RUN echo hello`, + expectedCount: 1, + expected: `# This is a comment +FROM ` + registryURL + `/onkernel/nodejs22-base:0.1.1 + +# Another comment +RUN echo hello`, + }, + { + name: "lowercase from is rewritten", + dockerfile: `from onkernel/nodejs22-base:0.1.1 +RUN echo hello`, + expectedCount: 1, + expected: `from ` + registryURL + `/onkernel/nodejs22-base:0.1.1 +RUN echo hello`, + }, + { + name: "scratch image is not rewritten", + dockerfile: `FROM scratch +COPY binary /`, + expectedCount: 0, + expected: `FROM scratch +COPY binary /`, + }, + { + name: "already local registry reference is not rewritten", + dockerfile: `FROM ` + registryURL + `/onkernel/nodejs22-base:0.1.1 +RUN echo hello`, + expectedCount: 0, + expected: `FROM ` + registryURL + `/onkernel/nodejs22-base:0.1.1 +RUN echo hello`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temp file + tmpDir := t.TempDir() + dockerfilePath := filepath.Join(tmpDir, "Dockerfile") + err := os.WriteFile(dockerfilePath, []byte(tt.dockerfile), 0644) + require.NoError(t, err) + + // Run rewrite (insecure=true for http test server) + count, err := rewriteDockerfileFROMs(dockerfilePath, registryURL, true, "") + require.NoError(t, err) + assert.Equal(t, tt.expectedCount, count) + + // Check result + result, err := os.ReadFile(dockerfilePath) + require.NoError(t, err) + assert.Equal(t, tt.expected, string(result)) + }) + } +} + +func TestNormalizeImageRef(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"docker.io/onkernel/nodejs22-base:0.1.1", "onkernel/nodejs22-base:0.1.1"}, + {"docker.io/library/alpine:3.21", "alpine:3.21"}, + {"onkernel/nodejs22-base:0.1.1", "onkernel/nodejs22-base:0.1.1"}, + {"alpine:3.21", "alpine:3.21"}, + {"library/alpine:3.21", "alpine:3.21"}, + {"nginx", "nginx"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := normalizeImageRef(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestCheckImageExistsInRegistry(t *testing.T) { + availableImages := map[string]bool{ + "myimage:v1": true, + "org/myimage:latest": true, + } + server := mockRegistryServer(availableImages) + defer server.Close() + + registryURL := strings.TrimPrefix(server.URL, "http://") + + tests := []struct { + name string + imageRef string + expected bool + }{ + { + name: "image exists with tag", + imageRef: "myimage:v1", + expected: true, + }, + { + name: "image exists with org and latest tag", + imageRef: "org/myimage:latest", + expected: true, + }, + { + name: "image does not exist", + imageRef: "notfound:v1", + expected: false, + }, + { + name: "image without tag defaults to latest", + imageRef: "org/myimage", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := checkImageExistsInRegistry(registryURL, tt.imageRef, true, "") + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/lib/builds/builder_agent/main.go b/lib/builds/builder_agent/main.go index 045b300..c6134d9 100644 --- a/lib/builds/builder_agent/main.go +++ b/lib/builds/builder_agent/main.go @@ -13,6 +13,7 @@ import ( "bytes" "context" "crypto/sha256" + "crypto/tls" "encoding/base64" "encoding/hex" "encoding/json" @@ -20,6 +21,7 @@ import ( "io" "log" "net" + "net/http" "os" "os/exec" "path/filepath" @@ -50,8 +52,8 @@ type BuildConfig struct { Secrets []SecretRef `json:"secrets,omitempty"` TimeoutSeconds int `json:"timeout_seconds"` NetworkMode string `json:"network_mode"` - IsAdminBuild bool `json:"is_admin_build,omitempty"` - GlobalCacheKey string `json:"global_cache_key,omitempty"` + IsAdminBuild bool `json:"is_admin_build,omitempty"` + GlobalCacheKey string `json:"global_cache_key,omitempty"` } // SecretRef references a secret to inject during build @@ -438,6 +440,23 @@ func runBuildProcess() { log.Println("Using Dockerfile from source") } + // Rewrite Dockerfile FROM instructions to use locally mirrored base images + // This avoids pulling base image layers from Docker Hub during builds + // The function auto-detects which images exist in the local registry + registryHost := config.RegistryURL + if strings.HasPrefix(registryHost, "https://") { + registryHost = strings.TrimPrefix(registryHost, "https://") + } else if strings.HasPrefix(registryHost, "http://") { + registryHost = strings.TrimPrefix(registryHost, "http://") + } + + rewriteCount, err := rewriteDockerfileFROMs(dockerfilePath, registryHost, config.RegistryInsecure, config.RegistryToken) + if err != nil { + log.Printf("Warning: failed to rewrite Dockerfile FROMs: %v", err) + } else if rewriteCount > 0 { + log.Printf("Rewrote %d FROM instruction(s) to use local base images", rewriteCount) + } + // Compute provenance provenance := computeProvenance(config) @@ -894,3 +913,191 @@ func getBuildkitVersion() string { out, _ := cmd.Output() return strings.TrimSpace(string(out)) } + +// rewriteDockerfileFROMs rewrites FROM instructions in a Dockerfile to use locally +// mirrored base images instead of pulling from external registries like Docker Hub. +// This is the key mechanism for avoiding Docker Hub downloads during builds. +// +// For each FROM instruction, if the base image exists in the local registry, it's +// rewritten to use the local registry reference. For example: +// +// FROM onkernel/nodejs22-base:0.1.1 +// +// becomes: +// +// FROM 172.30.0.1:8080/onkernel/nodejs22-base:0.1.1 +// +// The function handles multi-stage builds (multiple FROM instructions) and preserves +// AS aliases and other Dockerfile syntax. +func rewriteDockerfileFROMs(dockerfilePath, registryURL string, insecure bool, registryToken string) (int, error) { + content, err := os.ReadFile(dockerfilePath) + if err != nil { + return 0, fmt.Errorf("read dockerfile: %w", err) + } + + lines := strings.Split(string(content), "\n") + rewriteCount := 0 + + for i, line := range lines { + trimmed := strings.TrimSpace(line) + + // Skip empty lines and comments + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + + // Check for FROM instruction (case insensitive) + upper := strings.ToUpper(trimmed) + if !strings.HasPrefix(upper, "FROM ") { + continue + } + + // Parse the FROM instruction + // Formats: FROM image, FROM image AS name, FROM image:tag, FROM image:tag AS name + // Also: FROM --platform=xxx image ... + parts := strings.Fields(trimmed) + if len(parts) < 2 { + continue + } + + // Find the image reference (skip FROM and any flags like --platform) + imageIdx := 1 + for imageIdx < len(parts) && strings.HasPrefix(parts[imageIdx], "--") { + imageIdx++ + } + if imageIdx >= len(parts) { + continue + } + + imageRef := parts[imageIdx] + + // Skip if already referencing the local registry + if strings.HasPrefix(imageRef, registryURL+"/") { + continue + } + + // Skip scratch image (special case in Docker) + if imageRef == "scratch" { + continue + } + + // Normalize the image reference + // Docker Hub images can be referenced as: + // - "nginx" (library image) + // - "nginx:1.21" + // - "library/nginx:1.21" + // - "docker.io/library/nginx:1.21" + // - "onkernel/nodejs22-base:0.1.1" + // - "docker.io/onkernel/nodejs22-base:0.1.1" + normalizedRef := normalizeImageRef(imageRef) + + // Check if the image exists in the local registry + if !checkImageExistsInRegistry(registryURL, normalizedRef, insecure, registryToken) { + log.Printf("Base image not found locally, will pull from upstream: %s", imageRef) + continue + } + + // Build the new image reference with the local registry + newImageRef := fmt.Sprintf("%s/%s", registryURL, normalizedRef) + + // Reconstruct the FROM line with the new image reference + parts[imageIdx] = newImageRef + newLine := strings.Join(parts, " ") + + // Preserve original indentation + indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))] + lines[i] = indent + newLine + + log.Printf("Rewriting FROM: %s -> %s", imageRef, newImageRef) + rewriteCount++ + } + + if rewriteCount == 0 { + return 0, nil + } + + // Write the modified Dockerfile back + newContent := strings.Join(lines, "\n") + if err := os.WriteFile(dockerfilePath, []byte(newContent), 0644); err != nil { + return 0, fmt.Errorf("write dockerfile: %w", err) + } + + return rewriteCount, nil +} + +// checkImageExistsInRegistry checks if an image exists in the local registry +// by making a HEAD request to the manifest endpoint. +func checkImageExistsInRegistry(registryURL, imageRef string, insecure bool, registryToken string) bool { + // Parse the image reference to extract repo and tag + repo := imageRef + tag := "latest" + + // Handle digest references (repo@sha256:...) + if strings.Contains(imageRef, "@") { + parts := strings.SplitN(imageRef, "@", 2) + repo = parts[0] + tag = parts[1] // This will be the full digest like sha256:abc123 + } else if strings.Contains(imageRef, ":") { + // Handle tag references (repo:tag) + lastColon := strings.LastIndex(imageRef, ":") + repo = imageRef[:lastColon] + tag = imageRef[lastColon+1:] + } + + // Build the manifest URL + scheme := "https" + if insecure { + scheme = "http" + } + url := fmt.Sprintf("%s://%s/v2/%s/manifests/%s", scheme, registryURL, repo, tag) + + // Create HTTP client with appropriate TLS settings + client := &http.Client{ + Timeout: 5 * time.Second, + } + if insecure { + client.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + } + + // Make HEAD request to check if manifest exists + req, err := http.NewRequest(http.MethodHead, url, nil) + if err != nil { + log.Printf("Failed to create request for %s: %v", url, err) + return false + } + + // Accept OCI and Docker manifest types + req.Header.Set("Accept", "application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json") + + if registryToken != "" { + req.Header.Set("Authorization", "Bearer "+registryToken) + } + + resp, err := client.Do(req) + if err != nil { + log.Printf("Failed to check image %s in registry: %v", imageRef, err) + return false + } + defer resp.Body.Close() + + // 200 means the image exists + return resp.StatusCode == http.StatusOK +} + +// normalizeImageRef normalizes a Docker image reference for consistent lookup. +// It handles various forms of Docker Hub image references: +// - "nginx" -> "nginx" (library images stay as-is for simple lookup) +// - "nginx:1.21" -> "nginx:1.21" +// - "docker.io/library/nginx:1.21" -> "nginx:1.21" +// - "docker.io/onkernel/nodejs22-base:0.1.1" -> "onkernel/nodejs22-base:0.1.1" +func normalizeImageRef(ref string) string { + // Strip docker.io/ prefix if present + ref = strings.TrimPrefix(ref, "docker.io/") + + // Strip library/ prefix for official images + ref = strings.TrimPrefix(ref, "library/") + + return ref +} diff --git a/lib/builds/cache_integration_test.go b/lib/builds/cache_integration_test.go new file mode 100644 index 0000000..8c313f1 --- /dev/null +++ b/lib/builds/cache_integration_test.go @@ -0,0 +1,597 @@ +package builds + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/network" + "github.com/testcontainers/testcontainers-go/wait" +) + +// TestBuildKitCacheContainsLayerBlobs verifies that when BuildKit exports a cache +// with image-manifest=true, the cache image contains actual layer blobs (not just +// references to external registries like Docker Hub). +// +// This test reproduces the issue where: +// 1. Admin build populates global cache with image-manifest=true +// 2. New tenant's first build imports this cache +// 3. Despite the import, FROM instruction still downloads base image layers from Docker Hub +// +// The root cause is that BuildKit's registry cache stores metadata for FROM instructions, +// but the actual base image layers are referenced (not copied) to the cache. +func TestBuildKitCacheContainsLayerBlobs(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + ctx := context.Background() + + // Create a shared network for the containers + testNetwork, err := network.New(ctx) + require.NoError(t, err, "failed to create network") + defer testNetwork.Remove(ctx) + + networkName := testNetwork.Name + + // Start registry container + registryC, registryHostExternal, registryHostInternal := startRegistryContainer(t, ctx, networkName) + defer registryC.Terminate(ctx) + + // Start BuildKit container on the same network + buildkitC := startBuildKitContainer(t, ctx, networkName) + defer buildkitC.Terminate(ctx) + + // Use internal hostname for BuildKit to reach registry + registryHost := registryHostInternal + // Use external hostname for test to reach registry + buildkitHost := registryHostExternal + + // Create test Dockerfile that uses a base image + srcDir := t.TempDir() + dockerfile := `FROM alpine:3.21 +RUN echo "test layer 1" > /layer1.txt +RUN echo "test layer 2" > /layer2.txt +` + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "Dockerfile"), []byte(dockerfile), 0644)) + + // Copy source to BuildKit container + copyToContainer(t, ctx, buildkitC, srcDir, "/src") + + // Build and export cache with image-manifest=true + cacheRef := fmt.Sprintf("%s/cache/test/alpine-cache:v1", registryHost) + outputRef := fmt.Sprintf("%s/builds/test-build:v1", registryHost) + + t.Log("Building image and exporting cache with image-manifest=true...") + buildCmd := []string{ + "buildctl", "build", + "--frontend", "dockerfile.v0", + "--local", "context=/src", + "--local", "dockerfile=/src", + "--output", fmt.Sprintf("type=image,name=%s,push=true,registry.insecure=true", outputRef), + "--export-cache", fmt.Sprintf("type=registry,ref=%s,mode=max,image-manifest=true,oci-mediatypes=true,registry.insecure=true", cacheRef), + } + + exitCode, output, err := execInContainer(t, ctx, buildkitC, buildCmd) + t.Logf("BuildKit output:\n%s", output) + require.NoError(t, err) + require.Equal(t, 0, exitCode, "buildctl failed with exit code %d", exitCode) + + // Now inspect the cache image to verify it contains layer blobs + t.Log("Inspecting cache image...") + + // Fetch the cache manifest from registry + manifestURL := fmt.Sprintf("http://%s/v2/cache/test/alpine-cache/manifests/v1", buildkitHost) + req, _ := http.NewRequest("GET", manifestURL, nil) + req.Header.Set("Accept", "application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err, "failed to fetch cache manifest") + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + t.Logf("Cache manifest response (status %d):\n%s", resp.StatusCode, string(body)) + + if resp.StatusCode != http.StatusOK { + t.Fatalf("Failed to fetch cache manifest: status %d", resp.StatusCode) + } + + var manifest struct { + MediaType string `json:"mediaType"` + Layers []struct { + MediaType string `json:"mediaType"` + Size int64 `json:"size"` + Digest string `json:"digest"` + URLs []string `json:"urls,omitempty"` // Foreign layer URLs + } `json:"layers"` + } + require.NoError(t, json.Unmarshal(body, &manifest)) + + t.Logf("Cache manifest has %d layers", len(manifest.Layers)) + + // Check each layer for foreign references + var foreignLayers, localLayers int + for i, layer := range manifest.Layers { + t.Logf("Layer %d: digest=%s, size=%d, mediaType=%s", i, layer.Digest, layer.Size, layer.MediaType) + + // Check if layer has foreign URLs (indicates reference to external registry) + if len(layer.URLs) > 0 { + t.Logf(" Layer %d: FOREIGN - has external URLs: %v", i, layer.URLs) + foreignLayers++ + continue + } + + // Try to HEAD the layer blob to verify it exists locally + layerURL := fmt.Sprintf("http://%s/v2/cache/test/alpine-cache/blobs/%s", buildkitHost, layer.Digest) + layerResp, err := http.Head(layerURL) + if err != nil || layerResp.StatusCode != http.StatusOK { + t.Logf(" Layer %d: NOT FOUND in registry", i) + foreignLayers++ + } else { + t.Logf(" Layer %d: FOUND in registry (local blob)", i) + localLayers++ + layerResp.Body.Close() + } + } + + t.Logf("Summary: %d local layers, %d foreign layers", localLayers, foreignLayers) + + // The key assertion: with image-manifest=true, ALL layers should be stored locally + assert.Equal(t, 0, foreignLayers, + "Cache should contain all layer blobs locally (no foreign references). "+ + "Foreign layers indicate base image layers are still referenced to Docker Hub.") + assert.Greater(t, localLayers, 0, "Cache should have at least one local layer") +} + +// TestBuildKitCacheHitForBaseImageLayers verifies that when importing a cache, +// BuildKit actually uses the cached layers for the FROM instruction. +// +// This test: +// 1. Builds an image and exports cache (simulating admin cache population) +// 2. Prunes BuildKit's local cache +// 3. Builds again with only import-cache (simulating fresh tenant build) +// 4. Analyzes output to verify cache behavior for base image layers +func TestBuildKitCacheHitForBaseImageLayers(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + ctx := context.Background() + + // Create a shared network for the containers + testNetwork, err := network.New(ctx) + require.NoError(t, err, "failed to create network") + defer testNetwork.Remove(ctx) + + networkName := testNetwork.Name + + // Start registry container + registryC, _, registryHost := startRegistryContainer(t, ctx, networkName) + defer registryC.Terminate(ctx) + + // Start BuildKit container on the same network + buildkitC := startBuildKitContainer(t, ctx, networkName) + defer buildkitC.Terminate(ctx) + + // Create test Dockerfile + srcDir := t.TempDir() + dockerfile := `FROM alpine:3.21 +RUN echo "cache test" > /test.txt +` + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "Dockerfile"), []byte(dockerfile), 0644)) + + // Copy source to BuildKit container + copyToContainer(t, ctx, buildkitC, srcDir, "/src") + + cacheRef := fmt.Sprintf("%s/cache/global/test-runtime:v1", registryHost) + outputRef1 := fmt.Sprintf("%s/builds/admin-build:v1", registryHost) + outputRef2 := fmt.Sprintf("%s/builds/tenant-build:v1", registryHost) + + // Step 1: Admin build - populate cache + t.Log("Step 1: Admin build to populate cache...") + buildCmd1 := []string{ + "buildctl", "build", + "--frontend", "dockerfile.v0", + "--local", "context=/src", + "--local", "dockerfile=/src", + "--output", fmt.Sprintf("type=image,name=%s,push=true,registry.insecure=true", outputRef1), + "--export-cache", fmt.Sprintf("type=registry,ref=%s,mode=max,image-manifest=true,oci-mediatypes=true,registry.insecure=true", cacheRef), + } + + exitCode, output, err := execInContainer(t, ctx, buildkitC, buildCmd1) + t.Logf("Admin build output:\n%s", output) + require.NoError(t, err) + require.Equal(t, 0, exitCode, "Admin build failed") + + // Step 2: Clear BuildKit's local cache to simulate fresh/ephemeral environment + t.Log("Step 2: Clearing BuildKit local cache...") + pruneCmd := []string{"buildctl", "prune", "--all"} + execInContainer(t, ctx, buildkitC, pruneCmd) // Ignore errors + + // Step 3: Tenant build - import from cache only (no export) + t.Log("Step 3: Tenant build with cache import...") + buildCmd2 := []string{ + "buildctl", "build", + "--frontend", "dockerfile.v0", + "--local", "context=/src", + "--local", "dockerfile=/src", + "--output", fmt.Sprintf("type=image,name=%s,push=true,registry.insecure=true", outputRef2), + "--import-cache", fmt.Sprintf("type=registry,ref=%s,registry.insecure=true", cacheRef), + "--progress", "plain", + } + + exitCode, output, err = execInContainer(t, ctx, buildkitC, buildCmd2) + buildLog := output + t.Logf("Tenant build output:\n%s", buildLog) + require.NoError(t, err) + require.Equal(t, 0, exitCode, "Tenant build failed") + + // Analyze build output to determine if cache was used for base image + t.Log("Analyzing build output for cache effectiveness...") + + // Check for actual layer download from Docker Hub (shows download progress like "3.64MB / 3.64MB") + // Note: "resolve docker.io" is just metadata resolution (checking digest), not layer download + hasLayerDownload := strings.Contains(buildLog, "MB /") || // Download progress indicator + strings.Contains(buildLog, "extracting sha256:") // Layer extraction + + // Check for metadata resolution (this is expected - BuildKit needs to verify the digest) + hasMetadataResolve := strings.Contains(buildLog, "resolve docker.io") + + // Check for CACHED indicator on steps + hasCachedSteps := strings.Contains(buildLog, "CACHED") + + // Check for cache import messages + hasCacheImport := strings.Contains(buildLog, "importing cache manifest") + + t.Logf("Analysis results:") + t.Logf(" - Cache import detected: %v", hasCacheImport) + t.Logf(" - Metadata resolution (docker.io): %v (this is normal - just checking digest)", hasMetadataResolve) + t.Logf(" - Actual layer download detected: %v", hasLayerDownload) + t.Logf(" - Has CACHED steps: %v", hasCachedSteps) + + // Document the expected behavior vs actual behavior + if hasLayerDownload { + t.Log("") + t.Log("=== ISSUE REPRODUCED ===") + t.Log("Layer download detected despite cache import.") + t.Log("This confirms that BuildKit's registry cache does not effectively") + t.Log("provide base image layers from the cache.") + t.Log("") + } else if hasMetadataResolve && !hasLayerDownload { + t.Log("") + t.Log("=== CACHE WORKING CORRECTLY ===") + t.Log("Metadata was resolved from Docker Hub (normal behavior to verify digest),") + t.Log("but NO layer download occurred - layers were served from cache!") + t.Log("") + } + + // The key assertion: check for actual layer downloads, not just metadata resolution + // Metadata resolution is expected, but layer download should NOT happen with proper cache + assert.False(t, hasLayerDownload, + "Build should NOT download layers from Docker Hub when cache is available. "+ + "Layer download indicators (MB progress, extraction) should not appear.") +} + +// TestCacheExportArgsFormat verifies the cache export arguments are correctly formatted. +func TestCacheExportArgsFormat(t *testing.T) { + key := &CacheKey{ + Reference: "localhost:5000/cache/tenant/nodejs/abc123", + TenantScope: "tenant", + Runtime: "nodejs", + LockfileHash: "abc123", + } + + exportArg := key.ExportCacheArg() + + // Verify all required options are present + assert.Contains(t, exportArg, "type=registry") + assert.Contains(t, exportArg, "ref=localhost:5000/cache/tenant/nodejs/abc123") + assert.Contains(t, exportArg, "mode=max") + assert.Contains(t, exportArg, "image-manifest=true") + assert.Contains(t, exportArg, "oci-mediatypes=true") + + // Verify the exact format matches what BuildKit expects + expected := "type=registry,ref=localhost:5000/cache/tenant/nodejs/abc123,mode=max,image-manifest=true,oci-mediatypes=true" + assert.Equal(t, expected, exportArg) +} + +// startRegistryContainer starts a Docker registry container for testing. +// Returns the container, external host (for test access), and internal host (for container-to-container access). +func startRegistryContainer(t *testing.T, ctx context.Context, networkName string) (testcontainers.Container, string, string) { + t.Helper() + + const registryAlias = "registry" + + req := testcontainers.ContainerRequest{ + Image: "registry:2", + ExposedPorts: []string{"5000/tcp"}, + WaitingFor: wait.ForHTTP("/v2/").WithPort("5000/tcp"), + Networks: []string{networkName}, + NetworkAliases: map[string][]string{ + networkName: {registryAlias}, + }, + } + + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + require.NoError(t, err, "failed to start registry container") + + // Get the mapped port for external access + mappedPort, err := container.MappedPort(ctx, "5000") + require.NoError(t, err) + + host, err := container.Host(ctx) + require.NoError(t, err) + + externalHost := fmt.Sprintf("%s:%s", host, mappedPort.Port()) + internalHost := fmt.Sprintf("%s:5000", registryAlias) // Container-to-container uses alias:port + + t.Logf("Registry started - external: %s, internal: %s", externalHost, internalHost) + + return container, externalHost, internalHost +} + +// startBuildKitContainer starts a BuildKit container for testing on the specified network. +func startBuildKitContainer(t *testing.T, ctx context.Context, networkName string) testcontainers.Container { + t.Helper() + + req := testcontainers.ContainerRequest{ + Image: "moby/buildkit:latest", + Privileged: true, + Entrypoint: []string{"buildkitd"}, + WaitingFor: wait.ForLog("running server").WithStartupTimeout(30 * time.Second), + Networks: []string{networkName}, + } + + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + require.NoError(t, err, "failed to start BuildKit container") + + t.Log("BuildKit container started") + + return container +} + +// copyToContainer copies a directory to the container. +func copyToContainer(t *testing.T, ctx context.Context, container testcontainers.Container, srcPath, dstPath string) { + t.Helper() + + // First create the destination directory + _, _, err := container.Exec(ctx, []string{"mkdir", "-p", dstPath}) + require.NoError(t, err, "failed to create directory %s in container", dstPath) + + // Copy files individually + files, err := os.ReadDir(srcPath) + require.NoError(t, err, "failed to read source directory %s", srcPath) + + for _, file := range files { + srcFile := filepath.Join(srcPath, file.Name()) + content, err := os.ReadFile(srcFile) + require.NoError(t, err, "failed to read file %s", srcFile) + + err = container.CopyToContainer(ctx, content, filepath.Join(dstPath, file.Name()), 0644) + require.NoError(t, err, "failed to copy %s to container", file.Name()) + } +} + +// execInContainer executes a command in the container and returns exit code, output, and error. +func execInContainer(t *testing.T, ctx context.Context, container testcontainers.Container, cmd []string) (int, string, error) { + t.Helper() + + exitCode, reader, err := container.Exec(ctx, cmd) + if err != nil { + return exitCode, "", err + } + + output, _ := io.ReadAll(reader) + return exitCode, string(output), nil +} + +// TestCacheMismatchWithDifferentBaseImage demonstrates that cache populated with +// one base image does NOT help builds using a different base image. +// This reproduces the production issue where: +// - Global cache was populated with one Dockerfile (e.g., FROM node:20-alpine) +// - Tenant builds use a different base image (e.g., FROM onkernel/nodejs22-base:0.1.1) +// - Cache import succeeds but layers still download because digests don't match +func TestCacheMismatchWithDifferentBaseImage(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + ctx := context.Background() + + // Create a shared network + testNetwork, err := network.New(ctx) + require.NoError(t, err, "failed to create network") + defer testNetwork.Remove(ctx) + + networkName := testNetwork.Name + + // Start registry and BuildKit + registryC, _, registryHostInternal := startRegistryContainer(t, ctx, networkName) + defer registryC.Terminate(ctx) + + buildkitC := startBuildKitContainer(t, ctx, networkName) + defer buildkitC.Terminate(ctx) + + registryHost := registryHostInternal + cacheRef := fmt.Sprintf("%s/cache/global/node:v1", registryHost) + + // Step 1: Populate cache with alpine:3.21 (simulating admin build with different base) + t.Log("Step 1: Populating cache with alpine:3.21 (different base image)...") + srcDir1 := t.TempDir() + dockerfile1 := `FROM alpine:3.21 +RUN echo "admin build" > /admin.txt +` + require.NoError(t, os.WriteFile(filepath.Join(srcDir1, "Dockerfile"), []byte(dockerfile1), 0644)) + copyToContainer(t, ctx, buildkitC, srcDir1, "/src1") + + buildCmd1 := []string{ + "buildctl", "build", + "--frontend", "dockerfile.v0", + "--local", "context=/src1", + "--local", "dockerfile=/src1", + "--output", fmt.Sprintf("type=image,name=%s/builds/admin:v1,push=true,registry.insecure=true", registryHost), + "--export-cache", fmt.Sprintf("type=registry,ref=%s,mode=max,image-manifest=true,oci-mediatypes=true,registry.insecure=true", cacheRef), + } + + exitCode, output, err := execInContainer(t, ctx, buildkitC, buildCmd1) + t.Logf("Admin build output:\n%s", output) + require.NoError(t, err) + require.Equal(t, 0, exitCode) + + // Step 2: Clear local cache + t.Log("Step 2: Clearing local cache...") + execInContainer(t, ctx, buildkitC, []string{"buildctl", "prune", "--all"}) + + // Step 3: Tenant build with DIFFERENT base image (alpine:3.20 instead of 3.21) + t.Log("Step 3: Tenant build with DIFFERENT base image (alpine:3.20)...") + srcDir2 := t.TempDir() + dockerfile2 := `FROM alpine:3.20 +RUN echo "tenant build" > /tenant.txt +` + require.NoError(t, os.WriteFile(filepath.Join(srcDir2, "Dockerfile"), []byte(dockerfile2), 0644)) + copyToContainer(t, ctx, buildkitC, srcDir2, "/src2") + + buildCmd2 := []string{ + "buildctl", "build", + "--frontend", "dockerfile.v0", + "--local", "context=/src2", + "--local", "dockerfile=/src2", + "--output", fmt.Sprintf("type=image,name=%s/builds/tenant:v1,push=true,registry.insecure=true", registryHost), + "--import-cache", fmt.Sprintf("type=registry,ref=%s,registry.insecure=true", cacheRef), + "--progress", "plain", + } + + exitCode, output, err = execInContainer(t, ctx, buildkitC, buildCmd2) + buildLog := output + t.Logf("Tenant build output:\n%s", buildLog) + require.NoError(t, err) + require.Equal(t, 0, exitCode) + + // Analyze: cache import works, but layers still download + hasCacheImport := strings.Contains(buildLog, "importing cache manifest") + hasLayerDownload := strings.Contains(buildLog, "MB /") || strings.Contains(buildLog, "extracting sha256:") + + t.Logf("Analysis:") + t.Logf(" - Cache import: %v", hasCacheImport) + t.Logf(" - Layer download: %v", hasLayerDownload) + + // This demonstrates the problem: cache imports successfully but layers still download + // because the base images are different (alpine:3.21 vs alpine:3.20) + assert.True(t, hasCacheImport, "Cache manifest should be imported") + assert.True(t, hasLayerDownload, + "EXPECTED: Layers SHOULD download because base image differs. "+ + "This demonstrates that cache populated with one base image doesn't help "+ + "builds using a different base image.") + + t.Log("") + t.Log("=== TEST DEMONSTRATES THE PRODUCTION ISSUE ===") + t.Log("Cache was imported successfully, but layers still downloaded because") + t.Log("the cached layers (from alpine:3.21) don't match the required layers") + t.Log("(from alpine:3.20). The layer digests are completely different.") + t.Log("") + t.Log("FIX: Ensure populate-global-cache uses the SAME base image that") + t.Log("tenant Dockerfiles use (e.g., onkernel/nodejs22-base:0.1.1)") +} + +// TestInspectCacheManifestStructure inspects the structure of a BuildKit cache manifest +// to understand what's being stored. +func TestInspectCacheManifestStructure(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + ctx := context.Background() + + // Create a shared network for the containers + testNetwork, err := network.New(ctx) + require.NoError(t, err, "failed to create network") + defer testNetwork.Remove(ctx) + + networkName := testNetwork.Name + + // Start registry container + registryC, registryHostExternal, registryHostInternal := startRegistryContainer(t, ctx, networkName) + defer registryC.Terminate(ctx) + + // Start BuildKit container on the same network + buildkitC := startBuildKitContainer(t, ctx, networkName) + defer buildkitC.Terminate(ctx) + + // Use internal host for BuildKit, external for test HTTP requests + registryHost := registryHostInternal + buildkitHostForRegistry := registryHostExternal + + // Create simple Dockerfile + srcDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "Dockerfile"), []byte(`FROM alpine:3.21 +RUN echo "test" > /test.txt +`), 0644)) + + copyToContainer(t, ctx, buildkitC, srcDir, "/src") + + cacheRef := fmt.Sprintf("%s/cache/inspect-test:v1", registryHost) + outputRef := fmt.Sprintf("%s/builds/inspect-test:v1", registryHost) + + // Build with cache export + buildCmd := []string{ + "buildctl", "build", + "--frontend", "dockerfile.v0", + "--local", "context=/src", + "--local", "dockerfile=/src", + "--output", fmt.Sprintf("type=image,name=%s,push=true,registry.insecure=true", outputRef), + "--export-cache", fmt.Sprintf("type=registry,ref=%s,mode=max,image-manifest=true,oci-mediatypes=true,registry.insecure=true", cacheRef), + } + + exitCode, output, err := execInContainer(t, ctx, buildkitC, buildCmd) + t.Logf("Build output:\n%s", output) + require.NoError(t, err) + require.Equal(t, 0, exitCode) + + // Fetch and inspect the cache manifest + manifestURL := fmt.Sprintf("http://%s/v2/cache/inspect-test/manifests/v1", buildkitHostForRegistry) + req, _ := http.NewRequest("GET", manifestURL, nil) + req.Header.Set("Accept", "application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + t.Logf("Cache manifest (status %d):\n%s", resp.StatusCode, string(body)) + + if resp.StatusCode == http.StatusOK { + var manifest map[string]interface{} + if err := json.Unmarshal(body, &manifest); err == nil { + t.Logf("Manifest mediaType: %s", manifest["mediaType"]) + if layers, ok := manifest["layers"].([]interface{}); ok { + t.Logf("Number of layers: %d", len(layers)) + for i, layer := range layers { + if l, ok := layer.(map[string]interface{}); ok { + t.Logf(" Layer %d: mediaType=%s, size=%v, digest=%s", + i, l["mediaType"], l["size"], l["digest"]) + // Check for foreign layer URLs + if urls, ok := l["urls"]; ok { + t.Logf(" WARNING: Layer has external URLs: %v", urls) + } + } + } + } + } + } +} diff --git a/lib/builds/dockerfile.go b/lib/builds/dockerfile.go new file mode 100644 index 0000000..a968a57 --- /dev/null +++ b/lib/builds/dockerfile.go @@ -0,0 +1,138 @@ +package builds + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// ParseDockerfileFROMs extracts and deduplicates base image references from +// Dockerfile content. It reuses the same parsing logic as the builder agent's +// rewriteDockerfileFROMs: split lines, find FROM, skip flags/comments/scratch, +// normalize refs. Inter-stage references (FROM builder) and variable references +// (${VAR}) are skipped since they can't be resolved at parse time. +func ParseDockerfileFROMs(content string) []string { + lines := strings.Split(content, "\n") + + // Track stage names so we can skip inter-stage FROM references + stageNames := make(map[string]bool) + seen := make(map[string]bool) + var refs []string + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + // Skip empty lines and comments + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + + // Check for FROM instruction (case insensitive) + upper := strings.ToUpper(trimmed) + if !strings.HasPrefix(upper, "FROM ") { + continue + } + + parts := strings.Fields(trimmed) + if len(parts) < 2 { + continue + } + + // Find the image reference (skip FROM and any flags like --platform) + imageIdx := 1 + for imageIdx < len(parts) && strings.HasPrefix(parts[imageIdx], "--") { + imageIdx++ + } + if imageIdx >= len(parts) { + continue + } + + imageRef := parts[imageIdx] + + // Record AS alias if present + for j := imageIdx + 1; j < len(parts)-1; j++ { + if strings.EqualFold(parts[j], "AS") { + stageNames[strings.ToLower(parts[j+1])] = true + break + } + } + + // Skip scratch + if imageRef == "scratch" { + continue + } + + // Skip inter-stage references (e.g. FROM builder) + if stageNames[strings.ToLower(imageRef)] { + continue + } + + // Skip variable references that can't be resolved + if strings.Contains(imageRef, "${") { + continue + } + + // Normalize the image reference (same logic as builder agent) + normalized := normalizeImageRef(imageRef) + + if !seen[normalized] { + seen[normalized] = true + refs = append(refs, normalized) + } + } + + return refs +} + +// normalizeImageRef normalizes a Docker image reference by stripping +// docker.io/ and library/ prefixes. This matches the builder agent's +// normalizeImageRef function. +func normalizeImageRef(ref string) string { + ref = strings.TrimPrefix(ref, "docker.io/") + ref = strings.TrimPrefix(ref, "library/") + return ref +} + +// ExtractDockerfileFromTarball reads just the Dockerfile entry from a .tar.gz +// archive and returns its content as a string. It looks for entries named +// "Dockerfile" or "./Dockerfile" at the root of the archive. +func ExtractDockerfileFromTarball(tarballPath string) (string, error) { + f, err := os.Open(tarballPath) + if err != nil { + return "", fmt.Errorf("open tarball: %w", err) + } + defer f.Close() + + gz, err := gzip.NewReader(f) + if err != nil { + return "", fmt.Errorf("create gzip reader: %w", err) + } + defer gz.Close() + + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return "", fmt.Errorf("read tar entry: %w", err) + } + + // Match Dockerfile at root (with or without ./ prefix) + name := filepath.Clean(hdr.Name) + if name == "Dockerfile" { + data, err := io.ReadAll(tr) + if err != nil { + return "", fmt.Errorf("read Dockerfile from tarball: %w", err) + } + return string(data), nil + } + } + + return "", fmt.Errorf("Dockerfile not found in tarball") +} diff --git a/lib/builds/dockerfile_test.go b/lib/builds/dockerfile_test.go new file mode 100644 index 0000000..620829f --- /dev/null +++ b/lib/builds/dockerfile_test.go @@ -0,0 +1,230 @@ +package builds + +import ( + "archive/tar" + "compress/gzip" + "os" + "path/filepath" + "testing" +) + +func TestParseDockerfileFROMs_SingleFROM(t *testing.T) { + content := `FROM onkernel/nodejs22-base:0.1.1 +RUN echo hello +` + refs := ParseDockerfileFROMs(content) + if len(refs) != 1 { + t.Fatalf("expected 1 ref, got %d: %v", len(refs), refs) + } + if refs[0] != "onkernel/nodejs22-base:0.1.1" { + t.Errorf("expected onkernel/nodejs22-base:0.1.1, got %s", refs[0]) + } +} + +func TestParseDockerfileFROMs_MultiStage(t *testing.T) { + content := `FROM golang:1.21 AS builder +RUN go build -o /app . + +FROM alpine:3.21 +COPY --from=builder /app /app +` + refs := ParseDockerfileFROMs(content) + if len(refs) != 2 { + t.Fatalf("expected 2 refs, got %d: %v", len(refs), refs) + } + if refs[0] != "golang:1.21" { + t.Errorf("expected golang:1.21, got %s", refs[0]) + } + if refs[1] != "alpine:3.21" { + t.Errorf("expected alpine:3.21, got %s", refs[1]) + } +} + +func TestParseDockerfileFROMs_DockerIONormalization(t *testing.T) { + content := `FROM docker.io/library/alpine:3.21 +` + refs := ParseDockerfileFROMs(content) + if len(refs) != 1 { + t.Fatalf("expected 1 ref, got %d: %v", len(refs), refs) + } + if refs[0] != "alpine:3.21" { + t.Errorf("expected alpine:3.21, got %s", refs[0]) + } +} + +func TestParseDockerfileFROMs_PlatformFlag(t *testing.T) { + content := `FROM --platform=linux/amd64 node:20-alpine +RUN npm install +` + refs := ParseDockerfileFROMs(content) + if len(refs) != 1 { + t.Fatalf("expected 1 ref, got %d: %v", len(refs), refs) + } + if refs[0] != "node:20-alpine" { + t.Errorf("expected node:20-alpine, got %s", refs[0]) + } +} + +func TestParseDockerfileFROMs_SkipScratch(t *testing.T) { + content := `FROM golang:1.21 AS builder +RUN go build -o /app . + +FROM scratch +COPY --from=builder /app /app +` + refs := ParseDockerfileFROMs(content) + if len(refs) != 1 { + t.Fatalf("expected 1 ref, got %d: %v", len(refs), refs) + } + if refs[0] != "golang:1.21" { + t.Errorf("expected golang:1.21, got %s", refs[0]) + } +} + +func TestParseDockerfileFROMs_SkipStageReferences(t *testing.T) { + content := `FROM node:20 AS deps +RUN npm ci + +FROM node:20 AS builder +COPY --from=deps /app/node_modules ./node_modules +RUN npm run build + +FROM builder +CMD ["node", "dist/index.js"] +` + refs := ParseDockerfileFROMs(content) + if len(refs) != 1 { + t.Fatalf("expected 1 ref (deduplicated), got %d: %v", len(refs), refs) + } + if refs[0] != "node:20" { + t.Errorf("expected node:20, got %s", refs[0]) + } +} + +func TestParseDockerfileFROMs_SkipVariableReferences(t *testing.T) { + content := `ARG BASE_IMAGE=node:20 +FROM ${BASE_IMAGE} +RUN echo hello +` + refs := ParseDockerfileFROMs(content) + if len(refs) != 0 { + t.Fatalf("expected 0 refs (variable), got %d: %v", len(refs), refs) + } +} + +func TestParseDockerfileFROMs_Deduplication(t *testing.T) { + content := `FROM alpine:3.21 AS stage1 +RUN echo one + +FROM alpine:3.21 AS stage2 +RUN echo two +` + refs := ParseDockerfileFROMs(content) + if len(refs) != 1 { + t.Fatalf("expected 1 ref (deduplicated), got %d: %v", len(refs), refs) + } + if refs[0] != "alpine:3.21" { + t.Errorf("expected alpine:3.21, got %s", refs[0]) + } +} + +func TestParseDockerfileFROMs_CommentsAndEmptyLines(t *testing.T) { + content := `# Build stage +FROM golang:1.21 + +# This is a comment +# FROM fake:image + +RUN echo hello +` + refs := ParseDockerfileFROMs(content) + if len(refs) != 1 { + t.Fatalf("expected 1 ref, got %d: %v", len(refs), refs) + } + if refs[0] != "golang:1.21" { + t.Errorf("expected golang:1.21, got %s", refs[0]) + } +} + +func TestExtractDockerfileFromTarball(t *testing.T) { + // Create a temp tarball with a Dockerfile + dir := t.TempDir() + tarballPath := filepath.Join(dir, "source.tar.gz") + + dockerfileContent := "FROM alpine:3.21\nRUN echo hello\n" + createTarball(t, tarballPath, map[string]string{ + "Dockerfile": dockerfileContent, + "main.go": "package main\n", + }) + + content, err := ExtractDockerfileFromTarball(tarballPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if content != dockerfileContent { + t.Errorf("expected %q, got %q", dockerfileContent, content) + } +} + +func TestExtractDockerfileFromTarball_NotFound(t *testing.T) { + dir := t.TempDir() + tarballPath := filepath.Join(dir, "source.tar.gz") + + createTarball(t, tarballPath, map[string]string{ + "main.go": "package main\n", + }) + + _, err := ExtractDockerfileFromTarball(tarballPath) + if err == nil { + t.Fatal("expected error for missing Dockerfile") + } +} + +func TestExtractDockerfileFromTarball_DotSlashPrefix(t *testing.T) { + dir := t.TempDir() + tarballPath := filepath.Join(dir, "source.tar.gz") + + dockerfileContent := "FROM node:20\nRUN npm install\n" + createTarball(t, tarballPath, map[string]string{ + "./Dockerfile": dockerfileContent, + }) + + content, err := ExtractDockerfileFromTarball(tarballPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if content != dockerfileContent { + t.Errorf("expected %q, got %q", dockerfileContent, content) + } +} + +// createTarball creates a .tar.gz file with the given files (name -> content). +func createTarball(t *testing.T, path string, files map[string]string) { + t.Helper() + + f, err := os.Create(path) + if err != nil { + t.Fatalf("create tarball file: %v", err) + } + defer f.Close() + + gw := gzip.NewWriter(f) + defer gw.Close() + + tw := tar.NewWriter(gw) + defer tw.Close() + + for name, content := range files { + hdr := &tar.Header{ + Name: name, + Mode: 0644, + Size: int64(len(content)), + } + if err := tw.WriteHeader(hdr); err != nil { + t.Fatalf("write tar header for %s: %v", name, err) + } + if _, err := tw.Write([]byte(content)); err != nil { + t.Fatalf("write tar content for %s: %v", name, err) + } + } +} diff --git a/lib/builds/manager.go b/lib/builds/manager.go index 3a612ba..9321ca1 100644 --- a/lib/builds/manager.go +++ b/lib/builds/manager.go @@ -248,6 +248,30 @@ func (m *manager) CreateBuild(ctx context.Context, req CreateBuildRequest, sourc } } + // Add pull access for base image repos so the builder agent can + // detect mirrored images via checkImageExistsInRegistry + dockerfileContent := req.Dockerfile + if dockerfileContent == "" { + tarballPath := m.paths.BuildSourceDir(id) + "/source.tar.gz" + if content, err := ExtractDockerfileFromTarball(tarballPath); err == nil { + dockerfileContent = content + } + } + if dockerfileContent != "" { + refs := ParseDockerfileFROMs(dockerfileContent) + seen := make(map[string]bool) + for _, ref := range refs { + repo := ref + if idx := strings.LastIndex(repo, ":"); idx > 0 { + repo = repo[:idx] + } + if !seen[repo] { + seen[repo] = true + repoAccess = append(repoAccess, RepoPermission{Repo: repo, Scope: "pull"}) + } + } + } + registryToken, err := m.tokenGenerator.GenerateToken(id, repoAccess, tokenTTL) if err != nil { deleteBuild(m.paths, id) @@ -315,6 +339,13 @@ func (m *manager) runBuild(ctx context.Context, id string, req CreateBuildReques buildCtx, cancel := context.WithTimeout(ctx, time.Duration(policy.TimeoutSeconds)*time.Second) defer cancel() + // Mirror base images for admin builds before launching the VM + if req.IsAdminBuild { + if err := m.mirrorBaseImagesForBuild(buildCtx, id, req); err != nil { + m.logger.Warn("failed to mirror base images", "id", id, "error", err) + } + } + // Run the build in a builder VM result, err := m.executeBuild(buildCtx, id, req, policy) @@ -1129,6 +1160,30 @@ func (m *manager) refreshBuildToken(buildID string, req *CreateBuildRequest) err } } + // Add pull access for base image repos so the builder agent can + // detect mirrored images via checkImageExistsInRegistry + dockerfileContent := req.Dockerfile + if dockerfileContent == "" { + tarballPath := m.paths.BuildSourceDir(buildID) + "/source.tar.gz" + if content, err := ExtractDockerfileFromTarball(tarballPath); err == nil { + dockerfileContent = content + } + } + if dockerfileContent != "" { + refs := ParseDockerfileFROMs(dockerfileContent) + seen := make(map[string]bool) + for _, ref := range refs { + repo := ref + if idx := strings.LastIndex(repo, ":"); idx > 0 { + repo = repo[:idx] + } + if !seen[repo] { + seen[repo] = true + repoAccess = append(repoAccess, RepoPermission{Repo: repo, Scope: "pull"}) + } + } + } + // Generate fresh registry token registryToken, err := m.tokenGenerator.GenerateToken(buildID, repoAccess, tokenTTL) if err != nil { diff --git a/lib/builds/mirror.go b/lib/builds/mirror.go new file mode 100644 index 0000000..dc39f4b --- /dev/null +++ b/lib/builds/mirror.go @@ -0,0 +1,86 @@ +package builds + +import ( + "context" + "strings" + "time" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/kernel/hypeman/lib/images" +) + +// mirrorBaseImagesForBuild extracts base image references from the build's +// Dockerfile and mirrors each one to the local registry. This allows the +// builder agent's rewriteDockerfileFROMs to find the images locally and +// rewrite FROM lines to pull from the local registry instead of Docker Hub. +// +// Individual mirror failures are logged but do not fail the build (graceful +// degradation — the builder will simply pull from upstream as before). +func (m *manager) mirrorBaseImagesForBuild(ctx context.Context, id string, req CreateBuildRequest) error { + // Get Dockerfile content: prefer inline Dockerfile, fall back to tarball + var dockerfileContent string + if req.Dockerfile != "" { + dockerfileContent = req.Dockerfile + } else { + tarballPath := m.paths.BuildSourceDir(id) + "/source.tar.gz" + content, err := ExtractDockerfileFromTarball(tarballPath) + if err != nil { + m.logger.Warn("could not extract Dockerfile from tarball for mirroring", + "id", id, "error", err) + return nil + } + dockerfileContent = content + } + + // Parse FROM references + refs := ParseDockerfileFROMs(dockerfileContent) + if len(refs) == 0 { + return nil + } + + m.logger.Info("mirroring base images for admin build", "id", id, "images", refs) + + // Generate a scoped registry token that grants push access to the base + // image repos. The local registry requires JWT auth for all operations; + // go-containerregistry uses this via the Docker token auth flow (Basic + // auth username = JWT → /v2/token validates and returns bearer token). + // Build repo permissions. The Docker token scope uses the repo name without + // the tag (e.g. "onkernel/nodejs22-base", not "onkernel/nodejs22-base:0.1.1"). + seen := make(map[string]bool) + var repoPerms []RepoPermission + for _, ref := range refs { + repo := ref + if idx := strings.LastIndex(repo, ":"); idx > 0 { + repo = repo[:idx] + } + if !seen[repo] { + seen[repo] = true + repoPerms = append(repoPerms, RepoPermission{Repo: repo, Scope: "push"}) + } + } + registryToken, err := m.tokenGenerator.GenerateToken(id, repoPerms, 10*time.Minute) + if err != nil { + m.logger.Warn("failed to generate registry token for mirroring", + "id", id, "error", err) + return nil + } + // go-containerregistry's basicTransport only sends Basic auth when BOTH + // Username and Password are non-empty. The password value doesn't matter — + // our token handler extracts the JWT from the username field only. + authConfig := &authn.AuthConfig{Username: registryToken, Password: "x"} + + for _, ref := range refs { + result, err := images.MirrorBaseImage(ctx, m.config.RegistryURL, images.MirrorRequest{ + SourceImage: ref, + }, authConfig) + if err != nil { + m.logger.Warn("failed to mirror base image", + "id", id, "image", ref, "error", err) + continue + } + m.logger.Info("mirrored base image", + "id", id, "image", ref, "local_ref", result.LocalRef, "digest", result.Digest) + } + + return nil +} diff --git a/lib/images/mirror.go b/lib/images/mirror.go new file mode 100644 index 0000000..6508c61 --- /dev/null +++ b/lib/images/mirror.go @@ -0,0 +1,130 @@ +package images + +import ( + "context" + "fmt" + "strings" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" +) + +// MirrorRequest contains the parameters for mirroring a base image +type MirrorRequest struct { + // SourceImage is the full image reference to pull from (e.g., "docker.io/onkernel/nodejs22-base:0.1.1") + SourceImage string +} + +// MirrorResult contains the result of a mirror operation +type MirrorResult struct { + // SourceImage is the original image reference + SourceImage string `json:"source_image"` + // LocalRef is the local registry reference (e.g., "onkernel/nodejs22-base:0.1.1") + LocalRef string `json:"local_ref"` + // Digest is the image digest + Digest string `json:"digest"` +} + +// MirrorBaseImage pulls an image from an external registry and pushes it to the +// local registry with the same normalized name. This enables Dockerfile FROM rewriting +// to use locally mirrored base images instead of pulling from Docker Hub. +// +// For example, mirroring "docker.io/onkernel/nodejs22-base:0.1.1" will create +// "onkernel/nodejs22-base:0.1.1" in the local registry. +func MirrorBaseImage(ctx context.Context, registryURL string, req MirrorRequest, authConfig *authn.AuthConfig) (*MirrorResult, error) { + // Parse source reference + srcRef, err := name.ParseReference(req.SourceImage) + if err != nil { + return nil, fmt.Errorf("parse source image reference: %w", err) + } + + // Pull the image from source + img, err := remote.Image(srcRef, + remote.WithContext(ctx), + remote.WithAuthFromKeychain(authn.DefaultKeychain), + remote.WithPlatform(currentPlatform())) + if err != nil { + return nil, fmt.Errorf("pull source image: %w", wrapRegistryError(err)) + } + + // Get the digest + digest, err := img.Digest() + if err != nil { + return nil, fmt.Errorf("get image digest: %w", err) + } + + // Build the local reference under bases/ namespace + // Normalize the source to strip docker.io/ prefix for cleaner local refs + localRef := normalizeToLocalRef(srcRef) + + // Strip any scheme from registry URL + registryHost := stripScheme(registryURL) + + // Build full destination reference + dstRefStr := fmt.Sprintf("%s/%s", registryHost, localRef) + dstRef, err := name.ParseReference(dstRefStr) + if err != nil { + return nil, fmt.Errorf("parse destination reference: %w", err) + } + + // Push to local registry + // For insecure registries, we need to use the insecure transport + opts := []remote.Option{ + remote.WithContext(ctx), + } + + // If authConfig is provided, use it + if authConfig != nil { + opts = append(opts, remote.WithAuth(authn.FromConfig(*authConfig))) + } + + if err := remote.Write(dstRef, img, opts...); err != nil { + return nil, fmt.Errorf("push to local registry: %w", wrapRegistryError(err)) + } + + return &MirrorResult{ + SourceImage: req.SourceImage, + LocalRef: localRef, + Digest: digest.String(), + }, nil +} + +// normalizeToLocalRef converts a source image reference to a normalized local reference. +// It strips the docker.io/ prefix and library/ prefix for cleaner local refs. +// +// Examples: +// - "docker.io/onkernel/nodejs22-base:0.1.1" -> "onkernel/nodejs22-base:0.1.1" +// - "docker.io/library/alpine:3.21" -> "alpine:3.21" +// - "nginx:1.21" -> "nginx:1.21" +// - "gcr.io/google-containers/pause:3.2" -> "gcr.io/google-containers/pause:3.2" +func normalizeToLocalRef(ref name.Reference) string { + // Get the repository name (includes registry for non-Docker Hub images) + repo := ref.Context().String() + + // Strip index.docker.io/ prefix (canonical form of docker.io) + repo = strings.TrimPrefix(repo, "index.docker.io/") + + // Strip docker.io/ prefix + repo = strings.TrimPrefix(repo, "docker.io/") + + // Strip library/ prefix for official images + repo = strings.TrimPrefix(repo, "library/") + + // Build the tag or digest suffix + var suffix string + if tag, ok := ref.(name.Tag); ok { + suffix = ":" + tag.TagStr() + } else if dig, ok := ref.(name.Digest); ok { + suffix = "@" + dig.DigestStr() + } + + return repo + suffix +} + +// stripScheme removes http:// or https:// prefix from a URL +func stripScheme(url string) string { + url = strings.TrimPrefix(url, "https://") + url = strings.TrimPrefix(url, "http://") + return url +} diff --git a/lib/images/mirror_test.go b/lib/images/mirror_test.go new file mode 100644 index 0000000..1fc2613 --- /dev/null +++ b/lib/images/mirror_test.go @@ -0,0 +1,91 @@ +package images + +import ( + "testing" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNormalizeToLocalRef(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "docker hub user image with tag", + input: "docker.io/onkernel/nodejs22-base:0.1.1", + expected: "onkernel/nodejs22-base:0.1.1", + }, + { + name: "docker hub user image without registry prefix", + input: "onkernel/nodejs22-base:0.1.1", + expected: "onkernel/nodejs22-base:0.1.1", + }, + { + name: "docker hub official image with tag", + input: "docker.io/library/alpine:3.21", + expected: "alpine:3.21", + }, + { + name: "docker hub official image short form", + input: "alpine:3.21", + expected: "alpine:3.21", + }, + { + name: "docker hub image with index.docker.io", + input: "index.docker.io/onkernel/nodejs22-base:0.1.1", + expected: "onkernel/nodejs22-base:0.1.1", + }, + { + name: "gcr.io image", + input: "gcr.io/google-containers/pause:3.2", + expected: "gcr.io/google-containers/pause:3.2", + }, + { + name: "ghcr.io image", + input: "ghcr.io/some-org/some-image:v1.0", + expected: "ghcr.io/some-org/some-image:v1.0", + }, + { + name: "image with latest tag", + input: "nginx:latest", + expected: "nginx:latest", + }, + { + name: "image without tag uses latest", + input: "nginx", + expected: "nginx:latest", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ref, err := name.ParseReference(tt.input) + require.NoError(t, err) + result := normalizeToLocalRef(ref) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestStripScheme(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"https://localhost:8080", "localhost:8080"}, + {"http://localhost:8080", "localhost:8080"}, + {"localhost:8080", "localhost:8080"}, + {"https://registry.example.com", "registry.example.com"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := stripScheme(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/lib/middleware/oapi_auth.go b/lib/middleware/oapi_auth.go index 6f8a825..b73c17e 100644 --- a/lib/middleware/oapi_auth.go +++ b/lib/middleware/oapi_auth.go @@ -470,3 +470,70 @@ func JwtAuth(jwtSecret string) func(http.Handler) http.Handler { }) } } + +// JWTAuthMiddleware creates a simple chi middleware that validates JWT bearer tokens. +// This is designed for admin endpoints that are not part of the OpenAPI spec. +// It validates the token and sets the user ID in the request context. +func JWTAuthMiddleware(jwtSecret string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log := logger.FromContext(r.Context()) + + // Extract token from Authorization header + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + http.Error(w, `{"code":"unauthorized","message":"missing authorization header"}`, http.StatusUnauthorized) + return + } + + token, err := extractBearerToken(authHeader) + if err != nil { + log.DebugContext(r.Context(), "failed to extract bearer token", "error", err) + http.Error(w, `{"code":"unauthorized","message":"invalid authorization header"}`, http.StatusUnauthorized) + return + } + + // Parse and validate the token + parsedToken, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return []byte(jwtSecret), nil + }) + if err != nil || !parsedToken.Valid { + log.DebugContext(r.Context(), "invalid JWT token", "error", err) + http.Error(w, `{"code":"unauthorized","message":"invalid token"}`, http.StatusUnauthorized) + return + } + + claims, ok := parsedToken.Claims.(jwt.MapClaims) + if !ok { + http.Error(w, `{"code":"unauthorized","message":"invalid token claims"}`, http.StatusUnauthorized) + return + } + + // Reject registry tokens - they should not be used for admin API authentication + if _, hasRepos := claims["repos"]; hasRepos { + http.Error(w, `{"code":"unauthorized","message":"invalid token type"}`, http.StatusUnauthorized) + return + } + if _, hasScope := claims["scope"]; hasScope { + http.Error(w, `{"code":"unauthorized","message":"invalid token type"}`, http.StatusUnauthorized) + return + } + if _, hasBuildID := claims["build_id"]; hasBuildID { + http.Error(w, `{"code":"unauthorized","message":"invalid token type"}`, http.StatusUnauthorized) + return + } + + // Extract user ID from claims and add to context + var userID string + if sub, ok := claims["sub"].(string); ok { + userID = sub + } + + ctx := context.WithValue(r.Context(), userIDKey, userID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} From 952b21566f5c8163fa9ff81fd6242c20d5713af8 Mon Sep 17 00:00:00 2001 From: hiroTamada Date: Mon, 9 Feb 2026 16:22:25 -0500 Subject: [PATCH 6/7] refactor: drop admin mirror endpoint, integration test, and oci_test Remove convenience-only files that aren't needed for the core fix: - cmd/api/api/admin.go (manual mirror endpoint) - cmd/api/main.go admin route registration - lib/middleware/oapi_auth.go JWTAuthMiddleware - lib/builds/cache_integration_test.go (testcontainers dep) - lib/images/oci_test.go (pre-existing bug test) This also removes the testcontainers-go dependency from go.mod. Co-Authored-By: Claude Opus 4.6 --- cmd/api/api/admin.go | 86 ---- cmd/api/main.go | 13 - go.mod | 18 +- go.sum | 37 -- lib/builds/cache_integration_test.go | 597 --------------------------- lib/images/oci_test.go | 190 --------- lib/middleware/oapi_auth.go | 67 --- 7 files changed, 1 insertion(+), 1007 deletions(-) delete mode 100644 cmd/api/api/admin.go delete mode 100644 lib/builds/cache_integration_test.go delete mode 100644 lib/images/oci_test.go diff --git a/cmd/api/api/admin.go b/cmd/api/api/admin.go deleted file mode 100644 index ebf7058..0000000 --- a/cmd/api/api/admin.go +++ /dev/null @@ -1,86 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - - "github.com/kernel/hypeman/lib/images" - "github.com/kernel/hypeman/lib/logger" -) - -// MirrorBaseImageRequest is the request body for POST /admin/mirror-base-image -type MirrorBaseImageRequest struct { - // SourceImage is the full image reference to pull from (e.g., "docker.io/onkernel/nodejs22-base:0.1.1") - SourceImage string `json:"source_image"` -} - -// MirrorBaseImageResponse is the response body for POST /admin/mirror-base-image -type MirrorBaseImageResponse struct { - // SourceImage is the original image reference - SourceImage string `json:"source_image"` - // LocalRef is the local registry reference (e.g., "onkernel/nodejs22-base:0.1.1") - LocalRef string `json:"local_ref"` - // Digest is the image digest - Digest string `json:"digest"` -} - -// MirrorBaseImageHandler returns an HTTP handler for mirroring base images. -// This is an admin endpoint that pulls images from external registries and -// pushes them to the local registry with the same normalized name. -// -// Example usage: -// -// POST /admin/mirror-base-image -// {"source_image": "docker.io/onkernel/nodejs22-base:0.1.1"} -// -// Response: -// -// {"source_image": "...", "local_ref": "onkernel/nodejs22-base:0.1.1", "digest": "sha256:..."} -func MirrorBaseImageHandler(registryURL string) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - log := logger.FromContext(r.Context()) - - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - // Parse request body - var req MirrorBaseImageRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - log.ErrorContext(r.Context(), "failed to decode request body", "error", err) - http.Error(w, "invalid request body", http.StatusBadRequest) - return - } - - if req.SourceImage == "" { - http.Error(w, "source_image is required", http.StatusBadRequest) - return - } - - log.InfoContext(r.Context(), "mirroring base image", "source", req.SourceImage) - - // Mirror the image - result, err := images.MirrorBaseImage(r.Context(), registryURL, images.MirrorRequest{ - SourceImage: req.SourceImage, - }, nil) // No auth config for local insecure registry - if err != nil { - log.ErrorContext(r.Context(), "failed to mirror base image", "error", err, "source", req.SourceImage) - http.Error(w, "failed to mirror image: "+err.Error(), http.StatusInternalServerError) - return - } - - log.InfoContext(r.Context(), "base image mirrored successfully", - "source", result.SourceImage, - "local_ref", result.LocalRef, - "digest", result.Digest) - - // Return response - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(MirrorBaseImageResponse{ - SourceImage: result.SourceImage, - LocalRef: result.LocalRef, - Digest: result.Digest, - }) - } -} diff --git a/cmd/api/main.go b/cmd/api/main.go index 06b3a92..7f5e426 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -369,19 +369,6 @@ func run() error { }) }) - // Admin endpoints (authenticated but outside OpenAPI spec) - r.Route("/admin", func(r chi.Router) { - r.Use(middleware.RequestID) - r.Use(middleware.RealIP) - r.Use(middleware.Recoverer) - r.Use(mw.InjectLogger(logger)) - r.Use(mw.AccessLogger(accessLogger)) - r.Use(mw.JWTAuthMiddleware(cfg.JwtSecret)) - - // Mirror base images to local registry - r.Post("/mirror-base-image", api.MirrorBaseImageHandler(cfg.RegistryURL)) - }) - // Unauthenticated endpoints (outside group) r.Get("/spec.yaml", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/vnd.oai.openapi") diff --git a/go.mod b/go.mod index 00a535e..d6691b8 100644 --- a/go.mod +++ b/go.mod @@ -32,7 +32,6 @@ require ( github.com/riandyrn/otelchi v0.12.2 github.com/samber/lo v1.52.0 github.com/stretchr/testify v1.11.1 - github.com/testcontainers/testcontainers-go v0.40.0 github.com/u-root/u-root v0.15.0 github.com/vishvananda/netlink v1.3.1 go.opentelemetry.io/contrib/bridges/otelslog v0.13.0 @@ -55,7 +54,6 @@ require ( ) require ( - dario.cat/mergo v1.0.2 // indirect github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect @@ -66,10 +64,7 @@ require ( github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect - github.com/containerd/log v0.1.0 // indirect - github.com/containerd/platforms v0.2.1 // indirect github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect - github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/digitalocean/go-libvirt v0.0.0-20220804181439-8648fbde413e // indirect github.com/docker/cli v28.2.2+incompatible // indirect @@ -78,11 +73,9 @@ require ( github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/ebitengine/purego v0.8.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/go-test/deep v1.1.1 // indirect @@ -90,38 +83,28 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/pgzip v1.2.6 // indirect - github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect - github.com/magiconair/properties v1.8.10 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mdlayher/socket v0.5.1 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect - github.com/moby/go-archive v0.1.0 // indirect - github.com/moby/patternmatcher v0.6.0 // indirect github.com/moby/sys/sequential v0.6.0 // indirect github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.0 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect - github.com/morikuni/aec v1.0.0 // indirect github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/rootless-containers/proto/go-proto v0.0.0-20230421021042-4cd87ebadd67 // indirect - github.com/shirou/gopsutil/v4 v4.25.6 // indirect github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect - github.com/tklauser/go-sysconf v0.3.14 // indirect - github.com/tklauser/numcpus v0.8.0 // indirect github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect github.com/vbatts/go-mtree v0.6.1-0.20250911112631-8307d76bc1b9 // indirect github.com/vbatts/tar-split v0.12.1 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect - github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect @@ -136,4 +119,5 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + gotest.tools/v3 v3.5.2 // indirect ) diff --git a/go.sum b/go.sum index 62eed1d..a369c83 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,5 @@ al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA= al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= -dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= -dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= @@ -33,12 +31,8 @@ github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151X github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= -github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= -github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8= github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU= -github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= -github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= @@ -67,8 +61,6 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= -github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= @@ -85,8 +77,6 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= -github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= @@ -101,7 +91,6 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.20.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB31qAwjAohdSTU= @@ -139,10 +128,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= -github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= -github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= @@ -160,10 +145,6 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= -github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= -github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= -github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= @@ -208,8 +189,6 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/riandyrn/otelchi v0.12.2 h1:6QhGv0LVw/dwjtPd12mnNrl0oEQF4ZAlmHcnlTYbeAg= github.com/riandyrn/otelchi v0.12.2/go.mod h1:weZZeUJURvtCcbWsdb7Y6F8KFZGedJlSrgUjq9VirV8= github.com/rogpeppe/fastuuid v1.1.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= @@ -220,8 +199,6 @@ github.com/rootless-containers/proto/go-proto v0.0.0-20230421021042-4cd87ebadd67 github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= -github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= -github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af h1:Sp5TG9f7K39yfB+If0vjp97vuT74F72r8hfRpP8jLU0= github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/smartystreets/assertions v1.0.0/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= @@ -229,15 +206,11 @@ github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h github.com/smartystreets/gunit v1.0.0/go.mod h1:qwPWnhz6pn0NnRBP++URONOVyNkPyr4SauJk4cUOwJs= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= -github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= github.com/tj/assert v0.0.0-20171129193455-018094318fb0/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0= github.com/tj/assert v0.0.3 h1:Df/BlaZ20mq6kuai7f5z2TvPFiwC3xaWJSDQNiIS3Rk= github.com/tj/assert v0.0.3/go.mod h1:Ne6X72Q+TB1AteidzQncjw9PabbMp4PBMZ1k+vd1Pvk= @@ -245,10 +218,6 @@ github.com/tj/go-buffer v1.1.0/go.mod h1:iyiJpfFcR2B9sXu7KvjbT9fpM4mOelRSDTbntVj github.com/tj/go-elastic v0.0.0-20171221160941-36157cbbebc2/go.mod h1:WjeM0Oo1eNAjXGDx2yma7uG2XoyRZTq1uv3M/o7imD0= github.com/tj/go-kinesis v0.0.0-20171128231115-08b17f58cb1b/go.mod h1:/yhzCV0xPfx6jb1bBgRFjl5lytqVqZXEaeqWP8lTEao= github.com/tj/go-spin v1.1.0/go.mod h1:Mg1mzmePZm4dva8Qz60H2lHwmJ2loum4VIrLgVnKwh4= -github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU= -github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY= -github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY= -github.com/tklauser/numcpus v0.8.0/go.mod h1:ZJZlAY+dmR4eut8epnzf0u/VwodKmryxR8txiloSqBE= github.com/u-root/u-root v0.15.0 h1:8JXfjAA/Vs8EXfZUA2ftvoHbiYYLdaU8umJ461aq+Jw= github.com/u-root/u-root v0.15.0/go.mod h1:/0Qr7qJeDwWxoKku2xKQ4Szc+SwBE3g9VE8jNiamsmc= github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= @@ -266,8 +235,6 @@ github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZla github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= -github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/bridges/otelslog v0.13.0 h1:bwnLpizECbPr1RrQ27waeY2SPIPeccCx/xLuoYADZ9s= @@ -329,9 +296,7 @@ golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -341,8 +306,6 @@ golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= -golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= diff --git a/lib/builds/cache_integration_test.go b/lib/builds/cache_integration_test.go deleted file mode 100644 index 8c313f1..0000000 --- a/lib/builds/cache_integration_test.go +++ /dev/null @@ -1,597 +0,0 @@ -package builds - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/testcontainers/testcontainers-go" - "github.com/testcontainers/testcontainers-go/network" - "github.com/testcontainers/testcontainers-go/wait" -) - -// TestBuildKitCacheContainsLayerBlobs verifies that when BuildKit exports a cache -// with image-manifest=true, the cache image contains actual layer blobs (not just -// references to external registries like Docker Hub). -// -// This test reproduces the issue where: -// 1. Admin build populates global cache with image-manifest=true -// 2. New tenant's first build imports this cache -// 3. Despite the import, FROM instruction still downloads base image layers from Docker Hub -// -// The root cause is that BuildKit's registry cache stores metadata for FROM instructions, -// but the actual base image layers are referenced (not copied) to the cache. -func TestBuildKitCacheContainsLayerBlobs(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test in short mode") - } - - ctx := context.Background() - - // Create a shared network for the containers - testNetwork, err := network.New(ctx) - require.NoError(t, err, "failed to create network") - defer testNetwork.Remove(ctx) - - networkName := testNetwork.Name - - // Start registry container - registryC, registryHostExternal, registryHostInternal := startRegistryContainer(t, ctx, networkName) - defer registryC.Terminate(ctx) - - // Start BuildKit container on the same network - buildkitC := startBuildKitContainer(t, ctx, networkName) - defer buildkitC.Terminate(ctx) - - // Use internal hostname for BuildKit to reach registry - registryHost := registryHostInternal - // Use external hostname for test to reach registry - buildkitHost := registryHostExternal - - // Create test Dockerfile that uses a base image - srcDir := t.TempDir() - dockerfile := `FROM alpine:3.21 -RUN echo "test layer 1" > /layer1.txt -RUN echo "test layer 2" > /layer2.txt -` - require.NoError(t, os.WriteFile(filepath.Join(srcDir, "Dockerfile"), []byte(dockerfile), 0644)) - - // Copy source to BuildKit container - copyToContainer(t, ctx, buildkitC, srcDir, "/src") - - // Build and export cache with image-manifest=true - cacheRef := fmt.Sprintf("%s/cache/test/alpine-cache:v1", registryHost) - outputRef := fmt.Sprintf("%s/builds/test-build:v1", registryHost) - - t.Log("Building image and exporting cache with image-manifest=true...") - buildCmd := []string{ - "buildctl", "build", - "--frontend", "dockerfile.v0", - "--local", "context=/src", - "--local", "dockerfile=/src", - "--output", fmt.Sprintf("type=image,name=%s,push=true,registry.insecure=true", outputRef), - "--export-cache", fmt.Sprintf("type=registry,ref=%s,mode=max,image-manifest=true,oci-mediatypes=true,registry.insecure=true", cacheRef), - } - - exitCode, output, err := execInContainer(t, ctx, buildkitC, buildCmd) - t.Logf("BuildKit output:\n%s", output) - require.NoError(t, err) - require.Equal(t, 0, exitCode, "buildctl failed with exit code %d", exitCode) - - // Now inspect the cache image to verify it contains layer blobs - t.Log("Inspecting cache image...") - - // Fetch the cache manifest from registry - manifestURL := fmt.Sprintf("http://%s/v2/cache/test/alpine-cache/manifests/v1", buildkitHost) - req, _ := http.NewRequest("GET", manifestURL, nil) - req.Header.Set("Accept", "application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json") - - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err, "failed to fetch cache manifest") - defer resp.Body.Close() - - body, _ := io.ReadAll(resp.Body) - t.Logf("Cache manifest response (status %d):\n%s", resp.StatusCode, string(body)) - - if resp.StatusCode != http.StatusOK { - t.Fatalf("Failed to fetch cache manifest: status %d", resp.StatusCode) - } - - var manifest struct { - MediaType string `json:"mediaType"` - Layers []struct { - MediaType string `json:"mediaType"` - Size int64 `json:"size"` - Digest string `json:"digest"` - URLs []string `json:"urls,omitempty"` // Foreign layer URLs - } `json:"layers"` - } - require.NoError(t, json.Unmarshal(body, &manifest)) - - t.Logf("Cache manifest has %d layers", len(manifest.Layers)) - - // Check each layer for foreign references - var foreignLayers, localLayers int - for i, layer := range manifest.Layers { - t.Logf("Layer %d: digest=%s, size=%d, mediaType=%s", i, layer.Digest, layer.Size, layer.MediaType) - - // Check if layer has foreign URLs (indicates reference to external registry) - if len(layer.URLs) > 0 { - t.Logf(" Layer %d: FOREIGN - has external URLs: %v", i, layer.URLs) - foreignLayers++ - continue - } - - // Try to HEAD the layer blob to verify it exists locally - layerURL := fmt.Sprintf("http://%s/v2/cache/test/alpine-cache/blobs/%s", buildkitHost, layer.Digest) - layerResp, err := http.Head(layerURL) - if err != nil || layerResp.StatusCode != http.StatusOK { - t.Logf(" Layer %d: NOT FOUND in registry", i) - foreignLayers++ - } else { - t.Logf(" Layer %d: FOUND in registry (local blob)", i) - localLayers++ - layerResp.Body.Close() - } - } - - t.Logf("Summary: %d local layers, %d foreign layers", localLayers, foreignLayers) - - // The key assertion: with image-manifest=true, ALL layers should be stored locally - assert.Equal(t, 0, foreignLayers, - "Cache should contain all layer blobs locally (no foreign references). "+ - "Foreign layers indicate base image layers are still referenced to Docker Hub.") - assert.Greater(t, localLayers, 0, "Cache should have at least one local layer") -} - -// TestBuildKitCacheHitForBaseImageLayers verifies that when importing a cache, -// BuildKit actually uses the cached layers for the FROM instruction. -// -// This test: -// 1. Builds an image and exports cache (simulating admin cache population) -// 2. Prunes BuildKit's local cache -// 3. Builds again with only import-cache (simulating fresh tenant build) -// 4. Analyzes output to verify cache behavior for base image layers -func TestBuildKitCacheHitForBaseImageLayers(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test in short mode") - } - - ctx := context.Background() - - // Create a shared network for the containers - testNetwork, err := network.New(ctx) - require.NoError(t, err, "failed to create network") - defer testNetwork.Remove(ctx) - - networkName := testNetwork.Name - - // Start registry container - registryC, _, registryHost := startRegistryContainer(t, ctx, networkName) - defer registryC.Terminate(ctx) - - // Start BuildKit container on the same network - buildkitC := startBuildKitContainer(t, ctx, networkName) - defer buildkitC.Terminate(ctx) - - // Create test Dockerfile - srcDir := t.TempDir() - dockerfile := `FROM alpine:3.21 -RUN echo "cache test" > /test.txt -` - require.NoError(t, os.WriteFile(filepath.Join(srcDir, "Dockerfile"), []byte(dockerfile), 0644)) - - // Copy source to BuildKit container - copyToContainer(t, ctx, buildkitC, srcDir, "/src") - - cacheRef := fmt.Sprintf("%s/cache/global/test-runtime:v1", registryHost) - outputRef1 := fmt.Sprintf("%s/builds/admin-build:v1", registryHost) - outputRef2 := fmt.Sprintf("%s/builds/tenant-build:v1", registryHost) - - // Step 1: Admin build - populate cache - t.Log("Step 1: Admin build to populate cache...") - buildCmd1 := []string{ - "buildctl", "build", - "--frontend", "dockerfile.v0", - "--local", "context=/src", - "--local", "dockerfile=/src", - "--output", fmt.Sprintf("type=image,name=%s,push=true,registry.insecure=true", outputRef1), - "--export-cache", fmt.Sprintf("type=registry,ref=%s,mode=max,image-manifest=true,oci-mediatypes=true,registry.insecure=true", cacheRef), - } - - exitCode, output, err := execInContainer(t, ctx, buildkitC, buildCmd1) - t.Logf("Admin build output:\n%s", output) - require.NoError(t, err) - require.Equal(t, 0, exitCode, "Admin build failed") - - // Step 2: Clear BuildKit's local cache to simulate fresh/ephemeral environment - t.Log("Step 2: Clearing BuildKit local cache...") - pruneCmd := []string{"buildctl", "prune", "--all"} - execInContainer(t, ctx, buildkitC, pruneCmd) // Ignore errors - - // Step 3: Tenant build - import from cache only (no export) - t.Log("Step 3: Tenant build with cache import...") - buildCmd2 := []string{ - "buildctl", "build", - "--frontend", "dockerfile.v0", - "--local", "context=/src", - "--local", "dockerfile=/src", - "--output", fmt.Sprintf("type=image,name=%s,push=true,registry.insecure=true", outputRef2), - "--import-cache", fmt.Sprintf("type=registry,ref=%s,registry.insecure=true", cacheRef), - "--progress", "plain", - } - - exitCode, output, err = execInContainer(t, ctx, buildkitC, buildCmd2) - buildLog := output - t.Logf("Tenant build output:\n%s", buildLog) - require.NoError(t, err) - require.Equal(t, 0, exitCode, "Tenant build failed") - - // Analyze build output to determine if cache was used for base image - t.Log("Analyzing build output for cache effectiveness...") - - // Check for actual layer download from Docker Hub (shows download progress like "3.64MB / 3.64MB") - // Note: "resolve docker.io" is just metadata resolution (checking digest), not layer download - hasLayerDownload := strings.Contains(buildLog, "MB /") || // Download progress indicator - strings.Contains(buildLog, "extracting sha256:") // Layer extraction - - // Check for metadata resolution (this is expected - BuildKit needs to verify the digest) - hasMetadataResolve := strings.Contains(buildLog, "resolve docker.io") - - // Check for CACHED indicator on steps - hasCachedSteps := strings.Contains(buildLog, "CACHED") - - // Check for cache import messages - hasCacheImport := strings.Contains(buildLog, "importing cache manifest") - - t.Logf("Analysis results:") - t.Logf(" - Cache import detected: %v", hasCacheImport) - t.Logf(" - Metadata resolution (docker.io): %v (this is normal - just checking digest)", hasMetadataResolve) - t.Logf(" - Actual layer download detected: %v", hasLayerDownload) - t.Logf(" - Has CACHED steps: %v", hasCachedSteps) - - // Document the expected behavior vs actual behavior - if hasLayerDownload { - t.Log("") - t.Log("=== ISSUE REPRODUCED ===") - t.Log("Layer download detected despite cache import.") - t.Log("This confirms that BuildKit's registry cache does not effectively") - t.Log("provide base image layers from the cache.") - t.Log("") - } else if hasMetadataResolve && !hasLayerDownload { - t.Log("") - t.Log("=== CACHE WORKING CORRECTLY ===") - t.Log("Metadata was resolved from Docker Hub (normal behavior to verify digest),") - t.Log("but NO layer download occurred - layers were served from cache!") - t.Log("") - } - - // The key assertion: check for actual layer downloads, not just metadata resolution - // Metadata resolution is expected, but layer download should NOT happen with proper cache - assert.False(t, hasLayerDownload, - "Build should NOT download layers from Docker Hub when cache is available. "+ - "Layer download indicators (MB progress, extraction) should not appear.") -} - -// TestCacheExportArgsFormat verifies the cache export arguments are correctly formatted. -func TestCacheExportArgsFormat(t *testing.T) { - key := &CacheKey{ - Reference: "localhost:5000/cache/tenant/nodejs/abc123", - TenantScope: "tenant", - Runtime: "nodejs", - LockfileHash: "abc123", - } - - exportArg := key.ExportCacheArg() - - // Verify all required options are present - assert.Contains(t, exportArg, "type=registry") - assert.Contains(t, exportArg, "ref=localhost:5000/cache/tenant/nodejs/abc123") - assert.Contains(t, exportArg, "mode=max") - assert.Contains(t, exportArg, "image-manifest=true") - assert.Contains(t, exportArg, "oci-mediatypes=true") - - // Verify the exact format matches what BuildKit expects - expected := "type=registry,ref=localhost:5000/cache/tenant/nodejs/abc123,mode=max,image-manifest=true,oci-mediatypes=true" - assert.Equal(t, expected, exportArg) -} - -// startRegistryContainer starts a Docker registry container for testing. -// Returns the container, external host (for test access), and internal host (for container-to-container access). -func startRegistryContainer(t *testing.T, ctx context.Context, networkName string) (testcontainers.Container, string, string) { - t.Helper() - - const registryAlias = "registry" - - req := testcontainers.ContainerRequest{ - Image: "registry:2", - ExposedPorts: []string{"5000/tcp"}, - WaitingFor: wait.ForHTTP("/v2/").WithPort("5000/tcp"), - Networks: []string{networkName}, - NetworkAliases: map[string][]string{ - networkName: {registryAlias}, - }, - } - - container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) - require.NoError(t, err, "failed to start registry container") - - // Get the mapped port for external access - mappedPort, err := container.MappedPort(ctx, "5000") - require.NoError(t, err) - - host, err := container.Host(ctx) - require.NoError(t, err) - - externalHost := fmt.Sprintf("%s:%s", host, mappedPort.Port()) - internalHost := fmt.Sprintf("%s:5000", registryAlias) // Container-to-container uses alias:port - - t.Logf("Registry started - external: %s, internal: %s", externalHost, internalHost) - - return container, externalHost, internalHost -} - -// startBuildKitContainer starts a BuildKit container for testing on the specified network. -func startBuildKitContainer(t *testing.T, ctx context.Context, networkName string) testcontainers.Container { - t.Helper() - - req := testcontainers.ContainerRequest{ - Image: "moby/buildkit:latest", - Privileged: true, - Entrypoint: []string{"buildkitd"}, - WaitingFor: wait.ForLog("running server").WithStartupTimeout(30 * time.Second), - Networks: []string{networkName}, - } - - container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) - require.NoError(t, err, "failed to start BuildKit container") - - t.Log("BuildKit container started") - - return container -} - -// copyToContainer copies a directory to the container. -func copyToContainer(t *testing.T, ctx context.Context, container testcontainers.Container, srcPath, dstPath string) { - t.Helper() - - // First create the destination directory - _, _, err := container.Exec(ctx, []string{"mkdir", "-p", dstPath}) - require.NoError(t, err, "failed to create directory %s in container", dstPath) - - // Copy files individually - files, err := os.ReadDir(srcPath) - require.NoError(t, err, "failed to read source directory %s", srcPath) - - for _, file := range files { - srcFile := filepath.Join(srcPath, file.Name()) - content, err := os.ReadFile(srcFile) - require.NoError(t, err, "failed to read file %s", srcFile) - - err = container.CopyToContainer(ctx, content, filepath.Join(dstPath, file.Name()), 0644) - require.NoError(t, err, "failed to copy %s to container", file.Name()) - } -} - -// execInContainer executes a command in the container and returns exit code, output, and error. -func execInContainer(t *testing.T, ctx context.Context, container testcontainers.Container, cmd []string) (int, string, error) { - t.Helper() - - exitCode, reader, err := container.Exec(ctx, cmd) - if err != nil { - return exitCode, "", err - } - - output, _ := io.ReadAll(reader) - return exitCode, string(output), nil -} - -// TestCacheMismatchWithDifferentBaseImage demonstrates that cache populated with -// one base image does NOT help builds using a different base image. -// This reproduces the production issue where: -// - Global cache was populated with one Dockerfile (e.g., FROM node:20-alpine) -// - Tenant builds use a different base image (e.g., FROM onkernel/nodejs22-base:0.1.1) -// - Cache import succeeds but layers still download because digests don't match -func TestCacheMismatchWithDifferentBaseImage(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test in short mode") - } - - ctx := context.Background() - - // Create a shared network - testNetwork, err := network.New(ctx) - require.NoError(t, err, "failed to create network") - defer testNetwork.Remove(ctx) - - networkName := testNetwork.Name - - // Start registry and BuildKit - registryC, _, registryHostInternal := startRegistryContainer(t, ctx, networkName) - defer registryC.Terminate(ctx) - - buildkitC := startBuildKitContainer(t, ctx, networkName) - defer buildkitC.Terminate(ctx) - - registryHost := registryHostInternal - cacheRef := fmt.Sprintf("%s/cache/global/node:v1", registryHost) - - // Step 1: Populate cache with alpine:3.21 (simulating admin build with different base) - t.Log("Step 1: Populating cache with alpine:3.21 (different base image)...") - srcDir1 := t.TempDir() - dockerfile1 := `FROM alpine:3.21 -RUN echo "admin build" > /admin.txt -` - require.NoError(t, os.WriteFile(filepath.Join(srcDir1, "Dockerfile"), []byte(dockerfile1), 0644)) - copyToContainer(t, ctx, buildkitC, srcDir1, "/src1") - - buildCmd1 := []string{ - "buildctl", "build", - "--frontend", "dockerfile.v0", - "--local", "context=/src1", - "--local", "dockerfile=/src1", - "--output", fmt.Sprintf("type=image,name=%s/builds/admin:v1,push=true,registry.insecure=true", registryHost), - "--export-cache", fmt.Sprintf("type=registry,ref=%s,mode=max,image-manifest=true,oci-mediatypes=true,registry.insecure=true", cacheRef), - } - - exitCode, output, err := execInContainer(t, ctx, buildkitC, buildCmd1) - t.Logf("Admin build output:\n%s", output) - require.NoError(t, err) - require.Equal(t, 0, exitCode) - - // Step 2: Clear local cache - t.Log("Step 2: Clearing local cache...") - execInContainer(t, ctx, buildkitC, []string{"buildctl", "prune", "--all"}) - - // Step 3: Tenant build with DIFFERENT base image (alpine:3.20 instead of 3.21) - t.Log("Step 3: Tenant build with DIFFERENT base image (alpine:3.20)...") - srcDir2 := t.TempDir() - dockerfile2 := `FROM alpine:3.20 -RUN echo "tenant build" > /tenant.txt -` - require.NoError(t, os.WriteFile(filepath.Join(srcDir2, "Dockerfile"), []byte(dockerfile2), 0644)) - copyToContainer(t, ctx, buildkitC, srcDir2, "/src2") - - buildCmd2 := []string{ - "buildctl", "build", - "--frontend", "dockerfile.v0", - "--local", "context=/src2", - "--local", "dockerfile=/src2", - "--output", fmt.Sprintf("type=image,name=%s/builds/tenant:v1,push=true,registry.insecure=true", registryHost), - "--import-cache", fmt.Sprintf("type=registry,ref=%s,registry.insecure=true", cacheRef), - "--progress", "plain", - } - - exitCode, output, err = execInContainer(t, ctx, buildkitC, buildCmd2) - buildLog := output - t.Logf("Tenant build output:\n%s", buildLog) - require.NoError(t, err) - require.Equal(t, 0, exitCode) - - // Analyze: cache import works, but layers still download - hasCacheImport := strings.Contains(buildLog, "importing cache manifest") - hasLayerDownload := strings.Contains(buildLog, "MB /") || strings.Contains(buildLog, "extracting sha256:") - - t.Logf("Analysis:") - t.Logf(" - Cache import: %v", hasCacheImport) - t.Logf(" - Layer download: %v", hasLayerDownload) - - // This demonstrates the problem: cache imports successfully but layers still download - // because the base images are different (alpine:3.21 vs alpine:3.20) - assert.True(t, hasCacheImport, "Cache manifest should be imported") - assert.True(t, hasLayerDownload, - "EXPECTED: Layers SHOULD download because base image differs. "+ - "This demonstrates that cache populated with one base image doesn't help "+ - "builds using a different base image.") - - t.Log("") - t.Log("=== TEST DEMONSTRATES THE PRODUCTION ISSUE ===") - t.Log("Cache was imported successfully, but layers still downloaded because") - t.Log("the cached layers (from alpine:3.21) don't match the required layers") - t.Log("(from alpine:3.20). The layer digests are completely different.") - t.Log("") - t.Log("FIX: Ensure populate-global-cache uses the SAME base image that") - t.Log("tenant Dockerfiles use (e.g., onkernel/nodejs22-base:0.1.1)") -} - -// TestInspectCacheManifestStructure inspects the structure of a BuildKit cache manifest -// to understand what's being stored. -func TestInspectCacheManifestStructure(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test in short mode") - } - - ctx := context.Background() - - // Create a shared network for the containers - testNetwork, err := network.New(ctx) - require.NoError(t, err, "failed to create network") - defer testNetwork.Remove(ctx) - - networkName := testNetwork.Name - - // Start registry container - registryC, registryHostExternal, registryHostInternal := startRegistryContainer(t, ctx, networkName) - defer registryC.Terminate(ctx) - - // Start BuildKit container on the same network - buildkitC := startBuildKitContainer(t, ctx, networkName) - defer buildkitC.Terminate(ctx) - - // Use internal host for BuildKit, external for test HTTP requests - registryHost := registryHostInternal - buildkitHostForRegistry := registryHostExternal - - // Create simple Dockerfile - srcDir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(srcDir, "Dockerfile"), []byte(`FROM alpine:3.21 -RUN echo "test" > /test.txt -`), 0644)) - - copyToContainer(t, ctx, buildkitC, srcDir, "/src") - - cacheRef := fmt.Sprintf("%s/cache/inspect-test:v1", registryHost) - outputRef := fmt.Sprintf("%s/builds/inspect-test:v1", registryHost) - - // Build with cache export - buildCmd := []string{ - "buildctl", "build", - "--frontend", "dockerfile.v0", - "--local", "context=/src", - "--local", "dockerfile=/src", - "--output", fmt.Sprintf("type=image,name=%s,push=true,registry.insecure=true", outputRef), - "--export-cache", fmt.Sprintf("type=registry,ref=%s,mode=max,image-manifest=true,oci-mediatypes=true,registry.insecure=true", cacheRef), - } - - exitCode, output, err := execInContainer(t, ctx, buildkitC, buildCmd) - t.Logf("Build output:\n%s", output) - require.NoError(t, err) - require.Equal(t, 0, exitCode) - - // Fetch and inspect the cache manifest - manifestURL := fmt.Sprintf("http://%s/v2/cache/inspect-test/manifests/v1", buildkitHostForRegistry) - req, _ := http.NewRequest("GET", manifestURL, nil) - req.Header.Set("Accept", "application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json") - - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - defer resp.Body.Close() - - body, _ := io.ReadAll(resp.Body) - t.Logf("Cache manifest (status %d):\n%s", resp.StatusCode, string(body)) - - if resp.StatusCode == http.StatusOK { - var manifest map[string]interface{} - if err := json.Unmarshal(body, &manifest); err == nil { - t.Logf("Manifest mediaType: %s", manifest["mediaType"]) - if layers, ok := manifest["layers"].([]interface{}); ok { - t.Logf("Number of layers: %d", len(layers)) - for i, layer := range layers { - if l, ok := layer.(map[string]interface{}); ok { - t.Logf(" Layer %d: mediaType=%s, size=%v, digest=%s", - i, l["mediaType"], l["size"], l["digest"]) - // Check for foreign layer URLs - if urls, ok := l["urls"]; ok { - t.Logf(" WARNING: Layer has external URLs: %v", urls) - } - } - } - } - } - } -} diff --git a/lib/images/oci_test.go b/lib/images/oci_test.go deleted file mode 100644 index 592da9a..0000000 --- a/lib/images/oci_test.go +++ /dev/null @@ -1,190 +0,0 @@ -package images - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// BuildKit cache config mediatype - this is what BuildKit uses when exporting -// cache with image-manifest=true -const buildKitCacheConfigMediaType = "application/vnd.buildkit.cacheconfig.v0" - -// TestUnpackLayersFailsOnBuildKitCacheMediatype verifies that hypeman's image -// unpacker fails when encountering BuildKit cache images. This reproduces the -// production issue where global cache images exported by BuildKit cannot be -// pre-pulled by hypeman because they use a non-standard config mediatype. -// -// The error occurs because: -// 1. BuildKit exports cache with --export-cache type=registry,image-manifest=true -// 2. The exported manifest uses "application/vnd.buildkit.cacheconfig.v0" as config mediatype -// 3. hypeman's unpackLayers expects "application/vnd.oci.image.config.v1+json" -// 4. umoci.UnpackRootfs fails with "config blob is not correct mediatype" -func TestUnpackLayersFailsOnBuildKitCacheMediatype(t *testing.T) { - // Create a temp directory for the OCI layout - cacheDir := t.TempDir() - - // Create OCI layout structure with BuildKit cache mediatype - err := createBuildKitCacheLayout(cacheDir, "test-cache") - require.NoError(t, err, "failed to create mock BuildKit cache layout") - - // Create OCI client and try to unpack - client, err := newOCIClient(cacheDir) - require.NoError(t, err) - - targetDir := t.TempDir() - err = client.unpackLayers(context.Background(), "test-cache", targetDir) - - // This should fail with a mediatype error - require.Error(t, err, "unpackLayers should fail on BuildKit cache mediatype") - assert.Contains(t, err.Error(), "config", "error should mention config") - - t.Logf("Got expected error: %v", err) -} - -// TestExtractMetadataSucceedsOnBuildKitCache verifies that extractOCIMetadata -// does NOT fail on BuildKit cache images - it's go-containerregistry which is -// lenient about mediatypes. The failure only happens during unpackLayers when -// umoci tries to unpack the rootfs. -func TestExtractMetadataSucceedsOnBuildKitCache(t *testing.T) { - cacheDir := t.TempDir() - - err := createBuildKitCacheLayout(cacheDir, "test-cache") - require.NoError(t, err) - - client, err := newOCIClient(cacheDir) - require.NoError(t, err) - - // This succeeds because go-containerregistry doesn't validate config mediatype - // The failure only happens in unpackLayers when umoci validates the config - meta, err := client.extractOCIMetadata("test-cache") - require.NoError(t, err, "extractOCIMetadata succeeds - go-containerregistry is lenient") - - // But the metadata will be empty/invalid since it's not a real OCI config - t.Logf("Got metadata (likely empty): %+v", meta) -} - -// createBuildKitCacheLayout creates an OCI layout that mimics what BuildKit -// exports when using --export-cache type=registry,image-manifest=true -// -// Layout structure: -// cacheDir/ -// ├── oci-layout (OCI layout version marker) -// ├── index.json (points to manifest) -// └── blobs/sha256/ -// ├── (image manifest with buildkit config mediatype) -// ├── (buildkit cache config blob) -// └── (dummy layer) -func createBuildKitCacheLayout(cacheDir, layoutTag string) error { - // Create directory structure - blobsDir := filepath.Join(cacheDir, "blobs", "sha256") - if err := os.MkdirAll(blobsDir, 0755); err != nil { - return err - } - - // 1. Create oci-layout file - ociLayout := map[string]string{"imageLayoutVersion": "1.0.0"} - ociLayoutBytes, _ := json.Marshal(ociLayout) - if err := os.WriteFile(filepath.Join(cacheDir, "oci-layout"), ociLayoutBytes, 0644); err != nil { - return err - } - - // 2. Create a dummy layer blob (gzipped tar with a single file) - // This is a minimal valid gzipped tar - layerContent := []byte{ - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, // gzip header - 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // empty tar - } - layerDigest := sha256Hash(layerContent) - if err := os.WriteFile(filepath.Join(blobsDir, layerDigest), layerContent, 0644); err != nil { - return err - } - - // 3. Create BuildKit cache config blob - // This is what BuildKit puts in the config - NOT a standard OCI config - cacheConfig := map[string]interface{}{ - "layers": []map[string]interface{}{ - { - "blob": "sha256:" + layerDigest, - "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", - }, - }, - } - configBytes, _ := json.Marshal(cacheConfig) - configDigest := sha256Hash(configBytes) - if err := os.WriteFile(filepath.Join(blobsDir, configDigest), configBytes, 0644); err != nil { - return err - } - - // 4. Create image manifest with BuildKit's cache config mediatype - manifest := map[string]interface{}{ - "schemaVersion": 2, - "mediaType": "application/vnd.oci.image.manifest.v1+json", - "config": map[string]interface{}{ - "mediaType": buildKitCacheConfigMediaType, // This is the problem! - "digest": "sha256:" + configDigest, - "size": len(configBytes), - }, - "layers": []map[string]interface{}{ - { - "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", - "digest": "sha256:" + layerDigest, - "size": len(layerContent), - }, - }, - } - manifestBytes, _ := json.Marshal(manifest) - manifestDigest := sha256Hash(manifestBytes) - if err := os.WriteFile(filepath.Join(blobsDir, manifestDigest), manifestBytes, 0644); err != nil { - return err - } - - // 5. Create index.json pointing to the manifest with our layout tag - index := map[string]interface{}{ - "schemaVersion": 2, - "mediaType": "application/vnd.oci.image.index.v1+json", - "manifests": []map[string]interface{}{ - { - "mediaType": "application/vnd.oci.image.manifest.v1+json", - "digest": "sha256:" + manifestDigest, - "size": len(manifestBytes), - "annotations": map[string]string{ - "org.opencontainers.image.ref.name": layoutTag, - }, - }, - }, - } - indexBytes, _ := json.Marshal(index) - if err := os.WriteFile(filepath.Join(cacheDir, "index.json"), indexBytes, 0644); err != nil { - return err - } - - return nil -} - -// sha256Hash computes the SHA256 hash of data and returns the hex string -func sha256Hash(data []byte) string { - h := sha256.Sum256(data) - return hex.EncodeToString(h[:]) -} - -// TestConvertToOCIMediaTypePassesThroughBuildKitType verifies that the -// mediatype conversion function doesn't handle BuildKit's cache config type, -// which is the root cause of the unpack failure. -func TestConvertToOCIMediaTypePassesThroughBuildKitType(t *testing.T) { - // Verify that BuildKit's mediatype passes through unchanged - result := convertToOCIMediaType(buildKitCacheConfigMediaType) - assert.Equal(t, buildKitCacheConfigMediaType, result, - "BuildKit cache config mediatype should pass through unchanged (this is the bug)") - - // Standard Docker types should be converted - assert.Equal(t, "application/vnd.oci.image.config.v1+json", - convertToOCIMediaType("application/vnd.docker.container.image.v1+json")) -} diff --git a/lib/middleware/oapi_auth.go b/lib/middleware/oapi_auth.go index b73c17e..6f8a825 100644 --- a/lib/middleware/oapi_auth.go +++ b/lib/middleware/oapi_auth.go @@ -470,70 +470,3 @@ func JwtAuth(jwtSecret string) func(http.Handler) http.Handler { }) } } - -// JWTAuthMiddleware creates a simple chi middleware that validates JWT bearer tokens. -// This is designed for admin endpoints that are not part of the OpenAPI spec. -// It validates the token and sets the user ID in the request context. -func JWTAuthMiddleware(jwtSecret string) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - log := logger.FromContext(r.Context()) - - // Extract token from Authorization header - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - http.Error(w, `{"code":"unauthorized","message":"missing authorization header"}`, http.StatusUnauthorized) - return - } - - token, err := extractBearerToken(authHeader) - if err != nil { - log.DebugContext(r.Context(), "failed to extract bearer token", "error", err) - http.Error(w, `{"code":"unauthorized","message":"invalid authorization header"}`, http.StatusUnauthorized) - return - } - - // Parse and validate the token - parsedToken, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) { - if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) - } - return []byte(jwtSecret), nil - }) - if err != nil || !parsedToken.Valid { - log.DebugContext(r.Context(), "invalid JWT token", "error", err) - http.Error(w, `{"code":"unauthorized","message":"invalid token"}`, http.StatusUnauthorized) - return - } - - claims, ok := parsedToken.Claims.(jwt.MapClaims) - if !ok { - http.Error(w, `{"code":"unauthorized","message":"invalid token claims"}`, http.StatusUnauthorized) - return - } - - // Reject registry tokens - they should not be used for admin API authentication - if _, hasRepos := claims["repos"]; hasRepos { - http.Error(w, `{"code":"unauthorized","message":"invalid token type"}`, http.StatusUnauthorized) - return - } - if _, hasScope := claims["scope"]; hasScope { - http.Error(w, `{"code":"unauthorized","message":"invalid token type"}`, http.StatusUnauthorized) - return - } - if _, hasBuildID := claims["build_id"]; hasBuildID { - http.Error(w, `{"code":"unauthorized","message":"invalid token type"}`, http.StatusUnauthorized) - return - } - - // Extract user ID from claims and add to context - var userID string - if sub, ok := claims["sub"].(string); ok { - userID = sub - } - - ctx := context.WithValue(r.Context(), userIDKey, userID) - next.ServeHTTP(w, r.WithContext(ctx)) - }) - } -} From 31afe9da3396bfd167e5a29f5ef0c2a791f372bf Mon Sep 17 00:00:00 2001 From: hiroTamada Date: Mon, 9 Feb 2026 16:59:44 -0500 Subject: [PATCH 7/7] fix: track inter-stage aliases in Dockerfile FROM rewriting rewriteDockerfileFROMs was missing stage name tracking, which could cause it to rewrite inter-stage FROM references (e.g. FROM builder) to point at the local registry instead of the prior build stage. Also skips ARG variable references (${...}) that can't be resolved. Co-Authored-By: Claude Opus 4.6 --- .../builder_agent/dockerfile_rewrite_test.go | 34 +++++++++++++++++++ lib/builds/builder_agent/main.go | 19 +++++++++++ 2 files changed, 53 insertions(+) diff --git a/lib/builds/builder_agent/dockerfile_rewrite_test.go b/lib/builds/builder_agent/dockerfile_rewrite_test.go index c2147b0..88e2c00 100644 --- a/lib/builds/builder_agent/dockerfile_rewrite_test.go +++ b/lib/builds/builder_agent/dockerfile_rewrite_test.go @@ -139,6 +139,40 @@ COPY binary /`, RUN echo hello`, expectedCount: 0, expected: `FROM ` + registryURL + `/onkernel/nodejs22-base:0.1.1 +RUN echo hello`, + }, + { + name: "inter-stage FROM reference is not rewritten", + dockerfile: `FROM onkernel/nodejs22-base:0.1.1 AS builder +RUN npm install +FROM builder +COPY --from=builder /app /app`, + expectedCount: 1, + expected: `FROM ` + registryURL + `/onkernel/nodejs22-base:0.1.1 AS builder +RUN npm install +FROM builder +COPY --from=builder /app /app`, + }, + { + name: "inter-stage reference case insensitive", + dockerfile: `FROM onkernel/nodejs22-base:0.1.1 AS Builder +RUN npm install +FROM builder +COPY --from=builder /app /app`, + expectedCount: 1, + expected: `FROM ` + registryURL + `/onkernel/nodejs22-base:0.1.1 AS Builder +RUN npm install +FROM builder +COPY --from=builder /app /app`, + }, + { + name: "variable reference FROM is not rewritten", + dockerfile: `ARG BASE_IMAGE=onkernel/nodejs22-base:0.1.1 +FROM ${BASE_IMAGE} +RUN echo hello`, + expectedCount: 0, + expected: `ARG BASE_IMAGE=onkernel/nodejs22-base:0.1.1 +FROM ${BASE_IMAGE} RUN echo hello`, }, } diff --git a/lib/builds/builder_agent/main.go b/lib/builds/builder_agent/main.go index c6134d9..ced5489 100644 --- a/lib/builds/builder_agent/main.go +++ b/lib/builds/builder_agent/main.go @@ -937,6 +937,7 @@ func rewriteDockerfileFROMs(dockerfilePath, registryURL string, insecure bool, r lines := strings.Split(string(content), "\n") rewriteCount := 0 + stageNames := make(map[string]bool) for i, line := range lines { trimmed := strings.TrimSpace(line) @@ -971,6 +972,14 @@ func rewriteDockerfileFROMs(dockerfilePath, registryURL string, insecure bool, r imageRef := parts[imageIdx] + // Record AS alias if present + for j := imageIdx + 1; j < len(parts)-1; j++ { + if strings.EqualFold(parts[j], "AS") { + stageNames[strings.ToLower(parts[j+1])] = true + break + } + } + // Skip if already referencing the local registry if strings.HasPrefix(imageRef, registryURL+"/") { continue @@ -981,6 +990,16 @@ func rewriteDockerfileFROMs(dockerfilePath, registryURL string, insecure bool, r continue } + // Skip inter-stage references (e.g. FROM builder) + if stageNames[strings.ToLower(imageRef)] { + continue + } + + // Skip variable references that can't be resolved + if strings.Contains(imageRef, "${") { + continue + } + // Normalize the image reference // Docker Hub images can be referenced as: // - "nginx" (library image)