diff --git a/internal/cli/build.go b/internal/cli/build.go index f2e30f71e..870d1796f 100644 --- a/internal/cli/build.go +++ b/internal/cli/build.go @@ -37,7 +37,7 @@ import ( "chainguard.dev/apko/pkg/build" "chainguard.dev/apko/pkg/build/oci" "chainguard.dev/apko/pkg/build/types" - "chainguard.dev/apko/pkg/sbom" + "chainguard.dev/apko/pkg/sbom/generator" "chainguard.dev/apko/pkg/tarfs" ) @@ -85,8 +85,9 @@ Along the image, apko will generate SBOMs (software bill of materials) describin return fmt.Errorf("parsing annotations from command line: %w", err) } - if !writeSBOM { - sbomFormats = []string{} + var sbomGenerators []generator.Generator + if writeSBOM && len(sbomFormats) > 0 { + sbomGenerators = generator.Generators(sbomFormats...) } tmp, err := os.MkdirTemp(os.TempDir(), "apko-temp-*") @@ -102,7 +103,7 @@ Along the image, apko will generate SBOMs (software bill of materials) describin build.WithConfig(args[0], includePaths), build.WithBuildDate(buildDate), build.WithSBOM(sbomPath), - build.WithSBOMFormats(sbomFormats), + build.WithSBOMGenerators(sbomGenerators...), build.WithExtraKeys(extraKeys), build.WithExtraBuildRepos(extraBuildRepos), build.WithExtraRepos(extraRepos), @@ -125,7 +126,7 @@ Along the image, apko will generate SBOMs (software bill of materials) describin cmd.Flags().StringVar(&sbomPath, "sbom-path", "", "generate SBOMs in dir (defaults to image directory)") cmd.Flags().StringSliceVar(&archstrs, "arch", nil, "architectures to build for (e.g., x86_64,ppc64le,arm64) -- default is all, unless specified in config. Can also use 'host' to indicate arch of host this is running on") cmd.Flags().StringSliceVarP(&extraKeys, "keyring-append", "k", []string{}, "path to extra keys to include in the keyring") - cmd.Flags().StringSliceVar(&sbomFormats, "sbom-formats", sbom.DefaultOptions.Formats, "SBOM formats to output") + cmd.Flags().StringSliceVar(&sbomFormats, "sbom-formats", []string{"spdx"}, "SBOM formats to output") cmd.Flags().StringSliceVarP(&extraBuildRepos, "build-repository-append", "b", []string{}, "path to extra repositories to include") cmd.Flags().StringSliceVarP(&extraRepos, "repository-append", "r", []string{}, "path to extra repositories to include") cmd.Flags().StringSliceVarP(&extraPackages, "package-append", "p", []string{}, "extra packages to include") @@ -285,7 +286,7 @@ func buildImageComponents(ctx context.Context, workDir string, archs []types.Arc } var outputs []types.SBOM - if len(o.SBOMFormats) != 0 { + if len(o.SBOMGenerators) != 0 { outputs, err = bc.GenerateImageSBOM(ctx, arch, img) if err != nil { return fmt.Errorf("generating sbom for %s: %w", arch, err) @@ -301,7 +302,7 @@ func buildImageComponents(ctx context.Context, workDir string, archs []types.Arc multiArchBDE = bde } - if len(o.SBOMFormats) != 0 { + if len(o.SBOMGenerators) != 0 { sboms = append(sboms, outputs...) } @@ -333,7 +334,7 @@ func buildImageComponents(ctx context.Context, workDir string, archs []types.Arc } // the sboms are saved to the same working directory as the image components - if len(o.SBOMFormats) != 0 { + if len(o.SBOMGenerators) != 0 { files, err := build.GenerateIndexSBOM(ctx, *o, *ic, finalDigest, imgs) if err != nil { return nil, nil, fmt.Errorf("generating index SBOM: %w", err) diff --git a/internal/cli/build_test.go b/internal/cli/build_test.go index c364c139c..b429f9c00 100644 --- a/internal/cli/build_test.go +++ b/internal/cli/build_test.go @@ -30,6 +30,7 @@ import ( "chainguard.dev/apko/internal/cli" "chainguard.dev/apko/pkg/build" "chainguard.dev/apko/pkg/build/types" + "chainguard.dev/apko/pkg/sbom/generator/spdx" ) func TestBuild(t *testing.T) { @@ -43,7 +44,7 @@ func TestBuild(t *testing.T) { archs := types.ParseArchitectures([]string{"amd64", "arm64"}) opts := []build.Option{ build.WithConfig(config, []string{}), - build.WithSBOMFormats([]string{"spdx"}), + build.WithSBOMGenerators(spdx.New()), build.WithTags("golden:latest"), build.WithAnnotations(map[string]string{ "org.opencontainers.image.vendor": "Vendor", @@ -126,7 +127,7 @@ func TestBuildWithBase(t *testing.T) { lockfile := filepath.Join("testdata", "image_on_top.apko.lock.json") archs := types.ParseArchitectures([]string{"amd64", "arm64"}) - opts := []build.Option{build.WithConfig(config, []string{}), build.WithSBOMFormats([]string{"spdx"}), build.WithTags("golden_top:latest"), build.WithLockFile(lockfile), build.WithTempDir(apkoTempDir)} + opts := []build.Option{build.WithConfig(config, []string{}), build.WithSBOMGenerators(spdx.New()), build.WithTags("golden_top:latest"), build.WithLockFile(lockfile), build.WithTempDir(apkoTempDir)} sbomPath := filepath.Join(tmp, "sboms") err := os.MkdirAll(sbomPath, 0o750) diff --git a/internal/cli/publish.go b/internal/cli/publish.go index c81a063f1..71cbd0ee2 100644 --- a/internal/cli/publish.go +++ b/internal/cli/publish.go @@ -35,7 +35,7 @@ import ( "chainguard.dev/apko/pkg/build" "chainguard.dev/apko/pkg/build/oci" "chainguard.dev/apko/pkg/build/types" - "chainguard.dev/apko/pkg/sbom" + "chainguard.dev/apko/pkg/sbom/generator" ) func publish() *cobra.Command { @@ -70,8 +70,9 @@ in a keychain.`, return fmt.Errorf("requires at least 2 arg(s), 1 config file and at least 1 tag for the image") } - if !writeSBOM { - sbomFormats = []string{} + var sbomGenerators []generator.Generator + if writeSBOM && len(sbomFormats) > 0 { + sbomGenerators = generator.Generators(sbomFormats...) } archs := types.ParseArchitectures(archstrs) annotations, err := parseAnnotations(rawAnnotations) @@ -109,7 +110,7 @@ in a keychain.`, build.WithConfig(args[0], []string{}), build.WithBuildDate(buildDate), build.WithSBOM(sbomPath), - build.WithSBOMFormats(sbomFormats), + build.WithSBOMGenerators(sbomGenerators...), build.WithExtraKeys(extraKeys), build.WithExtraBuildRepos(extraBuildRepos), build.WithExtraRepos(extraRepos), @@ -140,7 +141,7 @@ in a keychain.`, cmd.Flags().StringVar(&sbomPath, "sbom-path", "", "path to write the SBOMs") cmd.Flags().StringSliceVar(&archstrs, "arch", nil, "architectures to build for (e.g., x86_64,ppc64le,arm64) -- default is all, unless specified in config.") cmd.Flags().StringSliceVarP(&extraKeys, "keyring-append", "k", []string{}, "path to extra keys to include in the keyring") - cmd.Flags().StringSliceVar(&sbomFormats, "sbom-formats", sbom.DefaultOptions.Formats, "SBOM formats to output") + cmd.Flags().StringSliceVar(&sbomFormats, "sbom-formats", []string{"spdx"}, "SBOM formats to output") cmd.Flags().StringSliceVarP(&extraBuildRepos, "build-repository-append", "b", []string{}, "path to extra repositories to include") cmd.Flags().StringSliceVarP(&extraRepos, "repository-append", "r", []string{}, "path to extra repositories to include") cmd.Flags().StringSliceVarP(&extraPackages, "package-append", "p", []string{}, "extra packages to include") diff --git a/internal/cli/publish_test.go b/internal/cli/publish_test.go index cbb58c598..a94e8f891 100644 --- a/internal/cli/publish_test.go +++ b/internal/cli/publish_test.go @@ -41,7 +41,7 @@ import ( "chainguard.dev/apko/internal/tarfs" "chainguard.dev/apko/pkg/build" "chainguard.dev/apko/pkg/build/types" - "chainguard.dev/apko/pkg/sbom" + "chainguard.dev/apko/pkg/sbom/generator/spdx" ) func TestPublish(t *testing.T) { @@ -72,7 +72,7 @@ func TestPublish(t *testing.T) { opts := []build.Option{ build.WithConfig(config, []string{}), build.WithTags(dst), - build.WithSBOMFormats(sbom.DefaultOptions.Formats), + build.WithSBOMGenerators(spdx.New()), build.WithAnnotations(map[string]string{"foo": "bar"}), } publishOpts := []cli.PublishOption{cli.WithTags(dst)} @@ -146,7 +146,7 @@ func TestPublishLayering(t *testing.T) { opts := []build.Option{ build.WithConfig(config, []string{}), build.WithTags(dst), - build.WithSBOMFormats(sbom.DefaultOptions.Formats), + build.WithSBOMGenerators(spdx.New()), build.WithAnnotations(map[string]string{"foo": "bar"}), } publishOpts := []cli.PublishOption{cli.WithTags(dst)} diff --git a/main.go b/main.go index 343ff7b97..eb2796d6f 100644 --- a/main.go +++ b/main.go @@ -21,6 +21,9 @@ import ( "os/signal" "chainguard.dev/apko/internal/cli" + + // Import spdx generator to register it. + _ "chainguard.dev/apko/pkg/sbom/generator/spdx" ) func main() { diff --git a/pkg/build/build.go b/pkg/build/build.go index baf97373b..c97ef43b3 100644 --- a/pkg/build/build.go +++ b/pkg/build/build.go @@ -498,7 +498,7 @@ func (bc *Context) Arch() types.Architecture { } func (bc *Context) WantSBOM() bool { - return len(bc.o.SBOMFormats) != 0 + return len(bc.o.SBOMGenerators) != 0 } func (bc *Context) APK() *apk.APK { diff --git a/pkg/build/options.go b/pkg/build/options.go index c56141442..4d604962b 100644 --- a/pkg/build/options.go +++ b/pkg/build/options.go @@ -26,6 +26,7 @@ import ( "chainguard.dev/apko/pkg/apk/apk" "chainguard.dev/apko/pkg/apk/auth" "chainguard.dev/apko/pkg/build/types" + "chainguard.dev/apko/pkg/sbom/generator" "github.com/chainguard-dev/clog" ) @@ -110,9 +111,20 @@ func WithSBOM(path string) Option { } } +// WithSBOMFormats sets the SBOM generators to use. +// +// Deprecated: use WithSBOMGenerators instead. func WithSBOMFormats(formats []string) Option { return func(bc *Context) error { - bc.o.SBOMFormats = formats + bc.o.SBOMGenerators = generator.Generators(formats...) + return nil + } +} + +// WithSBOMGenerators sets the SBOM generators to use. +func WithSBOMGenerators(generators ...generator.Generator) Option { + return func(bc *Context) error { + bc.o.SBOMGenerators = generators return nil } } diff --git a/pkg/build/sbom.go b/pkg/build/sbom.go index 0b13c85ec..a870a0f8d 100644 --- a/pkg/build/sbom.go +++ b/pkg/build/sbom.go @@ -38,7 +38,6 @@ import ( "chainguard.dev/apko/pkg/build/types" "chainguard.dev/apko/pkg/options" "chainguard.dev/apko/pkg/sbom" - "chainguard.dev/apko/pkg/sbom/generator" soptions "chainguard.dev/apko/pkg/sbom/options" ) @@ -60,7 +59,6 @@ func newSBOM(ctx context.Context, fsys apkfs.FullFS, o options.Options, ic types } sopt.ImageInfo.SourceDateEpoch = bde - sopt.Formats = o.SBOMFormats sopt.ImageInfo.VCSUrl = ic.VCSUrl sopt.ImageInfo.ImageMediaType = ggcrtypes.OCIManifestSchema1 @@ -125,20 +123,14 @@ func (bc *Context) GenerateImageSBOM(ctx context.Context, arch types.Architectur s.ImageInfo.Arch = arch var sboms = make([]types.SBOM, 0) - generators := generator.Generators() - for _, format := range s.Formats { - gen, ok := generators[format] - if !ok { - return nil, fmt.Errorf("unable to generate sboms: no generator available for format %s", format) - } - + for _, gen := range bc.o.SBOMGenerators { filename := filepath.Join(s.OutputDir, s.FileName+"."+gen.Ext()) if err := gen.Generate(ctx, &s, filename); err != nil { - return nil, fmt.Errorf("generating %s sbom: %w", format, err) + return nil, fmt.Errorf("generating %s sbom: %w", gen.Key(), err) } sboms = append(sboms, types.SBOM{ Path: filename, - Format: format, + Format: gen.Key(), Arch: arch.String(), Digest: h, }) @@ -212,7 +204,7 @@ func GenerateIndexSBOM(ctx context.Context, o options.Options, ic types.ImageCon _, span := otel.Tracer("apko").Start(ctx, "GenerateIndexSBOM") defer span.End() - if len(o.SBOMFormats) == 0 { + if len(o.SBOMGenerators) == 0 { log.Warn("skipping SBOM generation") return nil, nil } @@ -238,14 +230,8 @@ func GenerateIndexSBOM(ctx context.Context, o options.Options, ic types.ImageCon return archs[i].String() < archs[j].String() }) - generators := generator.Generators() - var sboms = make([]types.SBOM, 0, len(generators)) - for _, format := range s.Formats { - gen, ok := generators[format] - if !ok { - return nil, fmt.Errorf("unable to generate sboms: no generator available for format %s", format) - } - + var sboms = make([]types.SBOM, 0, len(o.SBOMGenerators)) + for _, gen := range o.SBOMGenerators { archImageInfos := make([]soptions.ArchImageInfo, 0, len(archs)) for _, arch := range archs { i := imgs[arch] @@ -270,11 +256,11 @@ func GenerateIndexSBOM(ctx context.Context, o options.Options, ic types.ImageCon filename := filepath.Join(s.OutputDir, "sbom-index."+gen.Ext()) if err := gen.GenerateIndex(&s, filename); err != nil { - return nil, fmt.Errorf("generating %s sbom: %w", format, err) + return nil, fmt.Errorf("generating %s sbom: %w", gen.Key(), err) } sboms = append(sboms, types.SBOM{ Path: filename, - Format: format, + Format: gen.Key(), Digest: h, }) } diff --git a/pkg/options/options.go b/pkg/options/options.go index b1a065660..2314affe7 100644 --- a/pkg/options/options.go +++ b/pkg/options/options.go @@ -25,6 +25,7 @@ import ( "chainguard.dev/apko/pkg/apk/apk" "chainguard.dev/apko/pkg/apk/auth" "chainguard.dev/apko/pkg/build/types" + "chainguard.dev/apko/pkg/sbom/generator" ) type Options struct { @@ -32,31 +33,31 @@ type Options struct { // ImageConfigFile might, but does not have to be a filename. It might be any abstract configuration identifier. ImageConfigFile string `json:"imageConfigFile,omitempty"` // ImageConfigChecksum (when set) allows to detect mismatch between configuration and the lockfile. - ImageConfigChecksum string `json:"configChecksum,omitempty"` - TarballPath string `json:"tarballPath,omitempty"` - Tags []string `json:"tags,omitempty"` - SourceDateEpoch time.Time `json:"sourceDateEpoch,omitempty"` - SBOMPath string `json:"sbomPath,omitempty"` - SBOMFormats []string `json:"sbomFormats,omitempty"` - ExtraKeyFiles []string `json:"extraKeyFiles,omitempty"` - ExtraBuildRepos []string `json:"extraBuildRepos,omitempty"` - ExtraRepos []string `json:"extraRepos,omitempty"` - ExtraPackages []string `json:"extraPackages,omitempty"` - Arch types.Architecture `json:"arch,omitempty"` - TempDirPath string `json:"tempDirPath,omitempty"` - PackageVersionTag string `json:"packageVersionTag,omitempty"` - PackageVersionTagStem bool `json:"packageVersionTagStem,omitempty"` - PackageVersionTagPrefix string `json:"packageVersionTagPrefix,omitempty"` - TagSuffix string `json:"tagSuffix,omitempty"` - Local bool `json:"local,omitempty"` - CacheDir string `json:"cacheDir,omitempty"` - Offline bool `json:"offline,omitempty"` - SharedCache *apk.Cache `json:"-"` - Lockfile string `json:"lockfile,omitempty"` - Auth auth.Authenticator `json:"-"` - IncludePaths []string `json:"includePaths,omitempty"` - IgnoreSignatures bool `json:"ignoreSignatures,omitempty"` - Transport http.RoundTripper `json:"-"` + ImageConfigChecksum string `json:"configChecksum,omitempty"` + TarballPath string `json:"tarballPath,omitempty"` + Tags []string `json:"tags,omitempty"` + SourceDateEpoch time.Time `json:"sourceDateEpoch,omitempty"` + SBOMPath string `json:"sbomPath,omitempty"` + SBOMGenerators []generator.Generator `json:"-"` + ExtraKeyFiles []string `json:"extraKeyFiles,omitempty"` + ExtraBuildRepos []string `json:"extraBuildRepos,omitempty"` + ExtraRepos []string `json:"extraRepos,omitempty"` + ExtraPackages []string `json:"extraPackages,omitempty"` + Arch types.Architecture `json:"arch,omitempty"` + TempDirPath string `json:"tempDirPath,omitempty"` + PackageVersionTag string `json:"packageVersionTag,omitempty"` + PackageVersionTagStem bool `json:"packageVersionTagStem,omitempty"` + PackageVersionTagPrefix string `json:"packageVersionTagPrefix,omitempty"` + TagSuffix string `json:"tagSuffix,omitempty"` + Local bool `json:"local,omitempty"` + CacheDir string `json:"cacheDir,omitempty"` + Offline bool `json:"offline,omitempty"` + SharedCache *apk.Cache `json:"-"` + Lockfile string `json:"lockfile,omitempty"` + Auth auth.Authenticator `json:"-"` + IncludePaths []string `json:"includePaths,omitempty"` + IgnoreSignatures bool `json:"ignoreSignatures,omitempty"` + Transport http.RoundTripper `json:"-"` } type Auth struct{ User, Pass string } diff --git a/pkg/sbom/generator/generator.go b/pkg/sbom/generator/generator.go index 4d70efbb0..4fde88663 100644 --- a/pkg/sbom/generator/generator.go +++ b/pkg/sbom/generator/generator.go @@ -16,11 +16,12 @@ package generator import ( "context" + "sync" - "chainguard.dev/apko/pkg/sbom/generator/spdx" "chainguard.dev/apko/pkg/sbom/options" ) +// Generator defines the interface for SBOM generators. type Generator interface { Key() string Ext() string @@ -28,11 +29,43 @@ type Generator interface { GenerateIndex(*options.Options, string) error } -func Generators() map[string]Generator { - generators := map[string]Generator{} +// GeneratorFactory is a function that creates a Generator. +type GeneratorFactory func() Generator - sx := spdx.New() - generators[sx.Key()] = sx +var ( + registryMu sync.RWMutex + registry = make(map[string]GeneratorFactory) +) + +// RegisterGenerator registers a custom generator factory under the given key. +// This allows external systems to plug in their own SBOM generator types. +// If a generator with the same key already exists, it will be overwritten. +func RegisterGenerator(key string, factory GeneratorFactory) { + registryMu.Lock() + defer registryMu.Unlock() + registry[key] = factory +} + +// Generators returns a map of registered generators. +// If names are provided, only generators with those keys will be returned. +func Generators(names ...string) []Generator { + generators := []Generator{} + + nameIdx := map[string]bool{} + for _, n := range names { + nameIdx[n] = true + } + // If no names are provided, return all generators. + all := len(nameIdx) == 0 + + registryMu.RLock() + defer registryMu.RUnlock() + + for key, factory := range registry { + if all || nameIdx[key] { + generators = append(generators, factory()) + } + } return generators } diff --git a/pkg/sbom/generator/spdx/spdx.go b/pkg/sbom/generator/spdx/spdx.go index 18ff654fb..9ef43d61b 100644 --- a/pkg/sbom/generator/spdx/spdx.go +++ b/pkg/sbom/generator/spdx/spdx.go @@ -32,9 +32,16 @@ import ( "chainguard.dev/apko/pkg/apk/apk" apkfs "chainguard.dev/apko/pkg/apk/fs" + "chainguard.dev/apko/pkg/sbom/generator" "chainguard.dev/apko/pkg/sbom/options" ) +func init() { + generator.RegisterGenerator("spdx", func() generator.Generator { + return New() + }) +} + // https://spdx.github.io/spdx-spec/3-package-information/#32-package-spdx-identifier var validIDCharsRe = regexp.MustCompile(`[^a-zA-Z0-9-.]+`) @@ -45,8 +52,7 @@ const ( apkSBOMdir = "/var/lib/db/sbom" ) -type SPDX struct { -} +type SPDX struct{} func New() *SPDX { return &SPDX{} diff --git a/pkg/sbom/options/options.go b/pkg/sbom/options/options.go index e27dc4ed7..db10ebfff 100644 --- a/pkg/sbom/options/options.go +++ b/pkg/sbom/options/options.go @@ -48,9 +48,6 @@ type Options struct { // FileName is the base name for the sboms, the proper extension will get appended FileName string - // Formats dictates which SBOM formats we will output - Formats []string - // Packages is a list of packages which will be listed in the SBOM Packages []*apk.InstalledPackage } diff --git a/pkg/sbom/sbom.go b/pkg/sbom/sbom.go index f3908d38a..f9d809f10 100644 --- a/pkg/sbom/sbom.go +++ b/pkg/sbom/sbom.go @@ -26,5 +26,4 @@ var DefaultOptions = options.Options{ Images: []options.ArchImageInfo{}, }, FileName: "sbom", - Formats: []string{"spdx"}, }