-
Notifications
You must be signed in to change notification settings - Fork 20
fix: extract container files from OCI tar instead of Docker daemon #296
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
When exportToCache=true, Docker images are built with `--output type=oci,dest=image.tar` which exports to a tar file but does NOT load the image into the Docker daemon. The extraction code was always trying to get the image from the Docker daemon using `daemon.Image()`, which failed with "No such image" error. This fix: 1. Checks if an OCI tar file exists (image.tar in the build directory) 2. If yes, extracts the OCI layout from the tar and loads the image from it 3. If no, falls back to the Docker daemon (existing behavior) Also removes the global mock in build_test.go that was preventing integration tests from testing the real extraction code. The mock is now a helper function that tests can explicitly call if needed. Fixes the production failure in gitpod-next where runner/shared/openssh:docker package build was failing with "No such image" error. Co-authored-by: Ona <no-reply@ona.com>
Adds TestDockerPackage_OCIExtraction_NoImage_Integration which reproduces and verifies the fix for the bug where container extraction fails with 'No such image' error when exportToCache=true and no image: config. The test: 1. Creates a Docker package with NO image: config (not pushed to registry) 2. Builds with exportToCache=true (creates OCI layout) 3. Verifies build succeeds without 'No such image' error 4. Confirms extraction works from OCI tar instead of Docker daemon This test would fail on main branch (uses mock) but passes on fix branch (uses real extraction from OCI tar). Co-authored-by: Ona <no-reply@ona.com>
Unit tests that use dummy docker (not real Docker) need the mock enabled because dummy docker doesn't create real images or OCI tars. Without the mock, container extraction fails with 'No such image' error when the test package has no image: config (which triggers extraction). Tests fixed: - TestBuildDockerDeps - TestDockerPostProcessing This resolves the CI unit test failures while keeping integration tests using real extraction code. Co-authored-by: Ona <no-reply@ona.com>
326925d to
d998ff0
Compare
SBOM generation was not checking the LEEWAY_DOCKER_EXPORT_TO_CACHE environment variable, causing it to always use the Docker daemon path even when SLSA was enabled and images were exported as OCI layout. The issue: buildDocker() calls determineDockerExportMode() which checks the environment variable and CLI flags, but writeSBOM() only checked p.Config.ExportToCache (which is nil when not explicitly set in BUILD.yaml). This fix makes writeSBOM() use the same precedence logic: 1. Package config (if explicitly set) 2. Environment variable (LEEWAY_DOCKER_EXPORT_TO_CACHE) 3. CLI flag (--docker-export-to-cache) Additionally, for the Docker daemon path, explicitly configure syft to use the 'docker' source provider to avoid ambiguity when the image tag is a content hash. Fixes the CI failure in gitpod-next PR #11869 where SLSA is enabled via workflow but SBOM generation was trying to scan from Docker daemon instead of the OCI layout. Co-authored-by: Ona <no-reply@ona.com>
d998ff0 to
4ee4883
Compare
Add TestDockerPackage_SBOM_EnvVar_Integration to verify SBOM generation correctly respects LEEWAY_DOCKER_EXPORT_TO_CACHE environment variable when package config doesn't explicitly set exportToCache. This test validates the scenario where SLSA is enabled by setting the environment variable (e.g., via workflow) but the package BUILD.yaml doesn't have exportToCache configured. The fix ensures SBOM generation uses the OCI layout path instead of incorrectly falling back to the Docker daemon path. The test: - Sets LEEWAY_DOCKER_EXPORT_TO_CACHE=true (simulating SLSA workflow) - Creates a Docker package WITHOUT exportToCache in config - Verifies SBOM generation uses OCI layout (confirmed by logs) - Validates all 3 SBOM formats are generated correctly Co-authored-by: Ona <no-reply@ona.com>
6d481e8 to
cba003b
Compare
| // extractTar extracts a tar archive to a destination directory | ||
| func extractTar(tarPath, destDir string) error { | ||
| file, err := os.Open(tarPath) | ||
| if err != nil { | ||
| return fmt.Errorf("opening tar file: %w", err) | ||
| } | ||
| defer file.Close() | ||
|
|
||
| tarReader := tar.NewReader(file) | ||
|
|
||
| for { | ||
| header, err := tarReader.Next() | ||
| if err == io.EOF { | ||
| break | ||
| } | ||
| if err != nil { | ||
| return fmt.Errorf("reading tar: %w", err) | ||
| } | ||
|
|
||
| target := filepath.Join(destDir, header.Name) | ||
|
|
||
| // Ensure the target is within destDir (security check) | ||
| if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(destDir)) { | ||
| return fmt.Errorf("illegal file path in tar: %s", header.Name) | ||
| } | ||
|
|
||
| switch header.Typeflag { | ||
| case tar.TypeDir: | ||
| if err := os.MkdirAll(target, 0755); err != nil { | ||
| return fmt.Errorf("creating directory: %w", err) | ||
| } | ||
| case tar.TypeReg: | ||
| // Create parent directories | ||
| if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { | ||
| return fmt.Errorf("creating parent directory: %w", err) | ||
| } | ||
|
|
||
| // Create file | ||
| outFile, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode)) | ||
| if err != nil { | ||
| return fmt.Errorf("creating file: %w", err) | ||
| } | ||
|
|
||
| if _, err := io.Copy(outFile, tarReader); err != nil { | ||
| outFile.Close() | ||
| return fmt.Errorf("writing file: %w", err) | ||
| } | ||
| outFile.Close() | ||
| } | ||
| } | ||
|
|
||
| return nil | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We already have the function func extractTarToDir(r io.Reader, destDir string) error. Can we re-use this instead of introducing a nearly identical one?
file, err := os.Open(tarPath)
if err != nil {
return fmt.Errorf("opening tar file: %w", err)
}
defer file.Close()
extractTarToDir(file, destDir)| // Determine if OCI export is enabled using the same logic as buildDocker | ||
| // Check: package config > environment variable > default | ||
| exportToCache := false | ||
| if cfg.ExportToCache != nil { | ||
| // Package explicitly sets exportToCache | ||
| exportToCache = *cfg.ExportToCache | ||
| } else { | ||
| // Check environment variable (set by SLSA or user) | ||
| envExport := os.Getenv(EnvvarDockerExportToCache) | ||
| exportToCache = (envExport == "true" || envExport == "1") | ||
| } | ||
|
|
||
| // Override with CLI flag if set | ||
| if buildctx.DockerExportSet { | ||
| exportToCache = buildctx.DockerExportToCache | ||
| } | ||
|
|
||
| // Check if OCI layout export is enabled | ||
| if cfg.ExportToCache != nil && *cfg.ExportToCache { | ||
| if exportToCache { | ||
| // OCI layout path - scan from oci-archive |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can't we call func determineDockerExportMode(p *Package, cfg *DockerPkgConfig, buildctx *buildContext) here instead?
| // Determine if OCI export is enabled using the same logic as buildDocker | |
| // Check: package config > environment variable > default | |
| exportToCache := false | |
| if cfg.ExportToCache != nil { | |
| // Package explicitly sets exportToCache | |
| exportToCache = *cfg.ExportToCache | |
| } else { | |
| // Check environment variable (set by SLSA or user) | |
| envExport := os.Getenv(EnvvarDockerExportToCache) | |
| exportToCache = (envExport == "true" || envExport == "1") | |
| } | |
| // Override with CLI flag if set | |
| if buildctx.DockerExportSet { | |
| exportToCache = buildctx.DockerExportToCache | |
| } | |
| // Check if OCI layout export is enabled | |
| if cfg.ExportToCache != nil && *cfg.ExportToCache { | |
| if exportToCache { | |
| // OCI layout path - scan from oci-archive | |
| // Use the same logic as buildDocker to determine export mode | |
| determineDockerExportMode(p, &cfg, buildctx) | |
| // Check if OCI layout export is enabled | |
| if cfg.ExportToCache != nil && *cfg.ExportToCache { | |
| // OCI layout path - scan from oci-archive |
This is exactly the same pattern used in buildDocker. The function mutates cfg.ExportToCache to hold the final resolved value, and then the code checks that value.
Why?
The sbom.go code in the PR implements a simplified 3-layer precedence:
- Package config (
cfg.ExportToCache) - Environment variable (
LEEWAY_DOCKER_EXPORT_TO_CACHE) - CLI flag (
buildctx.DockerExportSet)
But determineDockerExportMode in build.go has a more sophisticated 5-layer precedence:
- CLI flag (
buildctx.DockerExportSet) - User environment variable (
buildctx.DockerExportEnvSet) - Package config (
cfg.ExportToCache) - Workspace default (auto-set by
provenance.slsa: true) - Global default (false)
Key differences:
- The order is different (SBOM checks package config first, then env var, then CLI; build.go checks CLI first)
- SBOM code doesn't distinguish between "user env var" and "workspace default" (layers 2 vs 4)
- SBOM code doesn't use
buildctx.DockerExportEnvSet/buildctx.DockerExportEnvValue
This is a real bug risk. The SBOM generation could make a different decision than the build about whether OCI export is enabled, leading to inconsistent behavior.
Summary
Fixes the bug where container extraction fails with "No such image" error when
exportToCache=true(SLSA enabled) and the Docker package has noimage:config.Part of https://linear.app/ona-team/issue/CLC-2009/docker-export-mode-for-slsa-l3-compliance-leeway
Problem
When a Docker package:
image:config (not pushed to registry)exportToCache=true(SLSA L3 enabled)The build creates
image.tarwith OCI layout but extraction tries to get the image from Docker daemon, which fails because the image was never loaded into the daemon.Error:
Affected: monorepo CI -
runner/shared/openssh:dockerpackage buildSolution
Modified
extractImageWithOCILibsImpl()to:image.tarexists (created whenexportToCache=true)Changes
pkg/leeway/container_image.gogithub.com/google/go-containerregistry/pkg/v1/layoutextractImageWithOCILibsImpl()to handle OCI tar extractionextractTar()helper functionpkg/leeway/build_test.goinit()that was hiding bugssetupMockForUnitTests()helper for explicit mockingpkg/leeway/build_integration_test.goTestDockerPackage_OCIExtraction_NoImage_IntegrationTesting
New Integration Test
go test -tags=integration -v ./pkg/leeway \ -run TestDockerPackage_OCIExtraction_NoImage_Integration✅ PASS - Verifies extraction works from OCI tar
Existing Tests
TestDockerPackage_ContainerExtraction_Integration- PASSTestDockerPackage_OCILayout_Determinism_Integration- PASS (determinism preserved)Manual Testing
Tested with monorepo
runner/shared/openssh:docker:SLSA L3 Compatibility
✅ Determinism preserved - OCI layout is unchanged
✅ Cache structure unchanged - Only affects extraction
✅ Provenance unaffected - Generated before extraction
✅ Backward compatible - Falls back to daemon when OCI tar doesn't exist
Why This Wasn't Caught Before
The integration tests on main use a global mock in
init()that intercepts ALL extraction calls, including in integration tests. This means tests passed but the real extraction code was never tested.Evidence: Test output shows
msg="Mock: Extracting container filesystem"This PR removes the global mock and makes it opt-in, so integration tests now test real code.
Checklist
Related
Co-authored-by: Ona no-reply@ona.com