@@ -392,6 +392,8 @@ type buildOptions struct {
392
392
UseFixedBuildDir bool
393
393
DisableCoverage bool
394
394
InFlightChecksums bool
395
+ DockerExportToCache bool
396
+ DockerExportSet bool // Track if explicitly set via CLI flag or env var
395
397
396
398
context * buildContext
397
399
}
@@ -515,6 +517,15 @@ func WithInFlightChecksums(enabled bool) BuildOption {
515
517
}
516
518
}
517
519
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
+
518
529
func withBuildContext (ctx * buildContext ) BuildOption {
519
530
return func (opts * buildOptions ) error {
520
531
opts .context = ctx
@@ -1690,6 +1701,19 @@ func (p *Package) buildDocker(buildctx *buildContext, wd, result string) (res *p
1690
1701
return nil , err
1691
1702
}
1692
1703
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
+
1693
1717
var (
1694
1718
commands = make (map [PackageBuildPhase ][][]string )
1695
1719
imageDependencies = make (map [string ]string )
@@ -1850,9 +1874,9 @@ func (p *Package) buildDocker(buildctx *buildContext, wd, result string) (res *p
1850
1874
))
1851
1875
1852
1876
commands [PackageBuildPhasePackage ] = pkgcmds
1853
- } else if len (cfg .Image ) > 0 {
1877
+ } else if len (cfg .Image ) > 0 && ! cfg . ExportToCache {
1854
1878
// 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) " )
1856
1880
1857
1881
for _ , img := range cfg .Image {
1858
1882
pkgCommands = append (pkgCommands , [][]string {
@@ -1905,75 +1929,74 @@ func (p *Package) buildDocker(buildctx *buildContext, wd, result string) (res *p
1905
1929
Commands : commands ,
1906
1930
}
1907
1931
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" )
1912
1937
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
+ )
1928
1943
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
+ }
1932
1950
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 )
1942
1956
}
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
+ }
1943
1962
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
+ }
1959
1978
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 )
1963
1985
1964
- subjectLogger . WithField ( "digest" , digest ). Debug ( "Found image digest" )
1986
+ commands [ PackageBuildPhasePackage ] = pkgCommands
1965
1987
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
+ }
1974
1992
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 )
1976
1996
}
1997
+
1998
+ // Add subjects function for provenance generation
1999
+ res .Subjects = createDockerSubjectsFunction (version , cfg )
1977
2000
}
1978
2001
1979
2002
return res , nil
@@ -2033,6 +2056,179 @@ func extractImageNameFromCache(pkgName, cacheBundleFN string) (imgname string, e
2033
2056
return "" , nil
2034
2057
}
2035
2058
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
+
2036
2232
// Update buildGeneric to use compression arg helper
2037
2233
func (p * Package ) buildGeneric (buildctx * buildContext , wd , result string ) (res * packageBuild , err error ) {
2038
2234
cfg , ok := p .Config .(GenericPkgConfig )
0 commit comments