Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions lib/builds/builder_agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand All @@ -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"
}
Expand Down
4 changes: 3 additions & 1 deletion lib/builds/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/builds/cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
190 changes: 190 additions & 0 deletions lib/images/oci_test.go
Original file line number Diff line number Diff line change
@@ -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/
// ├── <manifest> (image manifest with buildkit config mediatype)
// ├── <config> (buildkit cache config blob)
// └── <layer> (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"))
}
8 changes: 8 additions & 0 deletions lib/registry/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down