Skip to content

Commit 46d9d00

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 <no-reply@ona.com>
1 parent 07d62e1 commit 46d9d00

File tree

1 file changed

+196
-61
lines changed

1 file changed

+196
-61
lines changed

pkg/leeway/build.go

Lines changed: 196 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")
1912-
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-
}
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")
19241937

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,118 @@ func extractImageNameFromCache(pkgName, cacheBundleFN string) (imgname string, e
20332056
return "", nil
20342057
}
20352058

2059+
// createDockerSubjectsFunction creates a function that generates SLSA provenance subjects for Docker images
2060+
func createDockerSubjectsFunction(version string, cfg DockerPkgConfig) func() ([]in_toto.Subject, error) {
2061+
return func() ([]in_toto.Subject, error) {
2062+
subjectLogger := log.WithField("operation", "provenance-subjects")
2063+
subjectLogger.Debug("Calculating provenance subjects for Docker images")
2064+
2065+
// Get image digest with improved error handling
2066+
out, err := exec.Command("docker", "inspect", version).CombinedOutput()
2067+
if err != nil {
2068+
return nil, xerrors.Errorf("failed to inspect image %s: %w\nOutput: %s",
2069+
version, err, string(out))
2070+
}
2071+
2072+
var inspectRes []struct {
2073+
ID string `json:"Id"`
2074+
RepoDigests []string `json:"RepoDigests"`
2075+
}
2076+
2077+
if err := json.Unmarshal(out, &inspectRes); err != nil {
2078+
return nil, xerrors.Errorf("cannot unmarshal Docker inspect response: %w", err)
2079+
}
2080+
2081+
if len(inspectRes) == 0 {
2082+
return nil, xerrors.Errorf("docker inspect returned empty result for image %s", version)
2083+
}
2084+
2085+
// Try to get digest from ID first (most reliable)
2086+
var digest common.DigestSet
2087+
if inspectRes[0].ID != "" {
2088+
segs := strings.Split(inspectRes[0].ID, ":")
2089+
if len(segs) == 2 {
2090+
digest = common.DigestSet{
2091+
segs[0]: segs[1],
2092+
}
2093+
}
2094+
}
2095+
2096+
// If we couldn't get digest from ID, try RepoDigests as fallback
2097+
if len(digest) == 0 && len(inspectRes[0].RepoDigests) > 0 {
2098+
for _, repoDigest := range inspectRes[0].RepoDigests {
2099+
parts := strings.Split(repoDigest, "@")
2100+
if len(parts) == 2 {
2101+
digestParts := strings.Split(parts[1], ":")
2102+
if len(digestParts) == 2 {
2103+
digest = common.DigestSet{
2104+
digestParts[0]: digestParts[1],
2105+
}
2106+
break
2107+
}
2108+
}
2109+
}
2110+
}
2111+
2112+
if len(digest) == 0 {
2113+
return nil, xerrors.Errorf("could not determine digest for image %s", version)
2114+
}
2115+
2116+
subjectLogger.WithField("digest", digest).Debug("Found image digest")
2117+
2118+
// Create subjects for each image
2119+
result := make([]in_toto.Subject, 0, len(cfg.Image))
2120+
for _, tag := range cfg.Image {
2121+
result = append(result, in_toto.Subject{
2122+
Name: tag,
2123+
Digest: digest,
2124+
})
2125+
}
2126+
2127+
return result, nil
2128+
}
2129+
}
2130+
2131+
// DockerImageMetadata holds metadata for exported Docker images
2132+
type DockerImageMetadata struct {
2133+
ImageNames []string `json:"image_names" yaml:"image_names"`
2134+
BuiltVersion string `json:"built_version" yaml:"built_version"`
2135+
Digest string `json:"digest,omitempty" yaml:"digest,omitempty"`
2136+
BuildTime time.Time `json:"build_time" yaml:"build_time"`
2137+
CustomMeta map[string]string `json:"custom_metadata,omitempty" yaml:"custom_metadata,omitempty"`
2138+
}
2139+
2140+
// createDockerExportMetadata creates metadata file for exported Docker images
2141+
func createDockerExportMetadata(wd, version string, cfg DockerPkgConfig) error {
2142+
metadata := DockerImageMetadata{
2143+
ImageNames: cfg.Image,
2144+
BuiltVersion: version,
2145+
BuildTime: time.Now(),
2146+
CustomMeta: cfg.Metadata,
2147+
}
2148+
2149+
// Try to get image digest if available
2150+
inspectCmd := exec.Command("docker", "inspect", "--format={{index .Id}}", version)
2151+
if output, err := inspectCmd.Output(); err == nil {
2152+
metadata.Digest = strings.TrimSpace(string(output))
2153+
}
2154+
2155+
// Write as JSON for easy parsing
2156+
metadataBytes, err := json.MarshalIndent(metadata, "", " ")
2157+
if err != nil {
2158+
return fmt.Errorf("failed to marshal Docker metadata: %w", err)
2159+
}
2160+
2161+
metadataPath := filepath.Join(wd, "docker-export-metadata.json")
2162+
if err := os.WriteFile(metadataPath, metadataBytes, 0644); err != nil {
2163+
return fmt.Errorf("failed to write Docker metadata: %w", err)
2164+
}
2165+
2166+
log.WithField("path", metadataPath).Debug("Created Docker export metadata")
2167+
return nil
2168+
}
2169+
2170+
20362171
// Update buildGeneric to use compression arg helper
20372172
func (p *Package) buildGeneric(buildctx *buildContext, wd, result string) (res *packageBuild, err error) {
20382173
cfg, ok := p.Config.(GenericPkgConfig)

0 commit comments

Comments
 (0)