Repository: chainguard-dev/apko
Pull request #78 (2022-03-08 21:43:55)

pkg/build: add build.WithImageConfiguration
this is needed for Melange (and Dagger will probably find it helpful too)


Patch:
diff --git a/Makefile b/Makefile
index 0a20eac..2df0a12 100644
--- a/Makefile
+++ b/Makefile
@@ -47,6 +47,12 @@ define create_kocache_path
   mkdir -p $(KOCACHE_PATH)
 endef
 
+##########
+# default
+##########
+
+default: help
+
 ##########
 # ko build
 ##########
@@ -80,11 +86,11 @@ ko-apply:  ## Build the image and apply the manifests
 ##########
 
 .PHONY: apko
-apko: $(SRCS)
+apko: $(SRCS) ## Builds apko
 	CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o $@ ./
 
 .PHONY: install
-install: $(SRCS) apko
+install: $(SRCS) apko ## Builds and moves apko into BINDIR (default /usr/bin)
 	install -Dm755 apko ${DESTDIR}${BINDIR}/apko
 
 #####################
diff --git a/go.mod b/go.mod
index feaa07f..1b5877b 100644
--- a/go.mod
+++ b/go.mod
@@ -5,9 +5,13 @@ go 1.17
 require (
 	github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.0.0-20220216180153-3d7835abdf40
 	github.com/chrismellard/docker-credential-acr-env v0.0.0-20220119192733-fe33c00cee21
+	github.com/dominodatalab/os-release v0.0.0-20190522011736-bcdb4a3e3c2f
 	github.com/google/go-containerregistry v0.8.1-0.20220223122423-dd8d514a9b24
+	github.com/hashicorp/go-multierror v1.1.1
+	github.com/maxbrunsfeld/counterfeiter/v6 v6.4.1
 	github.com/spf13/cobra v1.3.0
 	github.com/stretchr/testify v1.7.0
+	gitlab.alpinelinux.org/alpine/go v0.3.0
 	go.lsp.dev/uri v0.3.0
 	golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
 	gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
@@ -46,6 +50,7 @@ require (
 	github.com/docker/docker-credential-helpers v0.6.4 // indirect
 	github.com/form3tech-oss/jwt-go v3.2.2+incompatible // indirect
 	github.com/golang/protobuf v1.5.2 // indirect
+	github.com/hashicorp/errwrap v1.0.0 // indirect
 	github.com/inconshreveable/mousetrap v1.0.0 // indirect
 	github.com/jmespath/go-jmespath v0.4.0 // indirect
 	github.com/klauspost/compress v1.14.2 // indirect
@@ -59,9 +64,12 @@ require (
 	github.com/spf13/pflag v1.0.5 // indirect
 	github.com/vbatts/tar-split v0.11.2 // indirect
 	golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect
+	golang.org/x/mod v0.5.1 // indirect
 	golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect
 	golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 // indirect
 	golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27 // indirect
+	golang.org/x/tools v0.1.9 // indirect
+	golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
 	google.golang.org/protobuf v1.27.1 // indirect
 	gotest.tools/v3 v3.0.3 // indirect
diff --git a/go.sum b/go.sum
index 57c9ce1..defe957 100644
--- a/go.sum
+++ b/go.sum
@@ -96,6 +96,8 @@ github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbi
 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
 github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
 github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24/go.mod h1:4UJr5HIiMZrwgkSPdsjy2uOQExX/WEILpIrO9UPGuXs=
+github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
+github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
 github.com/Masterminds/goutils v1.1.0/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
 github.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
 github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
@@ -384,6 +386,8 @@ github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDD
 github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE=
 github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM=
 github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
+github.com/dominodatalab/os-release v0.0.0-20190522011736-bcdb4a3e3c2f h1:oEt43goQgsL1DzoOyQ/UZHQw7t9TqwyJec9W0vh0wfE=
+github.com/dominodatalab/os-release v0.0.0-20190522011736-bcdb4a3e3c2f/go.mod h1:RU3x9VqPvzbOGJ3wtP0pPBtUOp4yU/yzA/8qdxgi/6Q=
 github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo=
 github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
 github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
@@ -616,6 +620,7 @@ github.com/hashicorp/consul/api v1.11.0/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/
 github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
 github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms=
 github.com/hashicorp/errwrap v0.0.0-20141028054710-7554cd9344ce/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
 github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
 github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
 github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
@@ -628,6 +633,7 @@ github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iP
 github.com/hashicorp/go-multierror v0.0.0-20161216184304-ed905158d874/go.mod h1:JMRHfdO9jKNzS/+BTlxCjKNQHg/jZAft8U7LloJvN7I=
 github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
 github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
+github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
 github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
 github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
 github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
@@ -773,6 +779,7 @@ github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOq
 github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw=
 github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
 github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
+github.com/maxbrunsfeld/counterfeiter/v6 v6.4.1 h1:hZD/8vBuw7x1WqRXD/WGjVjipbbo/HcDBgySYYbrUSk=
 github.com/maxbrunsfeld/counterfeiter/v6 v6.4.1/go.mod h1:DK1Cjkc0E49ShgRVs5jy5ASrM15svSnem3K/hiSGD8o=
 github.com/mbilski/exhaustivestruct v1.2.0/go.mod h1:OeTBVxQWoEmB2J2JCHmXWPJ0aksxSUOUy+nvtVEfzXc=
 github.com/mgechev/dots v0.0.0-20210922191527-e955255bf517/go.mod h1:KQ7+USdGKfpPjXk4Ga+5XxQM4Lm4e3gAogrreFAYpOg=
@@ -858,6 +865,7 @@ github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7J
 github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
 github.com/onsi/gomega v1.10.3/go.mod h1:V9xEwhxec5O8UDM77eCW8vLymOMltsqPVYWrpDsH8xc=
 github.com/onsi/gomega v1.11.0/go.mod h1:azGKhqFUon9Vuj0YmTfLSmx0FUwqXYSTl5re8lQLTUg=
+github.com/onsi/gomega v1.16.0 h1:6gjqkI8iiRHMvdccRJM8rVKjCWk6ZIm6FTm3ddIe4/c=
 github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
 github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
 github.com/opencontainers/go-digest v0.0.0-20180430190053-c9281466c8b2/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
@@ -973,6 +981,7 @@ github.com/sagikazarmark/crypt v0.1.0/go.mod h1:B/mN0msZuINBtQ1zZLEQcegFJJf9vnYI
 github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig=
 github.com/sanposhiho/wastedassign/v2 v2.0.6/go.mod h1:KyZ0MWTwxxBmfwn33zh3k1dmsbF2ud9pAAGfoLfjhtI=
 github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
+github.com/sclevine/spec v1.4.0 h1:z/Q9idDcay5m5irkZ28M7PtQM4aOISzOpj4bUPkDee8=
 github.com/sclevine/spec v1.4.0/go.mod h1:LvpgJaFyvQzRvc1kaDs0bulYwzC70PbiYjC4QnFHkOM=
 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
 github.com/seccomp/libseccomp-golang v0.9.1/go.mod h1:GbW5+tmTXfcxTToHLXlScSlAvWlF4P2Ca7zGrPiEpWo=
@@ -1114,6 +1123,8 @@ github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1
 github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs=
 github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50/go.mod h1:NUSPSUX/bi6SeDMUh6brw0nXpxHnc96TguQh0+r/ssA=
 github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f/go.mod h1:GlGEuHIJweS1mbCqG+7vt2nvWLzLLnRHbXz5JKd/Qbg=
+gitlab.alpinelinux.org/alpine/go v0.3.0 h1:4wVjXZRAd4rApnvVEFZqReDTdOe8ZLEKa8/egMPqVJM=
+gitlab.alpinelinux.org/alpine/go v0.3.0/go.mod h1:auOw3SnxDQBo1vzPh8q6gjvKsYgKxYet03lgSKtA3Q4=
 go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
 go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
 go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
@@ -1210,6 +1221,7 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
+golang.org/x/mod v0.5.1 h1:OJxoQ/rynoF0dcCdI7cLPktw/hR2cueqYfjm43oqK38=
 golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -1430,6 +1442,7 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -1541,6 +1554,7 @@ golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.1.6/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
 golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
+golang.org/x/tools v0.1.9 h1:j9KsMiaP1c3B0OTQGth0/k+miLGTgLsAFUCrF2vLcF8=
 golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/internal/tools.go b/internal/tools.go
new file mode 100644
index 0000000..f96df81
--- /dev/null
+++ b/internal/tools.go
@@ -0,0 +1,24 @@
+//go:build tools
+// +build tools
+
+// Copyright 2022 Chainguard, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// This is used to import things required by build scripts, to force `go mod` to see them as dependencies
+
+package internal
+
+import (
+	_ "github.com/maxbrunsfeld/counterfeiter/v6"
+)
diff --git a/pkg/build/build.go b/pkg/build/build.go
index 9066cd4..60b7dd7 100644
--- a/pkg/build/build.go
+++ b/pkg/build/build.go
@@ -32,6 +32,9 @@ type Context struct {
 	UseProot           bool
 	Tags               []string
 	SourceDateEpoch    time.Time
+	Assertions         []Assertion
+	WantSBOM           bool
+	SBOMPath           string
 }
 
 func (bc *Context) Summarize() {
@@ -40,6 +43,7 @@ func (bc *Context) Summarize() {
 	log.Printf("  tarball path: %s", bc.TarballPath)
 	log.Printf("  use proot: %t", bc.UseProot)
 	log.Printf("  source date: %s", bc.SourceDateEpoch)
+	log.Printf("  SBOM output path: %s", bc.SBOMPath)
 	bc.ImageConfiguration.Summarize()
 }
 
@@ -157,6 +161,17 @@ func WithTarball(path string) Option {
 	}
 }
 
+// WithAssertions adds assertions to validate the result
+// of this build context.
+// Assertions are checked in parallel at the end of the
+// build process.
+func WithAssertions(a ...Assertion) Option {
+	return func(bc *Context) error {
+		bc.Assertions = append(bc.Assertions, a...)
+		return nil
+	}
+}
+
 // WithBuildDate sets the timestamps for the build context.
 // The string is parsed according to RFC3339.
 // An empty string is a special case and will default to
@@ -175,6 +190,23 @@ func WithBuildDate(s string) Option {
 		}
 
 		bc.SourceDateEpoch = t
+
+		return nil
+	}
+}
+
+func WithSBOM(path string) Option {
+	return func(bc *Context) error {
+		bc.SBOMPath = path
+		return nil
+	}
+}
+
+// WithImageConfiguration sets the ImageConfiguration object
+// to use when building.
+func WithImageConfiguration(ic types.ImageConfiguration) Option {
+	return func(bc *Context) error {
+		bc.ImageConfiguration = ic
 		return nil
 	}
 }
diff --git a/pkg/build/image_builder.go b/pkg/build/image_builder.go
index c800fe3..7945a18 100644
--- a/pkg/build/image_builder.go
+++ b/pkg/build/image_builder.go
@@ -20,7 +20,9 @@ import (
 	"log"
 	"os"
 	"path/filepath"
+	"sync"
 
+	"github.com/hashicorp/go-multierror"
 	"golang.org/x/sync/errgroup"
 )
 
@@ -88,6 +90,10 @@ func (bc *Context) BuildImage() error {
 		return err
 	}
 
+	if err := bc.runAssertions(); err != nil {
+		return err
+	}
+
 	// maybe install busybox symlinks
 	if bc.UseProot {
 		if err := bc.InstallBusyboxSymlinks(); err != nil {
@@ -100,10 +106,44 @@ func (bc *Context) BuildImage() error {
 		return fmt.Errorf("failed to write supervision tree: %w", err)
 	}
 
+	// generate SBOM
+	if bc.SBOMPath != "" {
+		if err := bc.GenerateSBOM(); err != nil {
+			return fmt.Errorf("failed to generate SBOM: %w", err)
+		}
+	}
+
 	log.Printf("finished building filesystem in %s", bc.WorkDir)
 	return nil
 }
 
+func (bc *Context) runAssertions() error {
+	var wg sync.WaitGroup
+	var result error
+	errCh := make(chan error, len(bc.Assertions))
+
+	for _, a := range bc.Assertions {
+		a := a
+
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			if err := a(bc); err != nil {
+				errCh <- err
+			}
+		}()
+	}
+
+	wg.Wait()
+	close(errCh)
+
+	for err := range errCh {
+		result = multierror.Append(result, err)
+	}
+
+	return result
+}
+
 // Installs the BusyBox symlinks, if appropriate.
 func (bc *Context) InstallBusyboxSymlinks() error {
 	path := filepath.Join(bc.WorkDir, "bin", "busybox")
diff --git a/pkg/build/sbom.go b/pkg/build/sbom.go
new file mode 100644
index 0000000..3b40e6a
--- /dev/null
+++ b/pkg/build/sbom.go
@@ -0,0 +1,45 @@
+// Copyright 2022 Chainguard, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package build
+
+import (
+	"fmt"
+	"log"
+
+	"chainguard.dev/apko/pkg/sbom"
+)
+
+// GenerateSBOM runs the sbom generation
+func (bc *Context) GenerateSBOM() error {
+	log.Printf("generating SBOM")
+
+	// TODO(puerco): Split GenerateSBOM into context implementation
+	s := sbom.NewWithWorkDir(bc.WorkDir)
+
+	// Generate the packages externally as we may
+	// move the package reader somewhere else
+	packages, err := s.ReadPackageIndex()
+	if err != nil {
+		return fmt.Errorf("getting installed packagesx from sbom: %w", err)
+	}
+	s.Options.OutputDir = bc.SBOMPath
+	s.Options.Packages = packages
+
+	if _, err := s.Generate(); err != nil {
+		return fmt.Errorf("generating SBOMs: %w", err)
+	}
+
+	return nil
+}
diff --git a/pkg/build/validate.go b/pkg/build/validate.go
new file mode 100644
index 0000000..a2053a9
--- /dev/null
+++ b/pkg/build/validate.go
@@ -0,0 +1,38 @@
+package build
+
+import (
+	"fmt"
+	"log"
+	"os"
+	"path/filepath"
+)
+
+type Assertion func(*Context) error
+
+func RequirePasswdFile(optional bool) Assertion {
+	return func(bc *Context) error {
+		path := filepath.Join(bc.WorkDir, "etc", "passwd")
+
+		_, err := os.Stat(path)
+		if err != nil && optional {
+			log.Printf("warning: %s is missing", path)
+			return nil
+		}
+
+		return fmt.Errorf("/etc/passwd file is missing: %w", err)
+	}
+}
+
+func RequireGroupFile(optional bool) Assertion {
+	return func(bc *Context) error {
+		path := filepath.Join(bc.WorkDir, "etc", "group")
+
+		_, err := os.Stat(path)
+		if err != nil && optional {
+			log.Printf("warning: %s is missing", path)
+			return nil
+		}
+
+		return fmt.Errorf("/etc/group file is missing: %w", err)
+	}
+}
diff --git a/pkg/cli/build-minirootfs.go b/pkg/cli/build-minirootfs.go
index 5db913e..a46683f 100644
--- a/pkg/cli/build-minirootfs.go
+++ b/pkg/cli/build-minirootfs.go
@@ -27,6 +27,7 @@ import (
 func BuildMinirootFS() *cobra.Command {
 	var useProot bool
 	var buildDate string
+	var sbomPath string
 
 	cmd := &cobra.Command{
 		Use:     "build-minirootfs",
@@ -40,12 +41,14 @@ func BuildMinirootFS() *cobra.Command {
 				build.WithTarball(args[1]),
 				build.WithProot(useProot),
 				build.WithBuildDate(buildDate),
+				build.WithSBOM(sbomPath),
 			)
 		},
 	}
 
 	cmd.Flags().BoolVar(&useProot, "use-proot", false, "use proot to simulate privileged operations")
 	cmd.Flags().StringVar(&buildDate, "build-date", "", "date used for the timestamps of the files inside the image")
+	cmd.Flags().StringVar(&sbomPath, "sbom-path", "", "generate an SBOM")
 
 	return cmd
 }
diff --git a/pkg/cli/build.go b/pkg/cli/build.go
index 3c725fa..5f36646 100644
--- a/pkg/cli/build.go
+++ b/pkg/cli/build.go
@@ -28,6 +28,7 @@ import (
 func Build() *cobra.Command {
 	var useProot bool
 	var buildDate string
+	var sbomPath string
 
 	cmd := &cobra.Command{
 		Use:   "build",
@@ -45,12 +46,15 @@ command, e.g.
 				build.WithConfig(args[0]),
 				build.WithProot(useProot),
 				build.WithBuildDate(buildDate),
+				build.WithAssertions(build.RequireGroupFile(true), build.RequirePasswdFile(true)),
+				build.WithSBOM(sbomPath),
 			)
 		},
 	}
 
 	cmd.Flags().BoolVar(&useProot, "use-proot", false, "use proot to simulate privileged operations")
 	cmd.Flags().StringVar(&buildDate, "build-date", "", "date used for the timestamps of the files inside the image")
+	cmd.Flags().StringVar(&sbomPath, "sbom-path", "", "generate an SBOM")
 
 	return cmd
 }
diff --git a/pkg/cli/publish.go b/pkg/cli/publish.go
index 355bfa1..6194e1b 100644
--- a/pkg/cli/publish.go
+++ b/pkg/cli/publish.go
@@ -29,6 +29,7 @@ func Publish() *cobra.Command {
 	var imageRefs string
 	var useProot bool
 	var buildDate string
+	var sbomPath string
 
 	cmd := &cobra.Command{
 		Use:   "publish",
@@ -45,6 +46,7 @@ in a keychain.`,
 				build.WithProot(useProot),
 				build.WithTags(args[1:]...),
 				build.WithBuildDate(buildDate),
+				build.WithSBOM(sbomPath),
 			); err != nil {
 				return err
 			}
@@ -55,6 +57,7 @@ in a keychain.`,
 	cmd.Flags().StringVar(&imageRefs, "image-refs", "", "path to file where a list of the published image references will be written")
 	cmd.Flags().BoolVar(&useProot, "use-proot", false, "use proot to simulate privileged operations")
 	cmd.Flags().StringVar(&buildDate, "build-date", "", "date used for the timestamps of the files inside the image")
+	cmd.Flags().StringVar(&sbomPath, "sbom-path", "", "generate an SBOM")
 
 	return cmd
 }
diff --git a/pkg/sbom/generator/cyclonedx/cyclonedx.go b/pkg/sbom/generator/cyclonedx/cyclonedx.go
new file mode 100644
index 0000000..0ce52f6
--- /dev/null
+++ b/pkg/sbom/generator/cyclonedx/cyclonedx.go
@@ -0,0 +1,155 @@
+// Copyright 2022 Chainguard, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cyclonedx
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"strings"
+
+	"chainguard.dev/apko/pkg/sbom/options"
+	"chainguard.dev/apko/pkg/sbom/purl"
+)
+
+type CycloneDX struct{}
+
+func New() CycloneDX {
+	return CycloneDX{}
+}
+
+func (cdx *CycloneDX) Key() string {
+	return "cyclonedx"
+}
+
+func (cdx *CycloneDX) Ext() string {
+	return "cdx"
+}
+
+// Generate writes a cyclondx sbom in path
+func (cdx *CycloneDX) Generate(opts *options.Options, path string) error {
+	pkgComponents := []Component{}
+	pkgDependencies := []Dependency{}
+
+	for _, pkg := range opts.Packages {
+		// add the component
+		c := Component{
+			BOMRef:      purl.Package(opts.OS.ID, pkg),
+			Name:        pkg.Name,
+			Version:     pkg.Version,
+			Description: pkg.Description,
+			Licenses: []License{
+				{
+					Expression: pkg.License,
+				},
+			},
+			PUrl: purl.Package(opts.OS.ID, pkg),
+			// TODO(kaniini): Talk with CycloneDX people about adding "package" type.
+			Type: "operating-system",
+		}
+
+		pkgComponents = append(pkgComponents, c)
+
+		// walk the dependency list
+		depRefs := []string{}
+		for _, dep := range pkg.Dependencies {
+			// TODO(kaniini): Properly handle virtual dependencies...
+			if strings.ContainsRune(dep, ':') {
+				continue
+			}
+
+			i := strings.IndexAny(dep, " ~<>=/!")
+			if i > -1 {
+				dep = dep[:i]
+			}
+			if dep == "" {
+				continue
+			}
+
+			depRefs = append(depRefs, fmt.Sprintf("pkg:apk/%s/%s", opts.OS.ID, dep))
+		}
+
+		d := Dependency{
+			Ref:       purl.Package(opts.OS.ID, pkg),
+			DependsOn: depRefs,
+		}
+		pkgDependencies = append(pkgDependencies, d)
+	}
+
+	rootComponent := Component{
+		BOMRef:     fmt.Sprintf("pkg:apk/%s", opts.OS.ID),
+		Name:       opts.OS.Name,
+		Version:    opts.OS.Version,
+		Type:       "operating-system",
+		Components: pkgComponents,
+	}
+
+	bom := Document{
+		BOMFormat:    "CycloneDX",
+		SpecVersion:  "1.4",
+		Version:      1,
+		Components:   []Component{rootComponent},
+		Dependencies: pkgDependencies,
+	}
+
+	out, err := os.Create(path)
+	if err != nil {
+		return fmt.Errorf("opening SBOM path %s for writing: %w", path, err)
+	}
+	defer out.Close()
+
+	enc := json.NewEncoder(out)
+	enc.SetIndent("", "  ")
+
+	if err := enc.Encode(bom); err != nil {
+		return fmt.Errorf("encoding BOM: %w", err)
+	}
+	return nil
+}
+
+// TODO(kaniini): Move most of this over to gitlab.alpinelinux.org/alpine/go.
+type Document struct {
+	BOMFormat    string       `json:"bomFormat"`
+	SpecVersion  string       `json:"specVersion"`
+	Version      int          `json:"version"`
+	Components   []Component  `json:"components,omitempty"`
+	Dependencies []Dependency `json:"dependencies,omitempty"`
+}
+
+type Component struct {
+	BOMRef             string              `json:"bom-ref"`
+	Type               string              `json:"type"`
+	Name               string              `json:"name"`
+	Version            string              `json:"version"`
+	Description        string              `json:"description"`
+	PUrl               string              `json:"purl"`
+	ExternalReferences []ExternalReference `json:"externalReferences,omitempty"`
+	Licenses           []License           `json:"licenses,omitempty"`
+	Components         []Component         `json:"components,omitempty"`
+}
+
+type License struct {
+	Expression string `json:"expression"`
+}
+
+type ExternalReference struct {
+	URL  string `json:"url"`
+	Type string `json:"type"`
+}
+
+type Dependency struct {
+	Ref       string   `json:"ref"`
+	DependsOn []string `json:"dependsOn"`
+}
diff --git a/pkg/sbom/generator/generator.go b/pkg/sbom/generator/generator.go
new file mode 100644
index 0000000..df8a612
--- /dev/null
+++ b/pkg/sbom/generator/generator.go
@@ -0,0 +1,39 @@
+// Copyright 2022 Chainguard, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package generator
+
+//go:generate go run github.com/maxbrunsfeld/counterfeiter/v6 -generate
+
+import (
+	"chainguard.dev/apko/pkg/sbom/generator/cyclonedx"
+	"chainguard.dev/apko/pkg/sbom/options"
+)
+
+//counterfeiter:generate . Generator
+
+type Generator interface {
+	Key() string
+	Ext() string
+	Generate(*options.Options, string) error
+}
+
+func Generators() map[string]Generator {
+	generators := map[string]Generator{}
+
+	cdx := cyclonedx.New()
+	generators[cdx.Key()] = &cdx
+
+	return generators
+}
diff --git a/pkg/sbom/generator/generatorfakes/fake_generator.go b/pkg/sbom/generator/generatorfakes/fake_generator.go
new file mode 100644
index 0000000..5a0eba2
--- /dev/null
+++ b/pkg/sbom/generator/generatorfakes/fake_generator.go
@@ -0,0 +1,244 @@
+// Code generated by counterfeiter. DO NOT EDIT.
+package generatorfakes
+
+import (
+	"sync"
+
+	"chainguard.dev/apko/pkg/sbom/generator"
+	"chainguard.dev/apko/pkg/sbom/options"
+)
+
+type FakeGenerator struct {
+	ExtStub        func() string
+	extMutex       sync.RWMutex
+	extArgsForCall []struct {
+	}
+	extReturns struct {
+		result1 string
+	}
+	extReturnsOnCall map[int]struct {
+		result1 string
+	}
+	GenerateStub        func(*options.Options, string) error
+	generateMutex       sync.RWMutex
+	generateArgsForCall []struct {
+		arg1 *options.Options
+		arg2 string
+	}
+	generateReturns struct {
+		result1 error
+	}
+	generateReturnsOnCall map[int]struct {
+		result1 error
+	}
+	KeyStub        func() string
+	keyMutex       sync.RWMutex
+	keyArgsForCall []struct {
+	}
+	keyReturns struct {
+		result1 string
+	}
+	keyReturnsOnCall map[int]struct {
+		result1 string
+	}
+	invocations      map[string][][]interface{}
+	invocationsMutex sync.RWMutex
+}
+
+func (fake *FakeGenerator) Ext() string {
+	fake.extMutex.Lock()
+	ret, specificReturn := fake.extReturnsOnCall[len(fake.extArgsForCall)]
+	fake.extArgsForCall = append(fake.extArgsForCall, struct {
+	}{})
+	stub := fake.ExtStub
+	fakeReturns := fake.extReturns
+	fake.recordInvocation("Ext", []interface{}{})
+	fake.extMutex.Unlock()
+	if stub != nil {
+		return stub()
+	}
+	if specificReturn {
+		return ret.result1
+	}
+	return fakeReturns.result1
+}
+
+func (fake *FakeGenerator) ExtCallCount() int {
+	fake.extMutex.RLock()
+	defer fake.extMutex.RUnlock()
+	return len(fake.extArgsForCall)
+}
+
+func (fake *FakeGenerator) ExtCalls(stub func() string) {
+	fake.extMutex.Lock()
+	defer fake.extMutex.Unlock()
+	fake.ExtStub = stub
+}
+
+func (fake *FakeGenerator) ExtReturns(result1 string) {
+	fake.extMutex.Lock()
+	defer fake.extMutex.Unlock()
+	fake.ExtStub = nil
+	fake.extReturns = struct {
+		result1 string
+	}{result1}
+}
+
+func (fake *FakeGenerator) ExtReturnsOnCall(i int, result1 string) {
+	fake.extMutex.Lock()
+	defer fake.extMutex.Unlock()
+	fake.ExtStub = nil
+	if fake.extReturnsOnCall == nil {
+		fake.extReturnsOnCall = make(map[int]struct {
+			result1 string
+		})
+	}
+	fake.extReturnsOnCall[i] = struct {
+		result1 string
+	}{result1}
+}
+
+func (fake *FakeGenerator) Generate(arg1 *options.Options, arg2 string) error {
+	fake.generateMutex.Lock()
+	ret, specificReturn := fake.generateReturnsOnCall[len(fake.generateArgsForCall)]
+	fake.generateArgsForCall = append(fake.generateArgsForCall, struct {
+		arg1 *options.Options
+		arg2 string
+	}{arg1, arg2})
+	stub := fake.GenerateStub
+	fakeReturns := fake.generateReturns
+	fake.recordInvocation("Generate", []interface{}{arg1, arg2})
+	fake.generateMutex.Unlock()
+	if stub != nil {
+		return stub(arg1, arg2)
+	}
+	if specificReturn {
+		return ret.result1
+	}
+	return fakeReturns.result1
+}
+
+func (fake *FakeGenerator) GenerateCallCount() int {
+	fake.generateMutex.RLock()
+	defer fake.generateMutex.RUnlock()
+	return len(fake.generateArgsForCall)
+}
+
+func (fake *FakeGenerator) GenerateCalls(stub func(*options.Options, string) error) {
+	fake.generateMutex.Lock()
+	defer fake.generateMutex.Unlock()
+	fake.GenerateStub = stub
+}
+
+func (fake *FakeGenerator) GenerateArgsForCall(i int) (*options.Options, string) {
+	fake.generateMutex.RLock()
+	defer fake.generateMutex.RUnlock()
+	argsForCall := fake.generateArgsForCall[i]
+	return argsForCall.arg1, argsForCall.arg2
+}
+
+func (fake *FakeGenerator) GenerateReturns(result1 error) {
+	fake.generateMutex.Lock()
+	defer fake.generateMutex.Unlock()
+	fake.GenerateStub = nil
+	fake.generateReturns = struct {
+		result1 error
+	}{result1}
+}
+
+func (fake *FakeGenerator) GenerateReturnsOnCall(i int, result1 error) {
+	fake.generateMutex.Lock()
+	defer fake.generateMutex.Unlock()
+	fake.GenerateStub = nil
+	if fake.generateReturnsOnCall == nil {
+		fake.generateReturnsOnCall = make(map[int]struct {
+			result1 error
+		})
+	}
+	fake.generateReturnsOnCall[i] = struct {
+		result1 error
+	}{result1}
+}
+
+func (fake *FakeGenerator) Key() string {
+	fake.keyMutex.Lock()
+	ret, specificReturn := fake.keyReturnsOnCall[len(fake.keyArgsForCall)]
+	fake.keyArgsForCall = append(fake.keyArgsForCall, struct {
+	}{})
+	stub := fake.KeyStub
+	fakeReturns := fake.keyReturns
+	fake.recordInvocation("Key", []interface{}{})
+	fake.keyMutex.Unlock()
+	if stub != nil {
+		return stub()
+	}
+	if specificReturn {
+		return ret.result1
+	}
+	return fakeReturns.result1
+}
+
+func (fake *FakeGenerator) KeyCallCount() int {
+	fake.keyMutex.RLock()
+	defer fake.keyMutex.RUnlock()
+	return len(fake.keyArgsForCall)
+}
+
+func (fake *FakeGenerator) KeyCalls(stub func() string) {
+	fake.keyMutex.Lock()
+	defer fake.keyMutex.Unlock()
+	fake.KeyStub = stub
+}
+
+func (fake *FakeGenerator) KeyReturns(result1 string) {
+	fake.keyMutex.Lock()
+	defer fake.keyMutex.Unlock()
+	fake.KeyStub = nil
+	fake.keyReturns = struct {
+		result1 string
+	}{result1}
+}
+
+func (fake *FakeGenerator) KeyReturnsOnCall(i int, result1 string) {
+	fake.keyMutex.Lock()
+	defer fake.keyMutex.Unlock()
+	fake.KeyStub = nil
+	if fake.keyReturnsOnCall == nil {
+		fake.keyReturnsOnCall = make(map[int]struct {
+			result1 string
+		})
+	}
+	fake.keyReturnsOnCall[i] = struct {
+		result1 string
+	}{result1}
+}
+
+func (fake *FakeGenerator) Invocations() map[string][][]interface{} {
+	fake.invocationsMutex.RLock()
+	defer fake.invocationsMutex.RUnlock()
+	fake.extMutex.RLock()
+	defer fake.extMutex.RUnlock()
+	fake.generateMutex.RLock()
+	defer fake.generateMutex.RUnlock()
+	fake.keyMutex.RLock()
+	defer fake.keyMutex.RUnlock()
+	copiedInvocations := map[string][][]interface{}{}
+	for key, value := range fake.invocations {
+		copiedInvocations[key] = value
+	}
+	return copiedInvocations
+}
+
+func (fake *FakeGenerator) recordInvocation(key string, args []interface{}) {
+	fake.invocationsMutex.Lock()
+	defer fake.invocationsMutex.Unlock()
+	if fake.invocations == nil {
+		fake.invocations = map[string][][]interface{}{}
+	}
+	if fake.invocations[key] == nil {
+		fake.invocations[key] = [][]interface{}{}
+	}
+	fake.invocations[key] = append(fake.invocations[key], args)
+}
+
+var _ generator.Generator = new(FakeGenerator)
diff --git a/pkg/sbom/options/options.go b/pkg/sbom/options/options.go
new file mode 100644
index 0000000..353556b
--- /dev/null
+++ b/pkg/sbom/options/options.go
@@ -0,0 +1,40 @@
+// Copyright 2022 Chainguard, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package options
+
+import "gitlab.alpinelinux.org/alpine/go/pkg/repository"
+
+type Options struct {
+	OS struct {
+		Name    string
+		ID      string
+		Version string
+	}
+
+	// Working directory,inherited from buid context
+	WorkDir string
+
+	// OutputDir is the directory where the sboms will be written
+	OutputDir string
+
+	// 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 alist of packages which will be listed in the SBOM
+	Packages []*repository.Package
+}
diff --git a/pkg/sbom/purl/purl.go b/pkg/sbom/purl/purl.go
new file mode 100644
index 0000000..8c1e8fa
--- /dev/null
+++ b/pkg/sbom/purl/purl.go
@@ -0,0 +1,29 @@
+// Copyright 2022 Chainguard, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package purl
+
+import (
+	"fmt"
+
+	"gitlab.alpinelinux.org/alpine/go/pkg/repository"
+)
+
+func Package(ns string, pkg *repository.Package) string {
+	return fmt.Sprintf("pkg:apk/%s/%s", ns, pkg.Name)
+}
+
+func Versioned(ns string, pkg *repository.Package) string {
+	return fmt.Sprintf("pkg:apk/%s/%s@%s", ns, pkg.Name, pkg.Version)
+}
diff --git a/pkg/sbom/sbom.go b/pkg/sbom/sbom.go
new file mode 100644
index 0000000..5490861
--- /dev/null
+++ b/pkg/sbom/sbom.go
@@ -0,0 +1,189 @@
+// Copyright 2022 Chainguard, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package sbom
+
+//go:generate go run github.com/maxbrunsfeld/counterfeiter/v6 -generate
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+
+	"chainguard.dev/apko/pkg/sbom/generator"
+	"chainguard.dev/apko/pkg/sbom/options"
+	osr "github.com/dominodatalab/os-release"
+	"gitlab.alpinelinux.org/alpine/go/pkg/repository"
+)
+
+const (
+	osReleasePath    = "/etc/os-release"
+	packageIndexPath = "/lib/apk/db/installed"
+)
+
+var DefaultOptions = options.Options{
+	OS: struct {
+		Name    string
+		ID      string
+		Version string
+	}{
+		ID:      "alpine",
+		Name:    "Alpine Linux",
+		Version: "Unknown",
+	},
+	FileName: "sbom",
+	Formats:  []string{"cyclonedx"},
+}
+
+type SBOM struct {
+	Generators map[string]generator.Generator
+	impl       sbomImplementation
+	Options    options.Options
+}
+
+func New() *SBOM {
+	return &SBOM{
+		Generators: generator.Generators(),
+		impl:       &defaultSBOMImplementation{},
+		Options:    DefaultOptions,
+	}
+}
+
+// NewWithWorkDir returns a new sbom object with a working dir preset
+func NewWithWorkDir(path string) *SBOM {
+	s := New()
+	s.Options.WorkDir = path
+	return s
+}
+
+func (s *SBOM) SetImplementation(impl sbomImplementation) {
+	s.impl = impl
+}
+
+func (s *SBOM) ReadReleaseData() error {
+	if err := s.impl.ReadReleaseData(
+		&s.Options, filepath.Join(s.Options.WorkDir, osReleasePath),
+	); err != nil {
+		return fmt.Errorf("reading release data: %w", err)
+	}
+	return nil
+}
+
+// ReadPackageIndex parses the package index in the working directory
+// and returns a slice of the installed packages
+func (s *SBOM) ReadPackageIndex() ([]*repository.Package, error) {
+	pks, err := s.impl.ReadPackageIndex(
+		&s.Options, filepath.Join(s.Options.WorkDir, packageIndexPath),
+	)
+	if err != nil {
+		return nil, fmt.Errorf("reading apk package index: %w", err)
+	}
+	return pks, nil
+}
+
+// Generate creates the sboms according to the options set
+func (s *SBOM) Generate() ([]string, error) {
+	if err := s.impl.CheckGenerators(
+		&s.Options, s.Generators,
+	); err != nil {
+		return nil, err
+	}
+	files, err := s.impl.Generate(&s.Options, s.Generators)
+	if err != nil {
+		return nil, fmt.Errorf("generating sboms: %w", err)
+	}
+	return files, nil
+}
+
+//counterfeiter:generate . sbomImplementation
+type sbomImplementation interface {
+	ReadReleaseData(*options.Options, string) error
+	ReadPackageIndex(*options.Options, string) ([]*repository.Package, error)
+	Generate(*options.Options, map[string]generator.Generator) ([]string, error)
+	CheckGenerators(*options.Options, map[string]generator.Generator) error
+}
+
+type defaultSBOMImplementation struct{}
+
+// readReleaseDataInternal reads the information from /etc/os-release
+func (di *defaultSBOMImplementation) ReadReleaseData(opts *options.Options, path string) error {
+	osReleaseData, err := os.ReadFile(path)
+	if err != nil {
+		return fmt.Errorf("reading os-release: %w", err)
+	}
+
+	info := osr.Parse(string(osReleaseData))
+	fmt.Printf("%+v", info)
+
+	opts.OS.Name = info.Name
+	opts.OS.ID = info.ID
+	opts.OS.Version = info.VersionID
+	return nil
+}
+
+// readPackageIndex parses the apk database passed in the path
+func (di *defaultSBOMImplementation) ReadPackageIndex(
+	opts *options.Options, path string,
+) (packages []*repository.Package, err error) {
+	installedDB, err := os.Open(path)
+	if err != nil {
+		return nil, fmt.Errorf("opening APK installed db: %w", err)
+	}
+	defer installedDB.Close()
+
+	// repository.ParsePackageIndex closes the file itself
+	packages, err = repository.ParsePackageIndex(installedDB)
+	if err != nil {
+		return nil, fmt.Errorf("parsing APK installed db: %w", err)
+	}
+	return packages, nil
+}
+
+// generate creates the documents according to the specified options
+func (di *defaultSBOMImplementation) Generate(
+	opts *options.Options, generators map[string]generator.Generator,
+) ([]string, error) {
+	files := []string{}
+	for _, format := range opts.Formats {
+		path := filepath.Join(
+			opts.OutputDir, opts.FileName+"."+generators[format].Ext(),
+		)
+		if err := generators[format].Generate(opts, path); err != nil {
+			return nil, fmt.Errorf("generating %s sbom: %w", format, err)
+		}
+		files = append(files, path)
+	}
+	return files, nil
+}
+
+// checkGenerators verifies we have generators available for the
+// formats specified in the options
+func (di *defaultSBOMImplementation) CheckGenerators(
+	opts *options.Options, generators map[string]generator.Generator,
+) error {
+	if len(generators) == 0 {
+		return fmt.Errorf("no generators defined")
+	}
+	if len(opts.Formats) == 0 {
+		return fmt.Errorf("no sbom format enabled in options")
+	}
+	for _, format := range opts.Formats {
+		if _, ok := generators[format]; !ok {
+			return fmt.Errorf(
+				"unable to generate sboms: no generator available for format %s", format,
+			)
+		}
+	}
+	return nil
+}
diff --git a/pkg/sbom/sbomfakes/fake_sbom_implementation.go b/pkg/sbom/sbomfakes/fake_sbom_implementation.go
new file mode 100644
index 0000000..6b92331
--- /dev/null
+++ b/pkg/sbom/sbomfakes/fake_sbom_implementation.go
@@ -0,0 +1,351 @@
+// Code generated by counterfeiter. DO NOT EDIT.
+package sbomfakes
+
+import (
+	"sync"
+
+	"chainguard.dev/apko/pkg/sbom/generator"
+	"chainguard.dev/apko/pkg/sbom/options"
+	"gitlab.alpinelinux.org/alpine/go/pkg/repository"
+)
+
+type FakeSbomImplementation struct {
+	CheckGeneratorsStub        func(*options.Options, map[string]generator.Generator) error
+	checkGeneratorsMutex       sync.RWMutex
+	checkGeneratorsArgsForCall []struct {
+		arg1 *options.Options
+		arg2 map[string]generator.Generator
+	}
+	checkGeneratorsReturns struct {
+		result1 error
+	}
+	checkGeneratorsReturnsOnCall map[int]struct {
+		result1 error
+	}
+	GenerateStub        func(*options.Options, map[string]generator.Generator) ([]string, error)
+	generateMutex       sync.RWMutex
+	generateArgsForCall []struct {
+		arg1 *options.Options
+		arg2 map[string]generator.Generator
+	}
+	generateReturns struct {
+		result1 []string
+		result2 error
+	}
+	generateReturnsOnCall map[int]struct {
+		result1 []string
+		result2 error
+	}
+	ReadPackageIndexStub        func(*options.Options, string) ([]*repository.Package, error)
+	readPackageIndexMutex       sync.RWMutex
+	readPackageIndexArgsForCall []struct {
+		arg1 *options.Options
+		arg2 string
+	}
+	readPackageIndexReturns struct {
+		result1 []*repository.Package
+		result2 error
+	}
+	readPackageIndexReturnsOnCall map[int]struct {
+		result1 []*repository.Package
+		result2 error
+	}
+	ReadReleaseDataStub        func(*options.Options, string) error
+	readReleaseDataMutex       sync.RWMutex
+	readReleaseDataArgsForCall []struct {
+		arg1 *options.Options
+		arg2 string
+	}
+	readReleaseDataReturns struct {
+		result1 error
+	}
+	readReleaseDataReturnsOnCall map[int]struct {
+		result1 error
+	}
+	invocations      map[string][][]interface{}
+	invocationsMutex sync.RWMutex
+}
+
+func (fake *FakeSbomImplementation) CheckGenerators(arg1 *options.Options, arg2 map[string]generator.Generator) error {
+	fake.checkGeneratorsMutex.Lock()
+	ret, specificReturn := fake.checkGeneratorsReturnsOnCall[len(fake.checkGeneratorsArgsForCall)]
+	fake.checkGeneratorsArgsForCall = append(fake.checkGeneratorsArgsForCall, struct {
+		arg1 *options.Options
+		arg2 map[string]generator.Generator
+	}{arg1, arg2})
+	stub := fake.CheckGeneratorsStub
+	fakeReturns := fake.checkGeneratorsReturns
+	fake.recordInvocation("CheckGenerators", []interface{}{arg1, arg2})
+	fake.checkGeneratorsMutex.Unlock()
+	if stub != nil {
+		return stub(arg1, arg2)
+	}
+	if specificReturn {
+		return ret.result1
+	}
+	return fakeReturns.result1
+}
+
+func (fake *FakeSbomImplementation) CheckGeneratorsCallCount() int {
+	fake.checkGeneratorsMutex.RLock()
+	defer fake.checkGeneratorsMutex.RUnlock()
+	return len(fake.checkGeneratorsArgsForCall)
+}
+
+func (fake *FakeSbomImplementation) CheckGeneratorsCalls(stub func(*options.Options, map[string]generator.Generator) error) {
+	fake.checkGeneratorsMutex.Lock()
+	defer fake.checkGeneratorsMutex.Unlock()
+	fake.CheckGeneratorsStub = stub
+}
+
+func (fake *FakeSbomImplementation) CheckGeneratorsArgsForCall(i int) (*options.Options, map[string]generator.Generator) {
+	fake.checkGeneratorsMutex.RLock()
+	defer fake.checkGeneratorsMutex.RUnlock()
+	argsForCall := fake.checkGeneratorsArgsForCall[i]
+	return argsForCall.arg1, argsForCall.arg2
+}
+
+func (fake *FakeSbomImplementation) CheckGeneratorsReturns(result1 error) {
+	fake.checkGeneratorsMutex.Lock()
+	defer fake.checkGeneratorsMutex.Unlock()
+	fake.CheckGeneratorsStub = nil
+	fake.checkGeneratorsReturns = struct {
+		result1 error
+	}{result1}
+}
+
+func (fake *FakeSbomImplementation) CheckGeneratorsReturnsOnCall(i int, result1 error) {
+	fake.checkGeneratorsMutex.Lock()
+	defer fake.checkGeneratorsMutex.Unlock()
+	fake.CheckGeneratorsStub = nil
+	if fake.checkGeneratorsReturnsOnCall == nil {
+		fake.checkGeneratorsReturnsOnCall = make(map[int]struct {
+			result1 error
+		})
+	}
+	fake.checkGeneratorsReturnsOnCall[i] = struct {
+		result1 error
+	}{result1}
+}
+
+func (fake *FakeSbomImplementation) Generate(arg1 *options.Options, arg2 map[string]generator.Generator) ([]string, error) {
+	fake.generateMutex.Lock()
+	ret, specificReturn := fake.generateReturnsOnCall[len(fake.generateArgsForCall)]
+	fake.generateArgsForCall = append(fake.generateArgsForCall, struct {
+		arg1 *options.Options
+		arg2 map[string]generator.Generator
+	}{arg1, arg2})
+	stub := fake.GenerateStub
+	fakeReturns := fake.generateReturns
+	fake.recordInvocation("Generate", []interface{}{arg1, arg2})
+	fake.generateMutex.Unlock()
+	if stub != nil {
+		return stub(arg1, arg2)
+	}
+	if specificReturn {
+		return ret.result1, ret.result2
+	}
+	return fakeReturns.result1, fakeReturns.result2
+}
+
+func (fake *FakeSbomImplementation) GenerateCallCount() int {
+	fake.generateMutex.RLock()
+	defer fake.generateMutex.RUnlock()
+	return len(fake.generateArgsForCall)
+}
+
+func (fake *FakeSbomImplementation) GenerateCalls(stub func(*options.Options, map[string]generator.Generator) ([]string, error)) {
+	fake.generateMutex.Lock()
+	defer fake.generateMutex.Unlock()
+	fake.GenerateStub = stub
+}
+
+func (fake *FakeSbomImplementation) GenerateArgsForCall(i int) (*options.Options, map[string]generator.Generator) {
+	fake.generateMutex.RLock()
+	defer fake.generateMutex.RUnlock()
+	argsForCall := fake.generateArgsForCall[i]
+	return argsForCall.arg1, argsForCall.arg2
+}
+
+func (fake *FakeSbomImplementation) GenerateReturns(result1 []string, result2 error) {
+	fake.generateMutex.Lock()
+	defer fake.generateMutex.Unlock()
+	fake.GenerateStub = nil
+	fake.generateReturns = struct {
+		result1 []string
+		result2 error
+	}{result1, result2}
+}
+
+func (fake *FakeSbomImplementation) GenerateReturnsOnCall(i int, result1 []string, result2 error) {
+	fake.generateMutex.Lock()
+	defer fake.generateMutex.Unlock()
+	fake.GenerateStub = nil
+	if fake.generateReturnsOnCall == nil {
+		fake.generateReturnsOnCall = make(map[int]struct {
+			result1 []string
+			result2 error
+		})
+	}
+	fake.generateReturnsOnCall[i] = struct {
+		result1 []string
+		result2 error
+	}{result1, result2}
+}
+
+func (fake *FakeSbomImplementation) ReadPackageIndex(arg1 *options.Options, arg2 string) ([]*repository.Package, error) {
+	fake.readPackageIndexMutex.Lock()
+	ret, specificReturn := fake.readPackageIndexReturnsOnCall[len(fake.readPackageIndexArgsForCall)]
+	fake.readPackageIndexArgsForCall = append(fake.readPackageIndexArgsForCall, struct {
+		arg1 *options.Options
+		arg2 string
+	}{arg1, arg2})
+	stub := fake.ReadPackageIndexStub
+	fakeReturns := fake.readPackageIndexReturns
+	fake.recordInvocation("ReadPackageIndex", []interface{}{arg1, arg2})
+	fake.readPackageIndexMutex.Unlock()
+	if stub != nil {
+		return stub(arg1, arg2)
+	}
+	if specificReturn {
+		return ret.result1, ret.result2
+	}
+	return fakeReturns.result1, fakeReturns.result2
+}
+
+func (fake *FakeSbomImplementation) ReadPackageIndexCallCount() int {
+	fake.readPackageIndexMutex.RLock()
+	defer fake.readPackageIndexMutex.RUnlock()
+	return len(fake.readPackageIndexArgsForCall)
+}
+
+func (fake *FakeSbomImplementation) ReadPackageIndexCalls(stub func(*options.Options, string) ([]*repository.Package, error)) {
+	fake.readPackageIndexMutex.Lock()
+	defer fake.readPackageIndexMutex.Unlock()
+	fake.ReadPackageIndexStub = stub
+}
+
+func (fake *FakeSbomImplementation) ReadPackageIndexArgsForCall(i int) (*options.Options, string) {
+	fake.readPackageIndexMutex.RLock()
+	defer fake.readPackageIndexMutex.RUnlock()
+	argsForCall := fake.readPackageIndexArgsForCall[i]
+	return argsForCall.arg1, argsForCall.arg2
+}
+
+func (fake *FakeSbomImplementation) ReadPackageIndexReturns(result1 []*repository.Package, result2 error) {
+	fake.readPackageIndexMutex.Lock()
+	defer fake.readPackageIndexMutex.Unlock()
+	fake.ReadPackageIndexStub = nil
+	fake.readPackageIndexReturns = struct {
+		result1 []*repository.Package
+		result2 error
+	}{result1, result2}
+}
+
+func (fake *FakeSbomImplementation) ReadPackageIndexReturnsOnCall(i int, result1 []*repository.Package, result2 error) {
+	fake.readPackageIndexMutex.Lock()
+	defer fake.readPackageIndexMutex.Unlock()
+	fake.ReadPackageIndexStub = nil
+	if fake.readPackageIndexReturnsOnCall == nil {
+		fake.readPackageIndexReturnsOnCall = make(map[int]struct {
+			result1 []*repository.Package
+			result2 error
+		})
+	}
+	fake.readPackageIndexReturnsOnCall[i] = struct {
+		result1 []*repository.Package
+		result2 error
+	}{result1, result2}
+}
+
+func (fake *FakeSbomImplementation) ReadReleaseData(arg1 *options.Options, arg2 string) error {
+	fake.readReleaseDataMutex.Lock()
+	ret, specificReturn := fake.readReleaseDataReturnsOnCall[len(fake.readReleaseDataArgsForCall)]
+	fake.readReleaseDataArgsForCall = append(fake.readReleaseDataArgsForCall, struct {
+		arg1 *options.Options
+		arg2 string
+	}{arg1, arg2})
+	stub := fake.ReadReleaseDataStub
+	fakeReturns := fake.readReleaseDataReturns
+	fake.recordInvocation("ReadReleaseData", []interface{}{arg1, arg2})
+	fake.readReleaseDataMutex.Unlock()
+	if stub != nil {
+		return stub(arg1, arg2)
+	}
+	if specificReturn {
+		return ret.result1
+	}
+	return fakeReturns.result1
+}
+
+func (fake *FakeSbomImplementation) ReadReleaseDataCallCount() int {
+	fake.readReleaseDataMutex.RLock()
+	defer fake.readReleaseDataMutex.RUnlock()
+	return len(fake.readReleaseDataArgsForCall)
+}
+
+func (fake *FakeSbomImplementation) ReadReleaseDataCalls(stub func(*options.Options, string) error) {
+	fake.readReleaseDataMutex.Lock()
+	defer fake.readReleaseDataMutex.Unlock()
+	fake.ReadReleaseDataStub = stub
+}
+
+func (fake *FakeSbomImplementation) ReadReleaseDataArgsForCall(i int) (*options.Options, string) {
+	fake.readReleaseDataMutex.RLock()
+	defer fake.readReleaseDataMutex.RUnlock()
+	argsForCall := fake.readReleaseDataArgsForCall[i]
+	return argsForCall.arg1, argsForCall.arg2
+}
+
+func (fake *FakeSbomImplementation) ReadReleaseDataReturns(result1 error) {
+	fake.readReleaseDataMutex.Lock()
+	defer fake.readReleaseDataMutex.Unlock()
+	fake.ReadReleaseDataStub = nil
+	fake.readReleaseDataReturns = struct {
+		result1 error
+	}{result1}
+}
+
+func (fake *FakeSbomImplementation) ReadReleaseDataReturnsOnCall(i int, result1 error) {
+	fake.readReleaseDataMutex.Lock()
+	defer fake.readReleaseDataMutex.Unlock()
+	fake.ReadReleaseDataStub = nil
+	if fake.readReleaseDataReturnsOnCall == nil {
+		fake.readReleaseDataReturnsOnCall = make(map[int]struct {
+			result1 error
+		})
+	}
+	fake.readReleaseDataReturnsOnCall[i] = struct {
+		result1 error
+	}{result1}
+}
+
+func (fake *FakeSbomImplementation) Invocations() map[string][][]interface{} {
+	fake.invocationsMutex.RLock()
+	defer fake.invocationsMutex.RUnlock()
+	fake.checkGeneratorsMutex.RLock()
+	defer fake.checkGeneratorsMutex.RUnlock()
+	fake.generateMutex.RLock()
+	defer fake.generateMutex.RUnlock()
+	fake.readPackageIndexMutex.RLock()
+	defer fake.readPackageIndexMutex.RUnlock()
+	fake.readReleaseDataMutex.RLock()
+	defer fake.readReleaseDataMutex.RUnlock()
+	copiedInvocations := map[string][][]interface{}{}
+	for key, value := range fake.invocations {
+		copiedInvocations[key] = value
+	}
+	return copiedInvocations
+}
+
+func (fake *FakeSbomImplementation) recordInvocation(key string, args []interface{}) {
+	fake.invocationsMutex.Lock()
+	defer fake.invocationsMutex.Unlock()
+	if fake.invocations == nil {
+		fake.invocations = map[string][][]interface{}{}
+	}
+	if fake.invocations[key] == nil {
+		fake.invocations[key] = [][]interface{}{}
+	}
+	fake.invocations[key] = append(fake.invocations[key], args)
+}


Test patch:
diff --git a/pkg/sbom/sbom_test.go b/pkg/sbom/sbom_test.go
new file mode 100644
index 0000000..5e4cdef
--- /dev/null
+++ b/pkg/sbom/sbom_test.go
@@ -0,0 +1,144 @@
+// Copyright 2022 Chainguard, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package sbom_test
+
+import (
+	"fmt"
+	"testing"
+
+	"chainguard.dev/apko/pkg/sbom"
+	"chainguard.dev/apko/pkg/sbom/sbomfakes"
+	"github.com/stretchr/testify/require"
+	"gitlab.alpinelinux.org/alpine/go/pkg/repository"
+)
+
+var errFake = fmt.Errorf("synthetic error")
+
+func TestGenerate(t *testing.T) {
+	for _, tc := range []struct {
+		prepare func(*sbomfakes.FakeSbomImplementation)
+		assert  func([]string, error)
+	}{
+		{
+			// CheckGenerators errors
+			prepare: func(fsi *sbomfakes.FakeSbomImplementation) {
+				fsi.CheckGeneratorsReturns(errFake)
+			},
+			assert: func(s []string, err error) {
+				require.Error(t, err)
+			},
+		},
+		{
+			// Generate fails
+			prepare: func(fsi *sbomfakes.FakeSbomImplementation) {
+				fsi.CheckGeneratorsReturns(nil)
+				fsi.GenerateReturns(nil, errFake)
+			},
+			assert: func(s []string, err error) {
+				require.Error(t, err)
+			},
+		},
+		{
+			// Success
+			prepare: func(fsi *sbomfakes.FakeSbomImplementation) {
+				fsi.GenerateReturns([]string{"/path/to/sbom.cdx"}, nil)
+			},
+			assert: func(s []string, err error) {
+				require.GreaterOrEqual(t, len(s), 1)
+				require.NoError(t, err)
+			},
+		},
+	} {
+		mock := &sbomfakes.FakeSbomImplementation{}
+		tc.prepare(mock)
+
+		sut := sbom.SBOM{}
+		sut.SetImplementation(mock)
+
+		obj, err := sut.Generate()
+		tc.assert(obj, err)
+	}
+}
+
+func TestReadPackageIndes(t *testing.T) {
+	for _, tc := range []struct {
+		prepare func(*sbomfakes.FakeSbomImplementation)
+		assert  func([]*repository.Package, error)
+	}{
+		{
+			// ReadPackageIndex fails
+			prepare: func(fsi *sbomfakes.FakeSbomImplementation) {
+				fsi.ReadPackageIndexReturns(nil, errFake)
+			},
+			assert: func(pkgs []*repository.Package, err error) {
+				require.Error(t, err)
+			},
+		},
+		{
+			// Success
+			prepare: func(fsi *sbomfakes.FakeSbomImplementation) {
+				fsi.ReadPackageIndexReturns([]*repository.Package{{}}, nil)
+			},
+			assert: func(pkgs []*repository.Package, err error) {
+				require.GreaterOrEqual(t, len(pkgs), 1)
+				require.NoError(t, err)
+			},
+		},
+	} {
+		mock := &sbomfakes.FakeSbomImplementation{}
+		tc.prepare(mock)
+
+		sut := sbom.SBOM{}
+		sut.SetImplementation(mock)
+
+		obj, err := sut.ReadPackageIndex()
+		tc.assert(obj, err)
+	}
+}
+
+func TestReadReleaseData(t *testing.T) {
+	for _, tc := range []struct {
+		prepare func(*sbomfakes.FakeSbomImplementation)
+		assert  func(error)
+	}{
+		{
+			// ReadReleaseData fails
+			prepare: func(fsi *sbomfakes.FakeSbomImplementation) {
+				fsi.ReadReleaseDataReturns(errFake)
+			},
+			assert: func(err error) {
+				require.Error(t, err)
+			},
+		},
+		{
+			// Success
+			prepare: func(fsi *sbomfakes.FakeSbomImplementation) {
+				fsi.ReadReleaseDataReturns(nil)
+			},
+			assert: func(err error) {
+				require.NoError(t, err)
+			},
+		},
+	} {
+		mock := &sbomfakes.FakeSbomImplementation{}
+		tc.prepare(mock)
+
+		sut := sbom.SBOM{}
+		sut.SetImplementation(mock)
+
+		err := sut.ReadReleaseData()
+		tc.assert(err)
+	}
+}
diff --git a/pkg/sbom/sbom_unit_test.go b/pkg/sbom/sbom_unit_test.go
new file mode 100644
index 0000000..822b1f3
--- /dev/null
+++ b/pkg/sbom/sbom_unit_test.go
@@ -0,0 +1,201 @@
+// Copyright 2022 Chainguard, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package sbom
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"chainguard.dev/apko/pkg/sbom/generator"
+	"chainguard.dev/apko/pkg/sbom/generator/generatorfakes"
+
+	"chainguard.dev/apko/pkg/sbom/options"
+	"github.com/stretchr/testify/require"
+)
+
+var errFake = fmt.Errorf("synthetic error")
+
+func TestReadReleaseData(t *testing.T) {
+	osinfoData := `NAME="Alpine Linux"
+ID=alpine
+VERSION_ID=3.15.0
+PRETTY_NAME="Alpine Linux v3.15"
+HOME_URL="https://alpinelinux.org/"
+BUG_REPORT_URL="https://bugs.alpinelinux.org/"
+`
+	tdir := t.TempDir()
+	require.NoError(
+		t, os.WriteFile(
+			filepath.Join(tdir, "os-release"), []byte(osinfoData), os.FileMode(0o644),
+		),
+	)
+	di := defaultSBOMImplementation{}
+
+	// Non existent file, should err
+	require.Error(t, di.ReadReleaseData(&options.Options{}, filepath.Join(tdir, "non-existent")))
+	opts := options.Options{}
+	require.NoError(t, di.ReadReleaseData(&opts, filepath.Join(tdir, "os-release")))
+	require.Equal(t, "alpine", opts.OS.ID)
+	require.Equal(t, "Alpine Linux", opts.OS.Name)
+	require.Equal(t, "3.15.0", opts.OS.Version)
+}
+
+func TestReadPackageIndex(t *testing.T) {
+	sampleDB := `
+C:Q1Deb0jNytkrjPW4N/eKLZ43BwOlw=
+P:musl
+V:1.2.2-r7
+A:x86_64
+S:383152
+I:622592
+T:the musl c library (libc) implementation
+U:https://musl.libc.org/
+L:MIT
+o:musl
+m:Pkg Author <user@domain.com>
+t:1632431095
+c:bf5bbfdbf780092f387b7abe401fbfceda90c84d
+p:so:libc.musl-x86_64.so.1=1
+F:lib
+R:ld-musl-x86_64.so.1
+a:0:0:755
+Z:Q12adwqQOjo9dFl+VJD2Ecd901vhE=
+R:libc.musl-x86_64.so.1
+a:0:0:777
+Z:Q17yJ3JFNypA4mxhJJr0ou6CzsJVI=
+
+C:Q1UQjutTNeqKQgMlKQyyZFnumOg3c=
+P:libretls
+V:3.3.4-r2
+A:x86_64
+S:29183
+I:86016
+T:port of libtls from libressl to openssl
+U:https://git.causal.agency/libretls/
+L:ISC AND (BSD-3-Clause OR MIT)
+o:libretls
+m:Pkg Author <user@domain.com>
+t:1634364270
+c:670bf5a8cc5bc605eede8ca2fd55b50a5c9f8660
+D:ca-certificates-bundle so:libc.musl-x86_64.so.1 so:libcrypto.so.1.1 so:libssl.so.1.1
+p:so:libtls.so.2=2.0.3
+F:usr
+F:usr/lib
+R:libtls.so.2
+a:0:0:777
+Z:Q1nNEC9T/t6W+Ecm0DxqMUnRvcT6k=
+R:libtls.so.2.0.3
+a:0:0:755
+Z:Q1/KAM0XSmA+YShex9ZKehdaf+mjw=
+
+`
+	tdir := t.TempDir()
+	require.NoError(
+		t, os.WriteFile(
+			filepath.Join(tdir, "installed"), []byte(sampleDB), os.FileMode(0o644),
+		),
+	)
+
+	// Write an invalid DB
+	require.NoError(
+		t, os.WriteFile(
+			filepath.Join(tdir, "installed-corrupt"),
+			[]byte("sldkjflskdjflsjdflkjsdlfkjsldfkj\nskdjfhksjdhfkjhsdkfjhksdjhf"),
+			os.FileMode(0o644),
+		),
+	)
+
+	di := defaultSBOMImplementation{}
+
+	// Non existent file must fail
+	opts := &options.Options{}
+	_, err := di.ReadPackageIndex(opts, filepath.Join(tdir, "non-existent"))
+	require.Error(t, err)
+	_, err = di.ReadPackageIndex(opts, filepath.Join(tdir, "installed-corrupt"))
+	require.Error(t, err)
+	pkg, err := di.ReadPackageIndex(opts, filepath.Join(tdir, "installed"))
+	require.NoError(t, err)
+	require.NotNil(t, pkg)
+	require.Len(t, pkg, 2)
+}
+
+func TestCheckGenerators(t *testing.T) {
+	di := defaultSBOMImplementation{}
+	gen := generatorfakes.FakeGenerator{}
+
+	// No generators set
+	require.Error(t, di.CheckGenerators(
+		&options.Options{Formats: []string{"cyclonedx"}},
+		map[string]generator.Generator{},
+	))
+	// No generators enabled in the options
+	require.Error(t, di.CheckGenerators(
+		&options.Options{Formats: []string{}},
+		map[string]generator.Generator{"fake": &gen},
+	))
+	// No generator for specified format
+	require.Error(t, di.CheckGenerators(
+		&options.Options{Formats: []string{"cyclonedx"}},
+		map[string]generator.Generator{"fake": &gen},
+	))
+	// Success
+	require.NoError(t, di.CheckGenerators(
+		&options.Options{Formats: []string{"fake"}},
+		map[string]generator.Generator{"fake": &gen},
+	))
+}
+
+func TestGenerate(t *testing.T) {
+	di := defaultSBOMImplementation{}
+	outputDir := "/path/to/sbom"
+	formats := []string{"fake"}
+
+	for _, tc := range []struct {
+		prepare func(*generatorfakes.FakeGenerator)
+		opts    options.Options
+		assert  func([]string, error)
+	}{
+		{
+			// Success
+			prepare: func(fg *generatorfakes.FakeGenerator) {
+				fg.GenerateReturns(nil)
+			},
+			opts: options.Options{OutputDir: outputDir, Formats: formats},
+			assert: func(sboms []string, err error) {
+				require.NoError(t, err)
+				require.GreaterOrEqual(t, len(sboms), 1)
+			},
+		},
+		{
+			// Generate fails
+			prepare: func(fg *generatorfakes.FakeGenerator) {
+				fg.GenerateReturns(errFake)
+			},
+			opts: options.Options{OutputDir: outputDir, Formats: formats},
+			assert: func(s []string, err error) {
+				require.Error(t, err)
+			},
+		},
+	} {
+		mock := &generatorfakes.FakeGenerator{}
+		tc.prepare(mock)
+		res, err := di.Generate(
+			&tc.opts, map[string]generator.Generator{"fake": mock},
+		)
+		tc.assert(res, err)
+	}
+}