Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions .github/workflows/integration-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ on:
push:
branches:
- 'main'
workflow_dispatch:

env:
GO_VERSION: '1.24'
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,11 @@ E.g. `component/nested:docker` becomes `COMPONENT_NESTED__DOCKER`.
- CLI flag: `leeway build --docker-export-to-cache`
- Environment variable: `LEEWAY_DOCKER_EXPORT_TO_CACHE=true`

**Requirements for OCI export (`exportToCache: true`):**
- Docker Buildx with `docker-container` driver (the default `docker` driver does not support OCI export)
- **Local development:** Create a builder with `docker buildx create --name leeway-builder --driver docker-container --bootstrap && docker buildx use leeway-builder`
- **CI/CD:** Use `docker/setup-buildx-action` which automatically creates a `docker-container` builder by default

See `leeway build --help` for more details.

### Generic packages
Expand Down
55 changes: 48 additions & 7 deletions pkg/leeway/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -2044,13 +2044,29 @@ func (p *Package) buildDocker(buildctx *buildContext, wd, result string) (res *p
})
extractLogger.Debug("Extracting container filesystem")

// First, verify the image exists
imageExists, err := checkImageExists(version)
if err != nil {
return xerrors.Errorf("failed to check if image exists: %w", err)
}
if !imageExists {
return xerrors.Errorf("image %s not found - build may have failed silently", version)
// Verify the image exists (check OCI layout or Docker daemon based on export mode)
if *cfg.ExportToCache {
// Check OCI layout
extractLogger.Debug("Checking OCI layout image.tar")
ociExists, err := checkOCILayoutExists(buildDir)
if err != nil {
return xerrors.Errorf("failed to check OCI layout: %w", err)
}
if !ociExists {
return xerrors.Errorf("OCI layout image.tar not found in %s - build may have failed silently", buildDir)
}
extractLogger.Debug("OCI layout image.tar found and valid")
} else {
// Check Docker daemon
extractLogger.Debug("Checking Docker daemon for image")
imageExists, err := checkImageExists(version)
if err != nil {
return xerrors.Errorf("failed to check if image exists in Docker daemon: %w", err)
}
if !imageExists {
return xerrors.Errorf("image %s not found in Docker daemon - build may have failed silently", version)
}
extractLogger.Debug("Image found in Docker daemon")
}

// Use the OCI libraries for extraction with more robust error handling
Expand Down Expand Up @@ -2803,6 +2819,31 @@ func checkImageExists(imageName string) (bool, error) {
return true, nil
}

// checkOCILayoutExists checks if an OCI layout image.tar exists and is valid
func checkOCILayoutExists(buildDir string) (bool, error) {
imageTarPath := filepath.Join(buildDir, "image.tar")

// Check if image.tar exists
info, err := os.Stat(imageTarPath)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, xerrors.Errorf("failed to stat image.tar: %w", err)
}

// Check if it's a regular file and not empty
if !info.Mode().IsRegular() {
return false, xerrors.Errorf("image.tar is not a regular file")
}

if info.Size() == 0 {
return false, xerrors.Errorf("image.tar is empty")
}

return true, nil
}

// logDirectoryStructure logs the directory structure for debugging
func logDirectoryStructure(dir string, logger *log.Entry) error {
cmd := exec.Command("find", dir, "-type", "f", "-o", "-type", "d", "|", "sort")
Expand Down
237 changes: 232 additions & 5 deletions pkg/leeway/build_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ func extractDockerMetadataFromCache(cacheBundleFN string) (*DockerImageMetadata,
return nil, fmt.Errorf("docker-export-metadata.json not found in cache bundle")
}

// TestDockerPackage_ExportToCache_Integration verifies OCI layout export functionality.
// Tests three scenarios:
// 1. Legacy push behavior (exportToCache=false) - pushes to registry
// 2. New OCI export (exportToCache=true) - creates image.tar in cache
// 3. Export without image config - extracts container filesystem
//
// SLSA relevance: Validates that exportToCache creates OCI layout required for SLSA L3.
func TestDockerPackage_ExportToCache_Integration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
Expand Down Expand Up @@ -109,9 +116,9 @@ func TestDockerPackage_ExportToCache_Integration(t *testing.T) {
name: "export without image config",
exportToCache: true,
hasImages: false,
expectFiles: []string{"content"},
expectError: true, // OCI layout export requires an image tag
expectErrorMatch: "(?i)(not found|failed)", // Build fails without image config in OCI mode
expectFiles: []string{"content"}, // Without image config, extracts container filesystem
expectError: false,
expectErrorMatch: "",
},
}

Expand All @@ -122,6 +129,26 @@ func TestDockerPackage_ExportToCache_Integration(t *testing.T) {
t.Skip(tt.skipReason)
}

// Create docker-container builder for OCI export if needed
if tt.exportToCache {
builderName := "leeway-export-test-builder"
createBuilder := exec.Command("docker", "buildx", "create", "--name", builderName, "--driver", "docker-container", "--bootstrap")
if err := createBuilder.Run(); err != nil {
// Builder might already exist, try to use it
t.Logf("Builder creation failed (might already exist): %v", err)
}
defer func() {
removeBuilder := exec.Command("docker", "buildx", "rm", builderName)
_ = removeBuilder.Run()
}()

// Set builder as default for this test
useBuilder := exec.Command("docker", "buildx", "use", builderName)
if err := useBuilder.Run(); err != nil {
t.Fatalf("Failed to use builder: %v", err)
}
}

// Create temporary workspace
tmpDir := t.TempDir()

Expand Down Expand Up @@ -324,6 +351,10 @@ func listTarGzContents(path string) ([]string, error) {
return files, nil
}

// TestDockerPackage_CacheRoundTrip_Integration verifies the complete cache workflow:
// Build with OCI export → Cache → Restore → Load into Docker → Verify image works
//
// SLSA relevance: Validates end-to-end cache workflow required for SLSA L3 compliance.
func TestDockerPackage_CacheRoundTrip_Integration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
Expand All @@ -334,6 +365,22 @@ func TestDockerPackage_CacheRoundTrip_Integration(t *testing.T) {
t.Skip("Docker not available, skipping integration test")
}

// Create docker-container builder for OCI export
builderName := "leeway-roundtrip-test-builder"
createBuilder := exec.Command("docker", "buildx", "create", "--name", builderName, "--driver", "docker-container", "--bootstrap")
if err := createBuilder.Run(); err != nil {
t.Logf("Builder creation failed (might already exist): %v", err)
}
defer func() {
removeBuilder := exec.Command("docker", "buildx", "rm", builderName)
_ = removeBuilder.Run()
}()

useBuilder := exec.Command("docker", "buildx", "use", builderName)
if err := useBuilder.Run(); err != nil {
t.Fatalf("Failed to use builder: %v", err)
}

// This test verifies that a Docker image can be:
// 1. Built and exported to cache
// 2. Extracted from cache
Expand Down Expand Up @@ -557,6 +604,11 @@ CMD ["cat", "/test-file.txt"]`
t.Log("✅ Round-trip test passed: image exported, cached, extracted, loaded, and executed successfully")
}

// TestDockerPackage_OCILayout_Determinism_Integration verifies deterministic builds with OCI layout.
// Builds the same package twice and compares SHA256 hashes of the resulting image.tar files.
//
// SLSA relevance: CRITICAL for SLSA L3 - deterministic builds enable reproducible builds
// and build provenance verification. This validates that OCI layout export is deterministic.
func TestDockerPackage_OCILayout_Determinism_Integration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
Expand Down Expand Up @@ -775,8 +827,16 @@ func checksumFile(path string) (string, error) {
return fmt.Sprintf("%x", h.Sum(nil)), nil
}

// TestDockerPackage_OCILayout_SLSA_Integration tests that SLSA provenance generation
// works correctly with OCI layout export (regression test for docker inspect bug)
// TestDockerPackage_OCILayout_SLSA_Integration is the PRIMARY SLSA L3 TEST.
// Tests end-to-end SLSA provenance generation with OCI layout export:
// - Workspace with provenance.slsa: true
// - Package with exportToCache: true
// - Build creates OCI layout (image.tar)
// - SLSA provenance generation succeeds
// - Digest extracted from index.json (not docker inspect)
//
// This validates the exact workflow used in production SLSA L3 builds.
// Regression test for the docker inspect bug where digest extraction failed with OCI layout.
func TestDockerPackage_OCILayout_SLSA_Integration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
Expand Down Expand Up @@ -969,3 +1029,170 @@ CMD ["cat", "/build-time.txt"]
t.Logf("✅ No 'docker inspect' error occurred")
t.Logf("✅ This confirms the fix works: digest extracted from OCI layout instead of Docker daemon")
}

// TestDockerPackage_ContainerExtraction_Integration tests container filesystem extraction
// with both Docker daemon and OCI layout paths. Validates the fix for checkOCILayoutExists().
//
// Tests two scenarios:
// 1. with_docker_daemon (exportToCache=false) - uses docker image inspect
// 2. with_oci_layout (exportToCache=true) - uses checkOCILayoutExists()
//
// SLSA relevance: Ensures packages that extract files from Docker images work with SLSA L3 caching.
func TestDockerPackage_ContainerExtraction_Integration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}

// Ensure Docker is available
if err := exec.Command("docker", "version").Run(); err != nil {
t.Skip("Docker not available, skipping integration test")
}

// Ensure buildx is available
if err := exec.Command("docker", "buildx", "version").Run(); err != nil {
t.Skip("Docker buildx not available, skipping integration test")
}

// Create docker-container builder for OCI export
builderName := "leeway-extract-test-builder"
createBuilder := exec.Command("docker", "buildx", "create", "--name", builderName, "--driver", "docker-container", "--bootstrap")
if err := createBuilder.Run(); err != nil {
t.Logf("Warning: failed to create builder (might already exist): %v", err)
}
defer func() {
exec.Command("docker", "buildx", "rm", builderName).Run()
}()

useBuilder := exec.Command("docker", "buildx", "use", builderName)
if err := useBuilder.Run(); err != nil {
t.Fatalf("Failed to use builder: %v", err)
}
defer func() {
exec.Command("docker", "buildx", "use", "default").Run()
}()

// Test both paths
testCases := []struct {
name string
exportToCache bool
expectedMessage string
}{
{
name: "with_docker_daemon",
exportToCache: false,
expectedMessage: "Image found in Docker daemon",
},
{
name: "with_oci_layout",
exportToCache: true,
expectedMessage: "OCI layout image.tar found and valid",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
tmpDir := t.TempDir()
wsDir := filepath.Join(tmpDir, "workspace")
if err := os.MkdirAll(wsDir, 0755); err != nil {
t.Fatal(err)
}

// Create WORKSPACE.yaml
workspaceYAML := `defaultTarget: ":test-extract"`
if err := os.WriteFile(filepath.Join(wsDir, "WORKSPACE.yaml"), []byte(workspaceYAML), 0644); err != nil {
t.Fatal(err)
}

// Create Dockerfile
dockerfile := `FROM alpine:3.18
RUN echo "test content" > /test.txt
`
if err := os.WriteFile(filepath.Join(wsDir, "Dockerfile"), []byte(dockerfile), 0644); err != nil {
t.Fatal(err)
}

// Create BUILD.yaml with container extraction
buildYAML := fmt.Sprintf(`packages:
- name: test-extract
type: docker
config:
dockerfile: Dockerfile
exportToCache: %v
`, tc.exportToCache)
if err := os.WriteFile(filepath.Join(wsDir, "BUILD.yaml"), []byte(buildYAML), 0644); err != nil {
t.Fatal(err)
}

// Initialize git repo
gitInit := exec.Command("git", "init")
gitInit.Dir = wsDir
if err := gitInit.Run(); err != nil {
t.Fatal(err)
}

gitConfigName := exec.Command("git", "config", "user.name", "Test User")
gitConfigName.Dir = wsDir
if err := gitConfigName.Run(); err != nil {
t.Fatal(err)
}

gitConfigEmail := exec.Command("git", "config", "user.email", "[email protected]")
gitConfigEmail.Dir = wsDir
if err := gitConfigEmail.Run(); err != nil {
t.Fatal(err)
}

gitAdd := exec.Command("git", "add", ".")
gitAdd.Dir = wsDir
if err := gitAdd.Run(); err != nil {
t.Fatal(err)
}

gitCommit := exec.Command("git", "commit", "-m", "initial")
gitCommit.Dir = wsDir
gitCommit.Env = append(os.Environ(),
"GIT_AUTHOR_DATE=2021-01-01T00:00:00Z",
"GIT_COMMITTER_DATE=2021-01-01T00:00:00Z",
)
if err := gitCommit.Run(); err != nil {
t.Fatal(err)
}

// Build
cacheDir := filepath.Join(tmpDir, "cache")
cache, err := local.NewFilesystemCache(cacheDir)
if err != nil {
t.Fatal(err)
}

buildCtx, err := newBuildContext(buildOptions{
LocalCache: cache,
DockerExportToCache: tc.exportToCache,
DockerExportSet: true,
Reporter: NewConsoleReporter(),
})
if err != nil {
t.Fatal(err)
}

ws, err := FindWorkspace(wsDir, Arguments{}, "", "")
if err != nil {
t.Fatal(err)
}

pkg, ok := ws.Packages["//:test-extract"]
if !ok {
t.Fatal("package //:test-extract not found")
}

// Build the package - this should extract the container filesystem
if err := pkg.build(buildCtx); err != nil {
t.Fatalf("build failed: %v", err)
}

t.Logf("✅ Build succeeded with exportToCache=%v", tc.exportToCache)
t.Logf("✅ Container filesystem extraction completed")
t.Logf("✅ No 'image not found' error occurred")
})
}
}
Loading
Loading