Skip to content

Commit d0c01ae

Browse files
leodidoona-agent
andcommitted
fix: support container extraction with OCI layout export
When exportToCache is enabled, images are in OCI layout format (not Docker daemon). Container extraction code was checking Docker daemon with 'docker image inspect', which fails for OCI layout images. Changes: - Add checkOCILayoutExists() to validate OCI layout before extraction - Update PostProcess to use appropriate check based on exportToCache flag - Add unit tests for OCI layout validation (4 test cases) - Add integration test for both Docker daemon and OCI layout paths - Add workflow_dispatch trigger to integration tests workflow All tests passing: - Unit tests: TestCheckOCILayoutExists (4/4) - Integration: TestDockerPackage_ContainerExtraction_Integration (2/2) Co-authored-by: Ona <no-reply@ona.com>
1 parent 61f08fe commit d0c01ae

File tree

4 files changed

+328
-10
lines changed

4 files changed

+328
-10
lines changed

.github/workflows/integration-tests.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ on:
1212
push:
1313
branches:
1414
- 'main'
15+
workflow_dispatch:
1516

1617
env:
1718
GO_VERSION: '1.24'

pkg/leeway/build.go

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2044,13 +2044,29 @@ func (p *Package) buildDocker(buildctx *buildContext, wd, result string) (res *p
20442044
})
20452045
extractLogger.Debug("Extracting container filesystem")
20462046

2047-
// First, verify the image exists
2048-
imageExists, err := checkImageExists(version)
2049-
if err != nil {
2050-
return xerrors.Errorf("failed to check if image exists: %w", err)
2051-
}
2052-
if !imageExists {
2053-
return xerrors.Errorf("image %s not found - build may have failed silently", version)
2047+
// Verify the image exists (check OCI layout or Docker daemon based on export mode)
2048+
if *cfg.ExportToCache {
2049+
// Check OCI layout
2050+
extractLogger.Debug("Checking OCI layout image.tar")
2051+
ociExists, err := checkOCILayoutExists(buildDir)
2052+
if err != nil {
2053+
return xerrors.Errorf("failed to check OCI layout: %w", err)
2054+
}
2055+
if !ociExists {
2056+
return xerrors.Errorf("OCI layout image.tar not found in %s - build may have failed silently", buildDir)
2057+
}
2058+
extractLogger.Debug("OCI layout image.tar found and valid")
2059+
} else {
2060+
// Check Docker daemon
2061+
extractLogger.Debug("Checking Docker daemon for image")
2062+
imageExists, err := checkImageExists(version)
2063+
if err != nil {
2064+
return xerrors.Errorf("failed to check if image exists in Docker daemon: %w", err)
2065+
}
2066+
if !imageExists {
2067+
return xerrors.Errorf("image %s not found in Docker daemon - build may have failed silently", version)
2068+
}
2069+
extractLogger.Debug("Image found in Docker daemon")
20542070
}
20552071

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

2822+
// checkOCILayoutExists checks if an OCI layout image.tar exists and is valid
2823+
func checkOCILayoutExists(buildDir string) (bool, error) {
2824+
imageTarPath := filepath.Join(buildDir, "image.tar")
2825+
2826+
// Check if image.tar exists
2827+
info, err := os.Stat(imageTarPath)
2828+
if err != nil {
2829+
if os.IsNotExist(err) {
2830+
return false, nil
2831+
}
2832+
return false, xerrors.Errorf("failed to stat image.tar: %w", err)
2833+
}
2834+
2835+
// Check if it's a regular file and not empty
2836+
if !info.Mode().IsRegular() {
2837+
return false, xerrors.Errorf("image.tar is not a regular file")
2838+
}
2839+
2840+
if info.Size() == 0 {
2841+
return false, xerrors.Errorf("image.tar is empty")
2842+
}
2843+
2844+
return true, nil
2845+
}
2846+
28062847
// logDirectoryStructure logs the directory structure for debugging
28072848
func logDirectoryStructure(dir string, logger *log.Entry) error {
28082849
cmd := exec.Command("find", dir, "-type", "f", "-o", "-type", "d", "|", "sort")

pkg/leeway/build_integration_test.go

Lines changed: 200 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,9 @@ func TestDockerPackage_ExportToCache_Integration(t *testing.T) {
109109
name: "export without image config",
110110
exportToCache: true,
111111
hasImages: false,
112-
expectFiles: []string{"content"},
113-
expectError: true, // OCI layout export requires an image tag
114-
expectErrorMatch: "(?i)(not found|failed)", // Build fails without image config in OCI mode
112+
expectFiles: []string{"content"}, // Without image config, extracts container filesystem
113+
expectError: false,
114+
expectErrorMatch: "",
115115
},
116116
}
117117

@@ -122,6 +122,26 @@ func TestDockerPackage_ExportToCache_Integration(t *testing.T) {
122122
t.Skip(tt.skipReason)
123123
}
124124

125+
// Create docker-container builder for OCI export if needed
126+
if tt.exportToCache {
127+
builderName := "leeway-export-test-builder"
128+
createBuilder := exec.Command("docker", "buildx", "create", "--name", builderName, "--driver", "docker-container", "--bootstrap")
129+
if err := createBuilder.Run(); err != nil {
130+
// Builder might already exist, try to use it
131+
t.Logf("Builder creation failed (might already exist): %v", err)
132+
}
133+
defer func() {
134+
removeBuilder := exec.Command("docker", "buildx", "rm", builderName)
135+
_ = removeBuilder.Run()
136+
}()
137+
138+
// Set builder as default for this test
139+
useBuilder := exec.Command("docker", "buildx", "use", builderName)
140+
if err := useBuilder.Run(); err != nil {
141+
t.Fatalf("Failed to use builder: %v", err)
142+
}
143+
}
144+
125145
// Create temporary workspace
126146
tmpDir := t.TempDir()
127147

@@ -334,6 +354,22 @@ func TestDockerPackage_CacheRoundTrip_Integration(t *testing.T) {
334354
t.Skip("Docker not available, skipping integration test")
335355
}
336356

357+
// Create docker-container builder for OCI export
358+
builderName := "leeway-roundtrip-test-builder"
359+
createBuilder := exec.Command("docker", "buildx", "create", "--name", builderName, "--driver", "docker-container", "--bootstrap")
360+
if err := createBuilder.Run(); err != nil {
361+
t.Logf("Builder creation failed (might already exist): %v", err)
362+
}
363+
defer func() {
364+
removeBuilder := exec.Command("docker", "buildx", "rm", builderName)
365+
_ = removeBuilder.Run()
366+
}()
367+
368+
useBuilder := exec.Command("docker", "buildx", "use", builderName)
369+
if err := useBuilder.Run(); err != nil {
370+
t.Fatalf("Failed to use builder: %v", err)
371+
}
372+
337373
// This test verifies that a Docker image can be:
338374
// 1. Built and exported to cache
339375
// 2. Extracted from cache
@@ -969,3 +1005,164 @@ CMD ["cat", "/build-time.txt"]
9691005
t.Logf("✅ No 'docker inspect' error occurred")
9701006
t.Logf("✅ This confirms the fix works: digest extracted from OCI layout instead of Docker daemon")
9711007
}
1008+
1009+
// TestDockerPackage_ContainerExtraction_Integration tests container filesystem extraction
1010+
// with both Docker daemon and OCI layout paths
1011+
func TestDockerPackage_ContainerExtraction_Integration(t *testing.T) {
1012+
if testing.Short() {
1013+
t.Skip("Skipping integration test in short mode")
1014+
}
1015+
1016+
// Ensure Docker is available
1017+
if err := exec.Command("docker", "version").Run(); err != nil {
1018+
t.Skip("Docker not available, skipping integration test")
1019+
}
1020+
1021+
// Ensure buildx is available
1022+
if err := exec.Command("docker", "buildx", "version").Run(); err != nil {
1023+
t.Skip("Docker buildx not available, skipping integration test")
1024+
}
1025+
1026+
// Create docker-container builder for OCI export
1027+
builderName := "leeway-extract-test-builder"
1028+
createBuilder := exec.Command("docker", "buildx", "create", "--name", builderName, "--driver", "docker-container", "--bootstrap")
1029+
if err := createBuilder.Run(); err != nil {
1030+
t.Logf("Warning: failed to create builder (might already exist): %v", err)
1031+
}
1032+
defer func() {
1033+
exec.Command("docker", "buildx", "rm", builderName).Run()
1034+
}()
1035+
1036+
useBuilder := exec.Command("docker", "buildx", "use", builderName)
1037+
if err := useBuilder.Run(); err != nil {
1038+
t.Fatalf("Failed to use builder: %v", err)
1039+
}
1040+
defer func() {
1041+
exec.Command("docker", "buildx", "use", "default").Run()
1042+
}()
1043+
1044+
// Test both paths
1045+
testCases := []struct {
1046+
name string
1047+
exportToCache bool
1048+
expectedMessage string
1049+
}{
1050+
{
1051+
name: "with_docker_daemon",
1052+
exportToCache: false,
1053+
expectedMessage: "Image found in Docker daemon",
1054+
},
1055+
{
1056+
name: "with_oci_layout",
1057+
exportToCache: true,
1058+
expectedMessage: "OCI layout image.tar found and valid",
1059+
},
1060+
}
1061+
1062+
for _, tc := range testCases {
1063+
t.Run(tc.name, func(t *testing.T) {
1064+
tmpDir := t.TempDir()
1065+
wsDir := filepath.Join(tmpDir, "workspace")
1066+
if err := os.MkdirAll(wsDir, 0755); err != nil {
1067+
t.Fatal(err)
1068+
}
1069+
1070+
// Create WORKSPACE.yaml
1071+
workspaceYAML := `defaultTarget: ":test-extract"`
1072+
if err := os.WriteFile(filepath.Join(wsDir, "WORKSPACE.yaml"), []byte(workspaceYAML), 0644); err != nil {
1073+
t.Fatal(err)
1074+
}
1075+
1076+
// Create Dockerfile
1077+
dockerfile := `FROM alpine:3.18
1078+
RUN echo "test content" > /test.txt
1079+
`
1080+
if err := os.WriteFile(filepath.Join(wsDir, "Dockerfile"), []byte(dockerfile), 0644); err != nil {
1081+
t.Fatal(err)
1082+
}
1083+
1084+
// Create BUILD.yaml with container extraction
1085+
buildYAML := fmt.Sprintf(`packages:
1086+
- name: test-extract
1087+
type: docker
1088+
config:
1089+
dockerfile: Dockerfile
1090+
exportToCache: %v
1091+
`, tc.exportToCache)
1092+
if err := os.WriteFile(filepath.Join(wsDir, "BUILD.yaml"), []byte(buildYAML), 0644); err != nil {
1093+
t.Fatal(err)
1094+
}
1095+
1096+
// Initialize git repo
1097+
gitInit := exec.Command("git", "init")
1098+
gitInit.Dir = wsDir
1099+
if err := gitInit.Run(); err != nil {
1100+
t.Fatal(err)
1101+
}
1102+
1103+
gitConfigName := exec.Command("git", "config", "user.name", "Test User")
1104+
gitConfigName.Dir = wsDir
1105+
if err := gitConfigName.Run(); err != nil {
1106+
t.Fatal(err)
1107+
}
1108+
1109+
gitConfigEmail := exec.Command("git", "config", "user.email", "test@example.com")
1110+
gitConfigEmail.Dir = wsDir
1111+
if err := gitConfigEmail.Run(); err != nil {
1112+
t.Fatal(err)
1113+
}
1114+
1115+
gitAdd := exec.Command("git", "add", ".")
1116+
gitAdd.Dir = wsDir
1117+
if err := gitAdd.Run(); err != nil {
1118+
t.Fatal(err)
1119+
}
1120+
1121+
gitCommit := exec.Command("git", "commit", "-m", "initial")
1122+
gitCommit.Dir = wsDir
1123+
gitCommit.Env = append(os.Environ(),
1124+
"GIT_AUTHOR_DATE=2021-01-01T00:00:00Z",
1125+
"GIT_COMMITTER_DATE=2021-01-01T00:00:00Z",
1126+
)
1127+
if err := gitCommit.Run(); err != nil {
1128+
t.Fatal(err)
1129+
}
1130+
1131+
// Build
1132+
cacheDir := filepath.Join(tmpDir, "cache")
1133+
cache, err := local.NewFilesystemCache(cacheDir)
1134+
if err != nil {
1135+
t.Fatal(err)
1136+
}
1137+
1138+
buildCtx, err := newBuildContext(buildOptions{
1139+
LocalCache: cache,
1140+
DockerExportToCache: tc.exportToCache,
1141+
DockerExportSet: true,
1142+
Reporter: NewConsoleReporter(),
1143+
})
1144+
if err != nil {
1145+
t.Fatal(err)
1146+
}
1147+
1148+
ws, err := FindWorkspace(wsDir, Arguments{}, "", "")
1149+
if err != nil {
1150+
t.Fatal(err)
1151+
}
1152+
1153+
pkg, ok := ws.Packages["//:test-extract"]
1154+
if !ok {
1155+
t.Fatal("package //:test-extract not found")
1156+
}
1157+
1158+
// Build the package - this should extract the container filesystem
1159+
if err := pkg.build(buildCtx); err != nil {
1160+
t.Fatalf("build failed: %v", err)
1161+
}
1162+
1163+
t.Logf("✅ Build succeeded with exportToCache=%v", tc.exportToCache)
1164+
t.Logf("✅ Container filesystem extraction completed")
1165+
t.Logf("✅ No 'image not found' error occurred")
1166+
})
1167+
}
1168+
}

pkg/leeway/build_oci_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,85 @@ func TestExtractDigestFromOCILayout_MissingFile(t *testing.T) {
145145
}
146146
}
147147

148+
// TestCheckOCILayoutExists tests the OCI layout validation function
149+
func TestCheckOCILayoutExists(t *testing.T) {
150+
tests := []struct {
151+
name string
152+
setup func(string) error
153+
wantExists bool
154+
wantErr bool
155+
errContains string
156+
}{
157+
{
158+
name: "valid image.tar exists",
159+
setup: func(dir string) error {
160+
return os.WriteFile(filepath.Join(dir, "image.tar"), []byte("fake tar content"), 0644)
161+
},
162+
wantExists: true,
163+
wantErr: false,
164+
},
165+
{
166+
name: "image.tar missing",
167+
setup: func(dir string) error {
168+
// Don't create image.tar
169+
return nil
170+
},
171+
wantExists: false,
172+
wantErr: false,
173+
},
174+
{
175+
name: "image.tar is empty",
176+
setup: func(dir string) error {
177+
return os.WriteFile(filepath.Join(dir, "image.tar"), []byte{}, 0644)
178+
},
179+
wantExists: false,
180+
wantErr: true,
181+
errContains: "empty",
182+
},
183+
{
184+
name: "image.tar is a directory",
185+
setup: func(dir string) error {
186+
return os.Mkdir(filepath.Join(dir, "image.tar"), 0755)
187+
},
188+
wantExists: false,
189+
wantErr: true,
190+
errContains: "not a regular file",
191+
},
192+
}
193+
194+
for _, tt := range tests {
195+
t.Run(tt.name, func(t *testing.T) {
196+
tmpDir := t.TempDir()
197+
198+
if err := tt.setup(tmpDir); err != nil {
199+
t.Fatalf("setup failed: %v", err)
200+
}
201+
202+
exists, err := checkOCILayoutExists(tmpDir)
203+
204+
if tt.wantErr {
205+
if err == nil {
206+
t.Errorf("checkOCILayoutExists() expected error, got nil")
207+
return
208+
}
209+
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
210+
t.Errorf("checkOCILayoutExists() error = %v, want error containing %q", err, tt.errContains)
211+
}
212+
return
213+
}
214+
215+
if err != nil {
216+
t.Errorf("checkOCILayoutExists() unexpected error: %v", err)
217+
return
218+
}
219+
220+
if exists != tt.wantExists {
221+
t.Errorf("checkOCILayoutExists() = %v, want %v", exists, tt.wantExists)
222+
}
223+
})
224+
}
225+
}
226+
148227
// TestCreateOCILayoutSubjectsFunction tests the OCI layout subjects function
149228
// Note: This function is set up regardless of SLSA being enabled, but is only
150229
// called when SLSA provenance generation is active. This test verifies the

0 commit comments

Comments
 (0)