Skip to content

Commit 0a6e8b8

Browse files
leodidoona-agent
andcommitted
feat(build): implement Docker image export to cache
Add new export-to-cache path for Docker packages that exports images as tar files instead of pushing directly to registries. Changes: - Add DockerExportToCache and DockerExportSet to buildOptions - Add WithDockerExportToCache BuildOption - Implement export logic in buildDocker function - Branch on exportToCache flag: legacy push vs new export - Export images to image.tar using 'docker save' - Package tar with metadata into cache artifact - Add override logic with proper precedence (CLI > env > config) - Enhanced logging with structured fields Export mode packages include: - image.tar (full Docker image) - imgnames.txt (image tags) - docker-export-metadata.json (structured metadata) - metadata.yaml (custom metadata if present) - Optional: provenance and SBOM files This enables Docker images to go through the same cache + signing flow as other artifacts, closing the SLSA L3 security gap. Co-authored-by: Ona <[email protected]>
1 parent b892734 commit 0a6e8b8

File tree

1 file changed

+257
-61
lines changed

1 file changed

+257
-61
lines changed

pkg/leeway/build.go

Lines changed: 257 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,8 @@ type buildOptions struct {
392392
UseFixedBuildDir bool
393393
DisableCoverage bool
394394
InFlightChecksums bool
395+
DockerExportToCache bool
396+
DockerExportSet bool // Track if explicitly set via CLI flag or env var
395397

396398
context *buildContext
397399
}
@@ -515,6 +517,15 @@ func WithInFlightChecksums(enabled bool) BuildOption {
515517
}
516518
}
517519

520+
// WithDockerExportToCache configures whether Docker images should be exported to cache
521+
func WithDockerExportToCache(exportToCache bool, explicitlySet bool) BuildOption {
522+
return func(opts *buildOptions) error {
523+
opts.DockerExportToCache = exportToCache
524+
opts.DockerExportSet = explicitlySet
525+
return nil
526+
}
527+
}
528+
518529
func withBuildContext(ctx *buildContext) BuildOption {
519530
return func(opts *buildOptions) error {
520531
opts.context = ctx
@@ -1690,6 +1701,19 @@ func (p *Package) buildDocker(buildctx *buildContext, wd, result string) (res *p
16901701
return nil, err
16911702
}
16921703

1704+
// Apply global export mode setting from build options (CLI flag or env var)
1705+
// This overrides the package-level configuration in BOTH directions
1706+
if buildctx.DockerExportSet {
1707+
if cfg.ExportToCache != buildctx.DockerExportToCache {
1708+
log.WithField("package", p.FullName()).
1709+
WithField("package_config", cfg.ExportToCache).
1710+
WithField("override_value", buildctx.DockerExportToCache).
1711+
Info("Docker export mode overridden via CLI flag or environment variable")
1712+
}
1713+
cfg.ExportToCache = buildctx.DockerExportToCache
1714+
}
1715+
// else: respect package config (no override)
1716+
16931717
var (
16941718
commands = make(map[PackageBuildPhase][][]string)
16951719
imageDependencies = make(map[string]string)
@@ -1850,9 +1874,9 @@ func (p *Package) buildDocker(buildctx *buildContext, wd, result string) (res *p
18501874
))
18511875

18521876
commands[PackageBuildPhasePackage] = pkgcmds
1853-
} else if len(cfg.Image) > 0 {
1877+
} else if len(cfg.Image) > 0 && !cfg.ExportToCache {
18541878
// Image push workflow
1855-
log.WithField("images", cfg.Image).Debug("configuring image push")
1879+
log.WithField("images", cfg.Image).Debug("configuring image push (legacy behavior)")
18561880

18571881
for _, img := range cfg.Image {
18581882
pkgCommands = append(pkgCommands, [][]string{
@@ -1905,75 +1929,74 @@ func (p *Package) buildDocker(buildctx *buildContext, wd, result string) (res *p
19051929
Commands: commands,
19061930
}
19071931

1908-
// Enhanced subjects function with better error handling and logging
1909-
res.Subjects = func() ([]in_toto.Subject, error) {
1910-
subjectLogger := log.WithField("operation", "provenance-subjects")
1911-
subjectLogger.Debug("Calculating provenance subjects for pushed images")
1932+
// Add subjects function for provenance generation
1933+
res.Subjects = createDockerSubjectsFunction(version, cfg)
1934+
} else if len(cfg.Image) > 0 && cfg.ExportToCache {
1935+
// Export to cache for signing
1936+
log.WithField("package", p.FullName()).Debug("Exporting Docker image to cache")
19121937

1913-
// Get image digest with improved error handling
1914-
out, err := exec.Command("docker", "inspect", version).CombinedOutput()
1915-
if err != nil {
1916-
return nil, xerrors.Errorf("failed to inspect image %s: %w\nOutput: %s",
1917-
version, err, string(out))
1918-
}
1919-
1920-
var inspectRes []struct {
1921-
ID string `json:"Id"`
1922-
RepoDigests []string `json:"RepoDigests"`
1923-
}
1924-
1925-
if err := json.Unmarshal(out, &inspectRes); err != nil {
1926-
return nil, xerrors.Errorf("cannot unmarshal Docker inspect response: %w", err)
1927-
}
1938+
// Export the image to tar
1939+
imageTarPath := filepath.Join(wd, "image.tar")
1940+
pkgCommands = append(pkgCommands,
1941+
[]string{"docker", "save", version, "-o", imageTarPath},
1942+
)
19281943

1929-
if len(inspectRes) == 0 {
1930-
return nil, xerrors.Errorf("docker inspect returned empty result for image %s", version)
1931-
}
1944+
// Store image names for later use
1945+
for _, img := range cfg.Image {
1946+
pkgCommands = append(pkgCommands,
1947+
[]string{"sh", "-c", fmt.Sprintf("echo %s >> %s", img, dockerImageNamesFiles)},
1948+
)
1949+
}
19321950

1933-
// Try to get digest from ID first (most reliable)
1934-
var digest common.DigestSet
1935-
if inspectRes[0].ID != "" {
1936-
segs := strings.Split(inspectRes[0].ID, ":")
1937-
if len(segs) == 2 {
1938-
digest = common.DigestSet{
1939-
segs[0]: segs[1],
1940-
}
1941-
}
1951+
// Add metadata file
1952+
if len(cfg.Metadata) > 0 {
1953+
metadataContent, err := yaml.Marshal(cfg.Metadata)
1954+
if err != nil {
1955+
return nil, xerrors.Errorf("failed to marshal metadata: %w", err)
19421956
}
1957+
encodedMetadata := base64.StdEncoding.EncodeToString(metadataContent)
1958+
pkgCommands = append(pkgCommands,
1959+
[]string{"sh", "-c", fmt.Sprintf("echo %s | base64 -d > %s", encodedMetadata, dockerMetadataFile)},
1960+
)
1961+
}
19431962

1944-
// If we couldn't get digest from ID, try RepoDigests as fallback
1945-
if len(digest) == 0 && len(inspectRes[0].RepoDigests) > 0 {
1946-
for _, repoDigest := range inspectRes[0].RepoDigests {
1947-
parts := strings.Split(repoDigest, "@")
1948-
if len(parts) == 2 {
1949-
digestParts := strings.Split(parts[1], ":")
1950-
if len(digestParts) == 2 {
1951-
digest = common.DigestSet{
1952-
digestParts[0]: digestParts[1],
1953-
}
1954-
break
1955-
}
1956-
}
1957-
}
1958-
}
1963+
// Package everything into final tar.gz
1964+
sourcePaths := []string{"./image.tar", fmt.Sprintf("./%s", dockerImageNamesFiles), "./docker-export-metadata.json"}
1965+
if len(cfg.Metadata) > 0 {
1966+
sourcePaths = append(sourcePaths, fmt.Sprintf("./%s", dockerMetadataFile))
1967+
}
1968+
if p.C.W.Provenance.Enabled {
1969+
sourcePaths = append(sourcePaths, fmt.Sprintf("./%s", provenanceBundleFilename))
1970+
}
1971+
if p.C.W.SBOM.Enabled {
1972+
sourcePaths = append(sourcePaths,
1973+
fmt.Sprintf("./%s", sbomBaseFilename+sbomCycloneDXFileExtension),
1974+
fmt.Sprintf("./%s", sbomBaseFilename+sbomSPDXFileExtension),
1975+
fmt.Sprintf("./%s", sbomBaseFilename+sbomSyftFileExtension),
1976+
)
1977+
}
19591978

1960-
if len(digest) == 0 {
1961-
return nil, xerrors.Errorf("could not determine digest for image %s", version)
1962-
}
1979+
archiveCmd := BuildTarCommand(
1980+
WithOutputFile(result),
1981+
WithSourcePaths(sourcePaths...),
1982+
WithCompression(!buildctx.DontCompress),
1983+
)
1984+
pkgCommands = append(pkgCommands, archiveCmd)
19631985

1964-
subjectLogger.WithField("digest", digest).Debug("Found image digest")
1986+
commands[PackageBuildPhasePackage] = pkgCommands
19651987

1966-
// Create subjects for each image
1967-
result := make([]in_toto.Subject, 0, len(cfg.Image))
1968-
for _, tag := range cfg.Image {
1969-
result = append(result, in_toto.Subject{
1970-
Name: tag,
1971-
Digest: digest,
1972-
})
1973-
}
1988+
// Initialize res with commands
1989+
res = &packageBuild{
1990+
Commands: commands,
1991+
}
19741992

1975-
return result, nil
1993+
// Add PostProcess to create structured metadata file
1994+
res.PostProcess = func(buildCtx *buildContext, pkg *Package, buildDir string) error {
1995+
return createDockerExportMetadata(buildDir, version, cfg)
19761996
}
1997+
1998+
// Add subjects function for provenance generation
1999+
res.Subjects = createDockerSubjectsFunction(version, cfg)
19772000
}
19782001

19792002
return res, nil
@@ -2033,6 +2056,179 @@ func extractImageNameFromCache(pkgName, cacheBundleFN string) (imgname string, e
20332056
return "", nil
20342057
}
20352058

2059+
// humanReadableSize converts bytes to human-readable format
2060+
func humanReadableSize(bytes int64) string {
2061+
const unit = 1024
2062+
if bytes < unit {
2063+
return fmt.Sprintf("%d B", bytes)
2064+
}
2065+
div, exp := int64(unit), 0
2066+
for n := bytes / unit; n >= unit; n /= unit {
2067+
div *= unit
2068+
exp++
2069+
}
2070+
return fmt.Sprintf("%.1f %ciB", float64(bytes)/float64(div), "KMGTPE"[exp])
2071+
}
2072+
2073+
// createDockerSubjectsFunction creates a function that generates SLSA provenance subjects for Docker images
2074+
func createDockerSubjectsFunction(version string, cfg DockerPkgConfig) func() ([]in_toto.Subject, error) {
2075+
return func() ([]in_toto.Subject, error) {
2076+
subjectLogger := log.WithField("operation", "provenance-subjects")
2077+
subjectLogger.Debug("Calculating provenance subjects for Docker images")
2078+
2079+
// Get image digest with improved error handling
2080+
out, err := exec.Command("docker", "inspect", version).CombinedOutput()
2081+
if err != nil {
2082+
return nil, xerrors.Errorf("failed to inspect image %s: %w\nOutput: %s",
2083+
version, err, string(out))
2084+
}
2085+
2086+
var inspectRes []struct {
2087+
ID string `json:"Id"`
2088+
RepoDigests []string `json:"RepoDigests"`
2089+
}
2090+
2091+
if err := json.Unmarshal(out, &inspectRes); err != nil {
2092+
return nil, xerrors.Errorf("cannot unmarshal Docker inspect response: %w", err)
2093+
}
2094+
2095+
if len(inspectRes) == 0 {
2096+
return nil, xerrors.Errorf("docker inspect returned empty result for image %s", version)
2097+
}
2098+
2099+
// Try to get digest from ID first (most reliable)
2100+
var digest common.DigestSet
2101+
if inspectRes[0].ID != "" {
2102+
segs := strings.Split(inspectRes[0].ID, ":")
2103+
if len(segs) == 2 {
2104+
digest = common.DigestSet{
2105+
segs[0]: segs[1],
2106+
}
2107+
}
2108+
}
2109+
2110+
// If we couldn't get digest from ID, try RepoDigests as fallback
2111+
if len(digest) == 0 && len(inspectRes[0].RepoDigests) > 0 {
2112+
for _, repoDigest := range inspectRes[0].RepoDigests {
2113+
parts := strings.Split(repoDigest, "@")
2114+
if len(parts) == 2 {
2115+
digestParts := strings.Split(parts[1], ":")
2116+
if len(digestParts) == 2 {
2117+
digest = common.DigestSet{
2118+
digestParts[0]: digestParts[1],
2119+
}
2120+
break
2121+
}
2122+
}
2123+
}
2124+
}
2125+
2126+
if len(digest) == 0 {
2127+
return nil, xerrors.Errorf("could not determine digest for image %s", version)
2128+
}
2129+
2130+
subjectLogger.WithField("digest", digest).Debug("Found image digest")
2131+
2132+
// Create subjects for each image
2133+
result := make([]in_toto.Subject, 0, len(cfg.Image))
2134+
for _, tag := range cfg.Image {
2135+
result = append(result, in_toto.Subject{
2136+
Name: tag,
2137+
Digest: digest,
2138+
})
2139+
}
2140+
2141+
return result, nil
2142+
}
2143+
}
2144+
2145+
// DockerImageMetadata holds metadata for exported Docker images
2146+
type DockerImageMetadata struct {
2147+
ImageNames []string `json:"image_names" yaml:"image_names"`
2148+
BuiltVersion string `json:"built_version" yaml:"built_version"`
2149+
Digest string `json:"digest,omitempty" yaml:"digest,omitempty"`
2150+
BuildTime time.Time `json:"build_time" yaml:"build_time"`
2151+
CustomMeta map[string]string `json:"custom_metadata,omitempty" yaml:"custom_metadata,omitempty"`
2152+
}
2153+
2154+
// createDockerExportMetadata creates metadata file for exported Docker images
2155+
func createDockerExportMetadata(wd, version string, cfg DockerPkgConfig) error {
2156+
metadata := DockerImageMetadata{
2157+
ImageNames: cfg.Image,
2158+
BuiltVersion: version,
2159+
BuildTime: time.Now(),
2160+
CustomMeta: cfg.Metadata,
2161+
}
2162+
2163+
// Try to get image digest if available
2164+
inspectCmd := exec.Command("docker", "inspect", "--format={{index .Id}}", version)
2165+
if output, err := inspectCmd.Output(); err == nil {
2166+
metadata.Digest = strings.TrimSpace(string(output))
2167+
}
2168+
2169+
// Write as JSON for easy parsing
2170+
metadataBytes, err := json.MarshalIndent(metadata, "", " ")
2171+
if err != nil {
2172+
return fmt.Errorf("failed to marshal Docker metadata: %w", err)
2173+
}
2174+
2175+
metadataPath := filepath.Join(wd, "docker-export-metadata.json")
2176+
if err := os.WriteFile(metadataPath, metadataBytes, 0644); err != nil {
2177+
return fmt.Errorf("failed to write Docker metadata: %w", err)
2178+
}
2179+
2180+
log.WithField("path", metadataPath).Debug("Created Docker export metadata")
2181+
return nil
2182+
}
2183+
2184+
// extractDockerMetadataFromCache extracts Docker image metadata from a cached package
2185+
func extractDockerMetadataFromCache(cacheBundleFN string) (*DockerImageMetadata, error) {
2186+
f, err := os.Open(cacheBundleFN)
2187+
if err != nil {
2188+
return nil, fmt.Errorf("failed to open cache bundle: %w", err)
2189+
}
2190+
defer f.Close()
2191+
2192+
gzin, err := gzip.NewReader(f)
2193+
if err != nil {
2194+
return nil, fmt.Errorf("failed to create gzip reader: %w", err)
2195+
}
2196+
defer gzin.Close()
2197+
2198+
tarin := tar.NewReader(gzin)
2199+
for {
2200+
hdr, err := tarin.Next()
2201+
if errors.Is(err, io.EOF) {
2202+
break
2203+
}
2204+
if err != nil {
2205+
return nil, fmt.Errorf("failed to read tar: %w", err)
2206+
}
2207+
2208+
if hdr.Typeflag != tar.TypeReg {
2209+
continue
2210+
}
2211+
2212+
if filepath.Base(hdr.Name) != "docker-export-metadata.json" {
2213+
continue
2214+
}
2215+
2216+
metadataBytes := make([]byte, hdr.Size)
2217+
if _, err := io.ReadFull(tarin, metadataBytes); err != nil {
2218+
return nil, fmt.Errorf("failed to read metadata: %w", err)
2219+
}
2220+
2221+
var metadata DockerImageMetadata
2222+
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
2223+
return nil, fmt.Errorf("failed to unmarshal metadata: %w", err)
2224+
}
2225+
2226+
return &metadata, nil
2227+
}
2228+
2229+
return nil, fmt.Errorf("docker-export-metadata.json not found in cache bundle")
2230+
}
2231+
20362232
// Update buildGeneric to use compression arg helper
20372233
func (p *Package) buildGeneric(buildctx *buildContext, wd, result string) (res *packageBuild, err error) {
20382234
cfg, ok := p.Config.(GenericPkgConfig)

0 commit comments

Comments
 (0)