diff --git a/README.md b/README.md index 67026dc..700a739 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,11 @@ config: config: # Dockerfile is the name of the Dockerfile to build. Automatically added to the package sources. dockerfile: "Dockerfile" + # exportToCache controls whether images are pushed directly or exported to cache + # - false (default): push directly to registry (legacy behavior) + # - true: export to cache for signing (enables SLSA L3 compliance) + # Can be overridden via --docker-export-to-cache flag or LEEWAY_DOCKER_EXPORT_TO_CACHE env var + exportToCache: false # Metadata produces a metadata.yaml file in the resulting package tarball. metadata: foo: bar @@ -191,6 +196,12 @@ The name of this build argument is the package name of the dependency, transform E.g. `component/nested:docker` becomes `COMPONENT_NESTED__DOCKER`. +**For SLSA Level 3 compliance:** Set `exportToCache: true` to enable cache-based Docker image distribution with cryptographic signing. This can be overridden globally using: +- CLI flag: `leeway build --docker-export-to-cache` +- Environment variable: `LEEWAY_DOCKER_EXPORT_TO_CACHE=true` + +See `leeway build --help` for more details. + ### Generic packages ```YAML config: diff --git a/cmd/build.go b/cmd/build.go index 9222c84..226bd35 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -24,7 +24,24 @@ import ( var buildCmd = &cobra.Command{ Use: "build [targetPackage]", Short: "Builds a package", - Args: cobra.MaximumNArgs(1), + Long: `Builds a package and all its dependencies. + +Docker Export Mode: + By default, Docker packages with 'image' configuration push directly to registries. + Use --docker-export-to-cache to export images to cache instead (enables SLSA L3). + + The LEEWAY_DOCKER_EXPORT_TO_CACHE environment variable sets the default for the flag. + +Examples: + # Build with Docker export mode enabled (CLI flag) + leeway build --docker-export-to-cache :myapp + + # Build with Docker export mode enabled (environment variable) + LEEWAY_DOCKER_EXPORT_TO_CACHE=true leeway build :myapp + + # Disable export mode even if env var is set + leeway build --docker-export-to-cache=false :myapp`, + Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { _, pkg, _, _ := getTarget(args, false) if pkg == nil { @@ -190,6 +207,7 @@ func addBuildFlags(cmd *cobra.Command) { cmd.Flags().String("report-segment", os.Getenv("LEEWAY_SEGMENT_KEY"), "Report build events to segment using the segment key (defaults to $LEEWAY_SEGMENT_KEY)") cmd.Flags().Bool("report-github", os.Getenv("GITHUB_OUTPUT") != "", "Report package build success/failure to GitHub Actions using the GITHUB_OUTPUT environment variable") cmd.Flags().Bool("fixed-build-dir", true, "Use a fixed build directory for each package, instead of based on the package version, to better utilize caches based on absolute paths (defaults to true)") + cmd.Flags().Bool("docker-export-to-cache", false, "Export Docker images to cache instead of pushing directly (enables SLSA L3 compliance)") } func getBuildOpts(cmd *cobra.Command) ([]leeway.BuildOption, cache.LocalCache) { @@ -330,6 +348,26 @@ func getBuildOpts(cmd *cobra.Command) ([]leeway.BuildOption, cache.LocalCache) { inFlightChecksums = inFlightChecksumsDefault } + // Get docker export to cache setting with proper precedence: + // 1. CLI flag (if explicitly set) + // 2. Environment variable (if set) + // 3. Package config (default) + dockerExportToCache := false + dockerExportSet := false + + if cmd.Flags().Changed("docker-export-to-cache") { + // Flag was explicitly set by user - this takes precedence + dockerExportToCache, err = cmd.Flags().GetBool("docker-export-to-cache") + if err != nil { + log.Fatal(err) + } + dockerExportSet = true + } else if envVal := os.Getenv("LEEWAY_DOCKER_EXPORT_TO_CACHE"); envVal != "" { + // Env var set (flag not set) - env var takes precedence over package config + dockerExportToCache = envVal == "true" || envVal == "1" + dockerExportSet = true + } + return []leeway.BuildOption{ leeway.WithLocalCache(localCache), leeway.WithRemoteCache(remoteCache), @@ -345,6 +383,7 @@ func getBuildOpts(cmd *cobra.Command) ([]leeway.BuildOption, cache.LocalCache) { leeway.WithFixedBuildDir(fixedBuildDir), leeway.WithDisableCoverage(disableCoverage), leeway.WithInFlightChecksums(inFlightChecksums), + leeway.WithDockerExportToCache(dockerExportToCache, dockerExportSet), }, localCache } diff --git a/pkg/leeway/build.go b/pkg/leeway/build.go index 69adf92..01d7e4c 100644 --- a/pkg/leeway/build.go +++ b/pkg/leeway/build.go @@ -392,6 +392,8 @@ type buildOptions struct { UseFixedBuildDir bool DisableCoverage bool InFlightChecksums bool + DockerExportToCache bool + DockerExportSet bool // Track if explicitly set via CLI flag or env var context *buildContext } @@ -515,6 +517,15 @@ func WithInFlightChecksums(enabled bool) BuildOption { } } +// WithDockerExportToCache configures whether Docker images should be exported to cache +func WithDockerExportToCache(exportToCache bool, explicitlySet bool) BuildOption { + return func(opts *buildOptions) error { + opts.DockerExportToCache = exportToCache + opts.DockerExportSet = explicitlySet + return nil + } +} + func withBuildContext(ctx *buildContext) BuildOption { return func(opts *buildOptions) error { opts.context = ctx @@ -1690,6 +1701,19 @@ func (p *Package) buildDocker(buildctx *buildContext, wd, result string) (res *p return nil, err } + // Apply global export mode setting from build options (CLI flag or env var) + // This overrides the package-level configuration in BOTH directions + if buildctx.DockerExportSet { + if cfg.ExportToCache != buildctx.DockerExportToCache { + log.WithField("package", p.FullName()). + WithField("package_config", cfg.ExportToCache). + WithField("override_value", buildctx.DockerExportToCache). + Info("Docker export mode overridden via CLI flag or environment variable") + } + cfg.ExportToCache = buildctx.DockerExportToCache + } + // else: respect package config (no override) + var ( commands = make(map[PackageBuildPhase][][]string) imageDependencies = make(map[string]string) @@ -1850,9 +1874,9 @@ func (p *Package) buildDocker(buildctx *buildContext, wd, result string) (res *p )) commands[PackageBuildPhasePackage] = pkgcmds - } else if len(cfg.Image) > 0 { + } else if len(cfg.Image) > 0 && !cfg.ExportToCache { // Image push workflow - log.WithField("images", cfg.Image).Debug("configuring image push") + log.WithField("images", cfg.Image).Debug("configuring image push (legacy behavior)") for _, img := range cfg.Image { pkgCommands = append(pkgCommands, [][]string{ @@ -1905,75 +1929,74 @@ func (p *Package) buildDocker(buildctx *buildContext, wd, result string) (res *p Commands: commands, } - // Enhanced subjects function with better error handling and logging - res.Subjects = func() ([]in_toto.Subject, error) { - subjectLogger := log.WithField("operation", "provenance-subjects") - subjectLogger.Debug("Calculating provenance subjects for pushed images") + // Add subjects function for provenance generation + res.Subjects = createDockerSubjectsFunction(version, cfg) + } else if len(cfg.Image) > 0 && cfg.ExportToCache { + // Export to cache for signing + log.WithField("package", p.FullName()).Debug("Exporting Docker image to cache") - // Get image digest with improved error handling - out, err := exec.Command("docker", "inspect", version).CombinedOutput() - if err != nil { - return nil, xerrors.Errorf("failed to inspect image %s: %w\nOutput: %s", - version, err, string(out)) - } - - var inspectRes []struct { - ID string `json:"Id"` - RepoDigests []string `json:"RepoDigests"` - } - - if err := json.Unmarshal(out, &inspectRes); err != nil { - return nil, xerrors.Errorf("cannot unmarshal Docker inspect response: %w", err) - } + // Export the image to tar + imageTarPath := filepath.Join(wd, "image.tar") + pkgCommands = append(pkgCommands, + []string{"docker", "save", version, "-o", imageTarPath}, + ) - if len(inspectRes) == 0 { - return nil, xerrors.Errorf("docker inspect returned empty result for image %s", version) - } + // Store image names for later use + for _, img := range cfg.Image { + pkgCommands = append(pkgCommands, + []string{"sh", "-c", fmt.Sprintf("echo %s >> %s", img, dockerImageNamesFiles)}, + ) + } - // Try to get digest from ID first (most reliable) - var digest common.DigestSet - if inspectRes[0].ID != "" { - segs := strings.Split(inspectRes[0].ID, ":") - if len(segs) == 2 { - digest = common.DigestSet{ - segs[0]: segs[1], - } - } + // Add metadata file + if len(cfg.Metadata) > 0 { + metadataContent, err := yaml.Marshal(cfg.Metadata) + if err != nil { + return nil, xerrors.Errorf("failed to marshal metadata: %w", err) } + encodedMetadata := base64.StdEncoding.EncodeToString(metadataContent) + pkgCommands = append(pkgCommands, + []string{"sh", "-c", fmt.Sprintf("echo %s | base64 -d > %s", encodedMetadata, dockerMetadataFile)}, + ) + } - // If we couldn't get digest from ID, try RepoDigests as fallback - if len(digest) == 0 && len(inspectRes[0].RepoDigests) > 0 { - for _, repoDigest := range inspectRes[0].RepoDigests { - parts := strings.Split(repoDigest, "@") - if len(parts) == 2 { - digestParts := strings.Split(parts[1], ":") - if len(digestParts) == 2 { - digest = common.DigestSet{ - digestParts[0]: digestParts[1], - } - break - } - } - } - } + // Package everything into final tar.gz + sourcePaths := []string{"./image.tar", fmt.Sprintf("./%s", dockerImageNamesFiles), "./docker-export-metadata.json"} + if len(cfg.Metadata) > 0 { + sourcePaths = append(sourcePaths, fmt.Sprintf("./%s", dockerMetadataFile)) + } + if p.C.W.Provenance.Enabled { + sourcePaths = append(sourcePaths, fmt.Sprintf("./%s", provenanceBundleFilename)) + } + if p.C.W.SBOM.Enabled { + sourcePaths = append(sourcePaths, + fmt.Sprintf("./%s", sbomBaseFilename+sbomCycloneDXFileExtension), + fmt.Sprintf("./%s", sbomBaseFilename+sbomSPDXFileExtension), + fmt.Sprintf("./%s", sbomBaseFilename+sbomSyftFileExtension), + ) + } - if len(digest) == 0 { - return nil, xerrors.Errorf("could not determine digest for image %s", version) - } + archiveCmd := BuildTarCommand( + WithOutputFile(result), + WithSourcePaths(sourcePaths...), + WithCompression(!buildctx.DontCompress), + ) + pkgCommands = append(pkgCommands, archiveCmd) - subjectLogger.WithField("digest", digest).Debug("Found image digest") + commands[PackageBuildPhasePackage] = pkgCommands - // Create subjects for each image - result := make([]in_toto.Subject, 0, len(cfg.Image)) - for _, tag := range cfg.Image { - result = append(result, in_toto.Subject{ - Name: tag, - Digest: digest, - }) - } + // Initialize res with commands + res = &packageBuild{ + Commands: commands, + } - return result, nil + // Add PostProcess to create structured metadata file + res.PostProcess = func(buildCtx *buildContext, pkg *Package, buildDir string) error { + return createDockerExportMetadata(buildDir, version, cfg) } + + // Add subjects function for provenance generation + res.Subjects = createDockerSubjectsFunction(version, cfg) } return res, nil @@ -2033,6 +2056,179 @@ func extractImageNameFromCache(pkgName, cacheBundleFN string) (imgname string, e return "", nil } +// humanReadableSize converts bytes to human-readable format +func humanReadableSize(bytes int64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(bytes)/float64(div), "KMGTPE"[exp]) +} + +// createDockerSubjectsFunction creates a function that generates SLSA provenance subjects for Docker images +func createDockerSubjectsFunction(version string, cfg DockerPkgConfig) func() ([]in_toto.Subject, error) { + return func() ([]in_toto.Subject, error) { + subjectLogger := log.WithField("operation", "provenance-subjects") + subjectLogger.Debug("Calculating provenance subjects for Docker images") + + // Get image digest with improved error handling + out, err := exec.Command("docker", "inspect", version).CombinedOutput() + if err != nil { + return nil, xerrors.Errorf("failed to inspect image %s: %w\nOutput: %s", + version, err, string(out)) + } + + var inspectRes []struct { + ID string `json:"Id"` + RepoDigests []string `json:"RepoDigests"` + } + + if err := json.Unmarshal(out, &inspectRes); err != nil { + return nil, xerrors.Errorf("cannot unmarshal Docker inspect response: %w", err) + } + + if len(inspectRes) == 0 { + return nil, xerrors.Errorf("docker inspect returned empty result for image %s", version) + } + + // Try to get digest from ID first (most reliable) + var digest common.DigestSet + if inspectRes[0].ID != "" { + segs := strings.Split(inspectRes[0].ID, ":") + if len(segs) == 2 { + digest = common.DigestSet{ + segs[0]: segs[1], + } + } + } + + // If we couldn't get digest from ID, try RepoDigests as fallback + if len(digest) == 0 && len(inspectRes[0].RepoDigests) > 0 { + for _, repoDigest := range inspectRes[0].RepoDigests { + parts := strings.Split(repoDigest, "@") + if len(parts) == 2 { + digestParts := strings.Split(parts[1], ":") + if len(digestParts) == 2 { + digest = common.DigestSet{ + digestParts[0]: digestParts[1], + } + break + } + } + } + } + + if len(digest) == 0 { + return nil, xerrors.Errorf("could not determine digest for image %s", version) + } + + subjectLogger.WithField("digest", digest).Debug("Found image digest") + + // Create subjects for each image + result := make([]in_toto.Subject, 0, len(cfg.Image)) + for _, tag := range cfg.Image { + result = append(result, in_toto.Subject{ + Name: tag, + Digest: digest, + }) + } + + return result, nil + } +} + +// DockerImageMetadata holds metadata for exported Docker images +type DockerImageMetadata struct { + ImageNames []string `json:"image_names" yaml:"image_names"` + BuiltVersion string `json:"built_version" yaml:"built_version"` + Digest string `json:"digest,omitempty" yaml:"digest,omitempty"` + BuildTime time.Time `json:"build_time" yaml:"build_time"` + CustomMeta map[string]string `json:"custom_metadata,omitempty" yaml:"custom_metadata,omitempty"` +} + +// createDockerExportMetadata creates metadata file for exported Docker images +func createDockerExportMetadata(wd, version string, cfg DockerPkgConfig) error { + metadata := DockerImageMetadata{ + ImageNames: cfg.Image, + BuiltVersion: version, + BuildTime: time.Now(), + CustomMeta: cfg.Metadata, + } + + // Try to get image digest if available + inspectCmd := exec.Command("docker", "inspect", "--format={{index .Id}}", version) + if output, err := inspectCmd.Output(); err == nil { + metadata.Digest = strings.TrimSpace(string(output)) + } + + // Write as JSON for easy parsing + metadataBytes, err := json.MarshalIndent(metadata, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal Docker metadata: %w", err) + } + + metadataPath := filepath.Join(wd, "docker-export-metadata.json") + if err := os.WriteFile(metadataPath, metadataBytes, 0644); err != nil { + return fmt.Errorf("failed to write Docker metadata: %w", err) + } + + log.WithField("path", metadataPath).Debug("Created Docker export metadata") + return nil +} + +// extractDockerMetadataFromCache extracts Docker image metadata from a cached package +func extractDockerMetadataFromCache(cacheBundleFN string) (*DockerImageMetadata, error) { + f, err := os.Open(cacheBundleFN) + if err != nil { + return nil, fmt.Errorf("failed to open cache bundle: %w", err) + } + defer f.Close() + + gzin, err := gzip.NewReader(f) + if err != nil { + return nil, fmt.Errorf("failed to create gzip reader: %w", err) + } + defer gzin.Close() + + tarin := tar.NewReader(gzin) + for { + hdr, err := tarin.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, fmt.Errorf("failed to read tar: %w", err) + } + + if hdr.Typeflag != tar.TypeReg { + continue + } + + if filepath.Base(hdr.Name) != "docker-export-metadata.json" { + continue + } + + metadataBytes := make([]byte, hdr.Size) + if _, err := io.ReadFull(tarin, metadataBytes); err != nil { + return nil, fmt.Errorf("failed to read metadata: %w", err) + } + + var metadata DockerImageMetadata + if err := json.Unmarshal(metadataBytes, &metadata); err != nil { + return nil, fmt.Errorf("failed to unmarshal metadata: %w", err) + } + + return &metadata, nil + } + + return nil, fmt.Errorf("docker-export-metadata.json not found in cache bundle") +} + // Update buildGeneric to use compression arg helper func (p *Package) buildGeneric(buildctx *buildContext, wd, result string) (res *packageBuild, err error) { cfg, ok := p.Config.(GenericPkgConfig) diff --git a/pkg/leeway/build_test.go b/pkg/leeway/build_test.go index 875dc47..ea1edd6 100644 --- a/pkg/leeway/build_test.go +++ b/pkg/leeway/build_test.go @@ -150,6 +150,172 @@ func TestBuildDockerDeps(t *testing.T) { } } +func TestDockerPkgConfig_ExportToCache(t *testing.T) { + tests := []struct { + name string + config leeway.DockerPkgConfig + expectedExport bool + }{ + { + name: "default behavior - push directly", + config: leeway.DockerPkgConfig{ + Image: []string{"test:latest"}, + }, + expectedExport: false, + }, + { + name: "explicit export to cache", + config: leeway.DockerPkgConfig{ + Image: []string{"test:latest"}, + ExportToCache: true, + }, + expectedExport: true, + }, + { + name: "explicit push directly", + config: leeway.DockerPkgConfig{ + Image: []string{"test:latest"}, + ExportToCache: false, + }, + expectedExport: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.config.ExportToCache != tt.expectedExport { + t.Errorf("ExportToCache = %v, want %v", tt.config.ExportToCache, tt.expectedExport) + } + }) + } +} + +func TestBuildDocker_ExportToCache(t *testing.T) { + if *testutil.Dut { + pth, err := os.MkdirTemp("", "") + if err != nil { + t.Fatal(err) + } + err = os.WriteFile(filepath.Join(pth, "docker"), []byte(dummyDocker), 0755) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(pth) }) + + os.Setenv("PATH", pth+":"+os.Getenv("PATH")) + log.WithField("path", os.Getenv("PATH")).Debug("modified path to use dummy docker") + } + testutil.RunDUT() + + tests := []*testutil.CommandFixtureTest{ + { + Name: "docker export to cache", + T: t, + Args: []string{"build", "-v", "-c", "none", "comp:pkg"}, + StderrSub: "Exporting Docker image to cache", + ExitCode: 0, + Fixture: &testutil.Setup{ + Components: []testutil.Component{ + { + Location: "comp", + Files: map[string]string{ + "Dockerfile": "FROM alpine:latest", + }, + Packages: []leeway.Package{ + { + PackageInternal: leeway.PackageInternal{ + Name: "pkg", + Type: leeway.DockerPackage, + }, + Config: leeway.DockerPkgConfig{ + Dockerfile: "Dockerfile", + Image: []string{"test:latest"}, + ExportToCache: true, + }, + }, + }, + }, + }, + }, + }, + } + + for _, test := range tests { + test.Run() + } +} + +func TestDockerPackage_BuildContextOverride(t *testing.T) { + tests := []struct { + name string + packageConfigValue bool + buildContextExportFlag bool + buildContextExportSet bool + expectedFinal bool + }{ + { + name: "no override - use package config false", + packageConfigValue: false, + buildContextExportFlag: false, + buildContextExportSet: false, + expectedFinal: false, + }, + { + name: "no override - use package config true", + packageConfigValue: true, + buildContextExportFlag: false, + buildContextExportSet: false, + expectedFinal: true, + }, + { + name: "CLI flag enables export (overrides package false)", + packageConfigValue: false, + buildContextExportFlag: true, + buildContextExportSet: true, + expectedFinal: true, + }, + { + name: "CLI flag keeps export enabled (package true)", + packageConfigValue: true, + buildContextExportFlag: true, + buildContextExportSet: true, + expectedFinal: true, + }, + { + name: "CLI flag disables export (overrides package true) - CRITICAL TEST", + packageConfigValue: true, + buildContextExportFlag: false, + buildContextExportSet: true, + expectedFinal: false, + }, + { + name: "CLI flag keeps export disabled (package false)", + packageConfigValue: false, + buildContextExportFlag: false, + buildContextExportSet: true, + expectedFinal: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := leeway.DockerPkgConfig{ + ExportToCache: tt.packageConfigValue, + } + + // Simulate the build context override logic from buildDocker + // This mimics: if buildctx.DockerExportSet { cfg.ExportToCache = buildctx.DockerExportToCache } + if tt.buildContextExportSet { + cfg.ExportToCache = tt.buildContextExportFlag + } + + if cfg.ExportToCache != tt.expectedFinal { + t.Errorf("ExportToCache = %v, want %v", cfg.ExportToCache, tt.expectedFinal) + } + }) + } +} + func TestDockerPostProcessing(t *testing.T) { if *testutil.Dut { pth, err := os.MkdirTemp("", "") diff --git a/pkg/leeway/package.go b/pkg/leeway/package.go index dfa93b9..8fc2b83 100644 --- a/pkg/leeway/package.go +++ b/pkg/leeway/package.go @@ -546,6 +546,11 @@ type DockerPkgConfig struct { BuildArgs map[string]string `yaml:"buildArgs,omitempty"` Squash bool `yaml:"squash,omitempty"` Metadata map[string]string `yaml:"metadata,omitempty"` + + // ExportToCache controls whether Docker images are exported to cache instead of pushed immediately. + // When true, images are saved as .tar files and go through the standard cache flow. + // When false (default), images are pushed directly to registries (legacy behavior). + ExportToCache bool `yaml:"exportToCache,omitempty"` } // AdditionalSources returns a list of unresolved sources coming in through this configuration