Skip to content

Commit a0e44fa

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 <[email protected]>
1 parent c4e9408 commit a0e44fa

File tree

4 files changed

+289
-7
lines changed

4 files changed

+289
-7
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: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -969,3 +969,164 @@ CMD ["cat", "/build-time.txt"]
969969
t.Logf("✅ No 'docker inspect' error occurred")
970970
t.Logf("✅ This confirms the fix works: digest extracted from OCI layout instead of Docker daemon")
971971
}
972+
973+
// TestDockerPackage_ContainerExtraction_Integration tests container filesystem extraction
974+
// with both Docker daemon and OCI layout paths
975+
func TestDockerPackage_ContainerExtraction_Integration(t *testing.T) {
976+
if testing.Short() {
977+
t.Skip("Skipping integration test in short mode")
978+
}
979+
980+
// Ensure Docker is available
981+
if err := exec.Command("docker", "version").Run(); err != nil {
982+
t.Skip("Docker not available, skipping integration test")
983+
}
984+
985+
// Ensure buildx is available
986+
if err := exec.Command("docker", "buildx", "version").Run(); err != nil {
987+
t.Skip("Docker buildx not available, skipping integration test")
988+
}
989+
990+
// Create docker-container builder for OCI export
991+
builderName := "leeway-extract-test-builder"
992+
createBuilder := exec.Command("docker", "buildx", "create", "--name", builderName, "--driver", "docker-container", "--bootstrap")
993+
if err := createBuilder.Run(); err != nil {
994+
t.Logf("Warning: failed to create builder (might already exist): %v", err)
995+
}
996+
defer func() {
997+
exec.Command("docker", "buildx", "rm", builderName).Run()
998+
}()
999+
1000+
useBuilder := exec.Command("docker", "buildx", "use", builderName)
1001+
if err := useBuilder.Run(); err != nil {
1002+
t.Fatalf("Failed to use builder: %v", err)
1003+
}
1004+
defer func() {
1005+
exec.Command("docker", "buildx", "use", "default").Run()
1006+
}()
1007+
1008+
// Test both paths
1009+
testCases := []struct {
1010+
name string
1011+
exportToCache bool
1012+
expectedMessage string
1013+
}{
1014+
{
1015+
name: "with_docker_daemon",
1016+
exportToCache: false,
1017+
expectedMessage: "Image found in Docker daemon",
1018+
},
1019+
{
1020+
name: "with_oci_layout",
1021+
exportToCache: true,
1022+
expectedMessage: "OCI layout image.tar found and valid",
1023+
},
1024+
}
1025+
1026+
for _, tc := range testCases {
1027+
t.Run(tc.name, func(t *testing.T) {
1028+
tmpDir := t.TempDir()
1029+
wsDir := filepath.Join(tmpDir, "workspace")
1030+
if err := os.MkdirAll(wsDir, 0755); err != nil {
1031+
t.Fatal(err)
1032+
}
1033+
1034+
// Create WORKSPACE.yaml
1035+
workspaceYAML := `defaultTarget: ":test-extract"`
1036+
if err := os.WriteFile(filepath.Join(wsDir, "WORKSPACE.yaml"), []byte(workspaceYAML), 0644); err != nil {
1037+
t.Fatal(err)
1038+
}
1039+
1040+
// Create Dockerfile
1041+
dockerfile := `FROM alpine:3.18
1042+
RUN echo "test content" > /test.txt
1043+
`
1044+
if err := os.WriteFile(filepath.Join(wsDir, "Dockerfile"), []byte(dockerfile), 0644); err != nil {
1045+
t.Fatal(err)
1046+
}
1047+
1048+
// Create BUILD.yaml with container extraction
1049+
buildYAML := fmt.Sprintf(`packages:
1050+
- name: test-extract
1051+
type: docker
1052+
config:
1053+
dockerfile: Dockerfile
1054+
exportToCache: %v
1055+
`, tc.exportToCache)
1056+
if err := os.WriteFile(filepath.Join(wsDir, "BUILD.yaml"), []byte(buildYAML), 0644); err != nil {
1057+
t.Fatal(err)
1058+
}
1059+
1060+
// Initialize git repo
1061+
gitInit := exec.Command("git", "init")
1062+
gitInit.Dir = wsDir
1063+
if err := gitInit.Run(); err != nil {
1064+
t.Fatal(err)
1065+
}
1066+
1067+
gitConfigName := exec.Command("git", "config", "user.name", "Test User")
1068+
gitConfigName.Dir = wsDir
1069+
if err := gitConfigName.Run(); err != nil {
1070+
t.Fatal(err)
1071+
}
1072+
1073+
gitConfigEmail := exec.Command("git", "config", "user.email", "[email protected]")
1074+
gitConfigEmail.Dir = wsDir
1075+
if err := gitConfigEmail.Run(); err != nil {
1076+
t.Fatal(err)
1077+
}
1078+
1079+
gitAdd := exec.Command("git", "add", ".")
1080+
gitAdd.Dir = wsDir
1081+
if err := gitAdd.Run(); err != nil {
1082+
t.Fatal(err)
1083+
}
1084+
1085+
gitCommit := exec.Command("git", "commit", "-m", "initial")
1086+
gitCommit.Dir = wsDir
1087+
gitCommit.Env = append(os.Environ(),
1088+
"GIT_AUTHOR_DATE=2021-01-01T00:00:00Z",
1089+
"GIT_COMMITTER_DATE=2021-01-01T00:00:00Z",
1090+
)
1091+
if err := gitCommit.Run(); err != nil {
1092+
t.Fatal(err)
1093+
}
1094+
1095+
// Build
1096+
cacheDir := filepath.Join(tmpDir, "cache")
1097+
cache, err := local.NewFilesystemCache(cacheDir)
1098+
if err != nil {
1099+
t.Fatal(err)
1100+
}
1101+
1102+
buildCtx, err := newBuildContext(buildOptions{
1103+
LocalCache: cache,
1104+
DockerExportToCache: tc.exportToCache,
1105+
DockerExportSet: true,
1106+
Reporter: NewConsoleReporter(),
1107+
})
1108+
if err != nil {
1109+
t.Fatal(err)
1110+
}
1111+
1112+
ws, err := FindWorkspace(wsDir, Arguments{}, "", "")
1113+
if err != nil {
1114+
t.Fatal(err)
1115+
}
1116+
1117+
pkg, ok := ws.Packages["//:test-extract"]
1118+
if !ok {
1119+
t.Fatal("package //:test-extract not found")
1120+
}
1121+
1122+
// Build the package - this should extract the container filesystem
1123+
if err := pkg.build(buildCtx); err != nil {
1124+
t.Fatalf("build failed: %v", err)
1125+
}
1126+
1127+
t.Logf("✅ Build succeeded with exportToCache=%v", tc.exportToCache)
1128+
t.Logf("✅ Container filesystem extraction completed")
1129+
t.Logf("✅ No 'image not found' error occurred")
1130+
})
1131+
}
1132+
}

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)