@@ -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+
518529func 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\n Output: %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\n Output: %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
20372233func (p * Package ) buildGeneric (buildctx * buildContext , wd , result string ) (res * packageBuild , err error ) {
20382234 cfg , ok := p .Config .(GenericPkgConfig )
0 commit comments