Repository: osbuild/osbuild-composer
Pull request #220 (2020-02-02 22:31:38)

Prepare to switch from dnf to rpm in our pipelines
These preliminary commits adds the needed low-level features to be able to use the rpm rather than the dnf stage. The switchover to change the produced pipelines is not yet done, so this can be merged independently of the required changes in osbuild.

teg: Rebased on master, fixed according to the reviews and dropped the final commit that changes the output of osbuild-pipeline. This should be done after osbuild itself has been adjusted.

Patch:
diff --git a/cmd/osbuild-pipeline/main.go b/cmd/osbuild-pipeline/main.go
index ce894ebb7..5832a689b 100644
--- a/cmd/osbuild-pipeline/main.go
+++ b/cmd/osbuild-pipeline/main.go
@@ -4,10 +4,11 @@ import (
 	"encoding/json"
 	"flag"
 	"fmt"
-	"github.com/osbuild/osbuild-composer/internal/common"
 	"io/ioutil"
 	"os"
 
+	"github.com/osbuild/osbuild-composer/internal/common"
+
 	"github.com/osbuild/osbuild-composer/internal/blueprint"
 	"github.com/osbuild/osbuild-composer/internal/distro"
 	"github.com/osbuild/osbuild-composer/internal/rpmmd"
@@ -29,7 +30,7 @@ func main() {
 		flag.Usage()
 		return
 	}
-	
+
 	// Validate architecture
 	if !common.ArchitectureExists(archArg) {
 		_, _ = fmt.Fprintf(os.Stderr, "The provided architecture (%s) is not supported. Use one of these:\n", archArg)
@@ -47,7 +48,7 @@ func main() {
 		}
 		return
 	}
-	
+
 	// Validate image type
 
 	blueprint := &blueprint.Blueprint{}
@@ -80,13 +81,28 @@ func main() {
 		}
 	}
 
+	pkgs, exclude_pkgs, err := d.BasePackages(imageType, archArg)
+	if err != nil {
+		panic("could not get base packages: " + err.Error())
+	}
+	packages = append(pkgs, packages...)
+
 	rpmmd := rpmmd.NewRPMMD()
-	_, checksums, err := rpmmd.Depsolve(packages, d.Repositories(archArg), true)
+	packageSpecs, checksums, err := rpmmd.Depsolve(packages, exclude_pkgs, d.Repositories(archArg), d.ModulePlatformID(), false)
 	if err != nil {
-		panic(err.Error())
+		panic("Could not depsolve: " + err.Error())
+	}
+
+	buildPkgs, err := d.BuildPackages(archArg)
+	if err != nil {
+		panic("Could not get build packages: " + err.Error())
+	}
+	buildPackageSpecs, _, err := rpmmd.Depsolve(buildPkgs, nil, d.Repositories(archArg), d.ModulePlatformID(), false)
+	if err != nil {
+		panic("Could not depsolve build packages: " + err.Error())
 	}
 
-	pipeline, err := d.Pipeline(blueprint, nil, checksums, archArg, imageType, 0)
+	pipeline, err := d.Pipeline(blueprint, nil, packageSpecs, buildPackageSpecs, checksums, archArg, imageType, 0)
 	if err != nil {
 		panic(err.Error())
 	}
diff --git a/cmd/osbuild-worker/main.go b/cmd/osbuild-worker/main.go
index bfaf03c04..ebadd1514 100644
--- a/cmd/osbuild-worker/main.go
+++ b/cmd/osbuild-worker/main.go
@@ -6,7 +6,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
-	"github.com/osbuild/osbuild-composer/internal/compose"
+	"io"
 	"log"
 	"net"
 	"net/http"
@@ -55,10 +55,11 @@ func (c *ComposerClient) AddJob() (*jobqueue.Job, error) {
 	return job, nil
 }
 
-func (c *ComposerClient) UpdateJob(job *jobqueue.Job, status common.ImageBuildState, image *compose.Image, result *common.ComposeResult) error {
+func (c *ComposerClient) UpdateJob(job *jobqueue.Job, status common.ImageBuildState, result *common.ComposeResult) error {
 	var b bytes.Buffer
-	json.NewEncoder(&b).Encode(&jobqueue.JobStatus{status, job.ImageBuildID, image, result})
-	req, err := http.NewRequest("PATCH", "http://localhost/job-queue/v1/jobs/"+job.ID.String(), &b)
+	json.NewEncoder(&b).Encode(&jobqueue.JobStatus{status, result})
+	url := fmt.Sprintf("http://localhost/job-queue/v1/jobs/%s/builds/%d", job.ID.String(), job.ImageBuildID)
+	req, err := http.NewRequest("PATCH", url, &b)
 	if err != nil {
 		return err
 	}
@@ -77,6 +78,14 @@ func (c *ComposerClient) UpdateJob(job *jobqueue.Job, status common.ImageBuildSt
 	return nil
 }
 
+func (c *ComposerClient) UploadImage(job *jobqueue.Job, reader io.Reader) error {
+	// content type doesn't really matter
+	url := fmt.Sprintf("http://localhost/job-queue/v1/jobs/%s/builds/%d/image", job.ID.String(), job.ImageBuildID)
+	_, err := c.client.Post(url, "application/octet-stream", reader)
+
+	return err
+}
+
 func handleJob(client *ComposerClient) error {
 	fmt.Println("Waiting for a new job...")
 	job, err := client.AddJob()
@@ -84,19 +93,19 @@ func handleJob(client *ComposerClient) error {
 		return err
 	}
 
-	err = client.UpdateJob(job, common.IBRunning, nil, nil)
+	err = client.UpdateJob(job, common.IBRunning, nil)
 	if err != nil {
 		return err
 	}
 
 	fmt.Printf("Running job %s\n", job.ID.String())
-	image, result, err := job.Run()
+	result, err := job.Run(client)
 	if err != nil {
 		log.Printf("  Job failed: %v", err)
-		return client.UpdateJob(job, common.IBFailed, nil, result)
+		return client.UpdateJob(job, common.IBFailed, result)
 	}
 
-	return client.UpdateJob(job, common.IBFinished, image, result)
+	return client.UpdateJob(job, common.IBFinished, result)
 }
 
 func main() {
diff --git a/dnf-json b/dnf-json
index 80a4981ad..4731675c4 100644
--- a/dnf-json
+++ b/dnf-json
@@ -3,9 +3,11 @@
 import datetime
 import dnf
 import hashlib
+import hawkey
 import json
 import shutil
 import sys
+import tempfile
 
 DNF_ERROR_EXIT_CODE = 10
 
@@ -36,8 +38,13 @@ def dnfrepo(desc, parent_conf=None):
     return repo
 
 
-def create_base(repos, clean=False):
+def create_base(repos, module_platform_id, persistdir, cachedir, clean=False):
     base = dnf.Base()
+    base.conf.module_platform_id = module_platform_id
+    base.conf.config_file_path = "/dev/null"
+    base.conf.persistdir = persistdir
+    if cachedir:
+        base.conf.cachedir = cachedir
 
     if clean:
         shutil.rmtree(base.conf.cachedir, ignore_errors=True)
@@ -80,53 +87,60 @@ def repo_checksums(base):
 
 call = json.load(sys.stdin)
 command = call["command"]
-arguments = call.get("arguments", {})
-
-if command == "dump":
-    base = create_base(arguments.get("repos", {}), arguments.get("clean", False))
-    packages = []
-    for package in base.sack.query().available():
-        packages.append({
-            "name": package.name,
-            "summary": package.summary,
-            "description": package.description,
-            "url": package.url,
-            "epoch": package.epoch,
-            "version": package.version,
-            "release": package.release,
-            "arch": package.arch,
-            "buildtime": timestamp_to_rfc3339(package.buildtime),
-            "license": package.license
-        })
-    json.dump({
-        "checksums": repo_checksums(base),
-        "packages": packages
-    }, sys.stdout)
-
-elif command == "depsolve":
-    base = create_base(arguments.get("repos", {}), arguments.get("clean", False))
-    errors = []
-
-    try:
-        base.install_specs(arguments["package-specs"])
-    except dnf.exceptions.MarkingErrors as e:
-        exit_with_dnf_error("MarkingErrors", f"Error occurred when marking packages for installation: {e}")
-
-    try:
-        base.resolve()
-    except dnf.exceptions.DepsolveError as e:
-        exit_with_dnf_error("DepsolveError", f"There was a problem depsolving {arguments['package-specs']}: {e}")
-
-    dependencies = []
-    for package in base.transaction.install_set:
-        dependencies.append({
-            "name": package.name,
-            "epoch": package.epoch,
-            "version": package.version,
-            "release": package.release,
-            "arch": package.arch
-        })
-    json.dump({
-        "checksums": repo_checksums(base),
-        "dependencies": dependencies
-    }, sys.stdout)
+arguments = call["arguments"]
+repos = arguments.get("repos", {})
+clean = arguments.get("clean", False)
+cachedir = arguments.get("cachedir", None)
+module_platform_id = arguments["module_platform_id"]
+
+with tempfile.TemporaryDirectory() as persistdir:
+    base = create_base(repos, module_platform_id, persistdir, cachedir, clean)
+
+    if command == "dump":
+        packages = []
+        for package in base.sack.query().available():
+            packages.append({
+                "name": package.name,
+                "summary": package.summary,
+                "description": package.description,
+                "url": package.url,
+                "epoch": package.epoch,
+                "version": package.version,
+                "release": package.release,
+                "arch": package.arch,
+                "buildtime": timestamp_to_rfc3339(package.buildtime),
+                "license": package.license
+            })
+        json.dump({
+            "checksums": repo_checksums(base),
+            "packages": packages
+        }, sys.stdout)
+
+    elif command == "depsolve":
+        errors = []
+
+        try:
+            base.install_specs(arguments["package-specs"], exclude=arguments.get("exclude-specs", []))
+        except dnf.exceptions.MarkingErrors as e:
+            exit_with_dnf_error("MarkingErrors", f"Error occurred when marking packages for installation: {e}")
+
+        try:
+            base.resolve()
+        except dnf.exceptions.DepsolveError as e:
+            exit_with_dnf_error("DepsolveError", f"There was a problem depsolving {arguments['package-specs']}: {e}")
+
+        dependencies = []
+        for package in base.transaction.install_set:
+            dependencies.append({
+                "name": package.name,
+                "epoch": package.epoch,
+                "version": package.version,
+                "release": package.release,
+                "arch": package.arch,
+                "remote_location": package.remote_location(),
+                "checksum": f"{hawkey.chksum_name(package.chksum[0])}:{package.chksum[1].hex()}",
+            })
+        json.dump({
+            "checksums": repo_checksums(base),
+            "dependencies": dependencies
+        }, sys.stdout)
diff --git a/internal/compose/compose.go b/internal/compose/compose.go
index 232f7fa92..547a2fcd1 100644
--- a/internal/compose/compose.go
+++ b/internal/compose/compose.go
@@ -3,11 +3,12 @@
 package compose
 
 import (
+	"time"
+
 	"github.com/osbuild/osbuild-composer/internal/blueprint"
 	"github.com/osbuild/osbuild-composer/internal/common"
-	"github.com/osbuild/osbuild-composer/internal/pipeline"
+	"github.com/osbuild/osbuild-composer/internal/osbuild"
 	"github.com/osbuild/osbuild-composer/internal/target"
-	"time"
 )
 
 type StateTransitionError struct {
@@ -31,7 +32,7 @@ type ImageBuild struct {
 	Distro      common.Distribution    `json:"distro"`
 	QueueStatus common.ImageBuildState `json:"queue_status"`
 	ImageType   common.ImageType       `json:"image_type"`
-	Pipeline    *pipeline.Pipeline     `json:"pipeline"`
+	Pipeline    *osbuild.Pipeline      `json:"pipeline"`
 	Targets     []*target.Target       `json:"targets"`
 	JobCreated  time.Time              `json:"job_created"`
 	JobStarted  time.Time              `json:"job_started"`
@@ -47,7 +48,7 @@ func (ib *ImageBuild) DeepCopy() ImageBuild {
 		imageCopy := *ib.Image
 		newImagePtr = &imageCopy
 	}
-	var newPipelinePtr *pipeline.Pipeline = nil
+	var newPipelinePtr *osbuild.Pipeline = nil
 	if ib.Pipeline != nil {
 		pipelineCopy := *ib.Pipeline
 		newPipelinePtr = &pipelineCopy
@@ -71,6 +72,16 @@ func (ib *ImageBuild) DeepCopy() ImageBuild {
 	}
 }
 
+func (ib *ImageBuild) HasLocalTarget() bool {
+	for _, t := range ib.Targets {
+		if _, ok := t.Options.(*target.LocalTargetOptions); ok {
+			return true
+		}
+	}
+
+	return false
+}
+
 // A Compose represent the task of building a set of images from a single blueprint.
 // It contains all the information necessary to generate the inputs for the job, as
 // well as the job's state.
diff --git a/internal/distro/distro.go b/internal/distro/distro.go
index 4b25cdd32..cf74fa813 100644
--- a/internal/distro/distro.go
+++ b/internal/distro/distro.go
@@ -3,13 +3,14 @@ package distro
 import (
 	"bufio"
 	"errors"
-	"github.com/osbuild/osbuild-composer/internal/common"
 	"io"
 	"os"
 	"strings"
 
+	"github.com/osbuild/osbuild-composer/internal/common"
+	"github.com/osbuild/osbuild-composer/internal/osbuild"
+
 	"github.com/osbuild/osbuild-composer/internal/blueprint"
-	"github.com/osbuild/osbuild-composer/internal/pipeline"
 	"github.com/osbuild/osbuild-composer/internal/rpmmd"
 
 	"github.com/osbuild/osbuild-composer/internal/distro/fedora30"
@@ -24,6 +25,10 @@ type Distro interface {
 	// Return strong-typed distribution
 	Distribution() common.Distribution
 
+	// Returns the module platform id of the distro. This is used by DNF
+	// for modularity support.
+	ModulePlatformID() string
+
 	// Returns a list of repositories from which this distribution gets its
 	// content.
 	Repositories(arch string) []rpmmd.RepoConfig
@@ -39,10 +44,20 @@ type Distro interface {
 	// is 0 the default value for the format will be returned.
 	GetSizeForOutputType(outputFormat string, size uint64) uint64
 
+	// Returns the base packages for a given output type and architecture
+	BasePackages(outputFormat, outputArchitecture string) ([]string, []string, error)
+
+	// Returns the build packages for a given output architecture
+	BuildPackages(outputArchitecture string) ([]string, error)
+
 	// Returns an osbuild pipeline that generates an image in the given
 	// output format with all packages and customizations specified in the
 	// given blueprint.
-	Pipeline(b *blueprint.Blueprint, additionalRepos []rpmmd.RepoConfig, checksums map[string]string, outputArchitecture, outputFormat string, size uint64) (*pipeline.Pipeline, error)
+	Pipeline(b *blueprint.Blueprint, additionalRepos []rpmmd.RepoConfig, packageSpecs, buildPackageSpecs []rpmmd.PackageSpec, checksums map[string]string, outputArchitecture, outputFormat string, size uint64) (*osbuild.Pipeline, error)
+
+	// Returns an osbuild sources object that is required for building the
+	// corresponding pipeline containing the given packages.
+	Sources(packages []rpmmd.PackageSpec) *osbuild.Sources
 
 	// Returns a osbuild runner that can be used on this distro.
 	Runner() string
diff --git a/internal/distro/fedora30/distro.go b/internal/distro/fedora30/distro.go
index d72e26b61..7387c95ee 100644
--- a/internal/distro/fedora30/distro.go
+++ b/internal/distro/fedora30/distro.go
@@ -2,22 +2,24 @@ package fedora30
 
 import (
 	"errors"
-	"github.com/osbuild/osbuild-composer/internal/common"
 	"log"
 	"sort"
 	"strconv"
 
+	"github.com/osbuild/osbuild-composer/internal/common"
+	"github.com/osbuild/osbuild-composer/internal/osbuild"
+
 	"github.com/google/uuid"
 
 	"github.com/osbuild/osbuild-composer/internal/blueprint"
 	"github.com/osbuild/osbuild-composer/internal/crypt"
-	"github.com/osbuild/osbuild-composer/internal/pipeline"
 	"github.com/osbuild/osbuild-composer/internal/rpmmd"
 )
 
 type Fedora30 struct {
-	arches  map[string]arch
-	outputs map[string]output
+	arches        map[string]arch
+	outputs       map[string]output
+	buildPackages []string
 }
 
 type arch struct {
@@ -38,10 +40,11 @@ type output struct {
 	KernelOptions    string
 	Bootable         bool
 	DefaultSize      uint64
-	Assembler        func(uefi bool, size uint64) *pipeline.Assembler
+	Assembler        func(uefi bool, size uint64) *osbuild.Assembler
 }
 
 const Distro = common.Fedora30
+const ModulePlatformID = "platform:f30"
 
 func New(confPaths []string) *Fedora30 {
 	const GigaByte = 1024 * 1024 * 1024
@@ -49,6 +52,15 @@ func New(confPaths []string) *Fedora30 {
 	r := Fedora30{
 		arches:  map[string]arch{},
 		outputs: map[string]output{},
+		buildPackages: []string{
+			"dnf",
+			"dosfstools",
+			"e2fsprogs",
+			"policycoreutils",
+			"qemu-img",
+			"systemd",
+			"tar",
+		},
 	}
 
 	repoMap, err := rpmmd.LoadRepositories(confPaths, r.Name())
@@ -115,7 +127,7 @@ func New(confPaths []string) *Fedora30 {
 		KernelOptions: "ro no_timer_check console=ttyS0,115200n8 console=tty1 biosdevname=0 net.ifnames=0 console=ttyS0,115200",
 		Bootable:      true,
 		DefaultSize:   6 * GigaByte,
-		Assembler: func(uefi bool, size uint64) *pipeline.Assembler {
+		Assembler: func(uefi bool, size uint64) *osbuild.Assembler {
 			return r.qemuAssembler("raw.xz", "image.raw.xz", uefi, size)
 		},
 	}
@@ -137,7 +149,7 @@ func New(confPaths []string) *Fedora30 {
 		KernelOptions: "ro biosdevname=0 net.ifnames=0",
 		Bootable:      false,
 		DefaultSize:   2 * GigaByte,
-		Assembler:     func(uefi bool, size uint64) *pipeline.Assembler { return r.rawFSAssembler("filesystem.img", size) },
+		Assembler:     func(uefi bool, size uint64) *osbuild.Assembler { return r.rawFSAssembler("filesystem.img", size) },
 	}
 
 	r.outputs["partitioned-disk"] = output{
@@ -157,7 +169,7 @@ func New(confPaths []string) *Fedora30 {
 		KernelOptions: "ro biosdevname=0 net.ifnames=0",
 		Bootable:      true,
 		DefaultSize:   2 * GigaByte,
-		Assembler: func(uefi bool, size uint64) *pipeline.Assembler {
+		Assembler: func(uefi bool, size uint64) *osbuild.Assembler {
 			return r.qemuAssembler("raw", "disk.img", uefi, size)
 		},
 	}
@@ -184,7 +196,7 @@ func New(confPaths []string) *Fedora30 {
 		KernelOptions: "ro biosdevname=0 net.ifnames=0",
 		Bootable:      true,
 		DefaultSize:   2 * GigaByte,
-		Assembler: func(uefi bool, size uint64) *pipeline.Assembler {
+		Assembler: func(uefi bool, size uint64) *osbuild.Assembler {
 			return r.qemuAssembler("qcow2", "disk.qcow2", uefi, size)
 		},
 	}
@@ -210,7 +222,7 @@ func New(confPaths []string) *Fedora30 {
 		KernelOptions: "ro biosdevname=0 net.ifnames=0",
 		Bootable:      true,
 		DefaultSize:   2 * GigaByte,
-		Assembler: func(uefi bool, size uint64) *pipeline.Assembler {
+		Assembler: func(uefi bool, size uint64) *osbuild.Assembler {
 			return r.qemuAssembler("qcow2", "disk.qcow2", uefi, size)
 		},
 	}
@@ -232,7 +244,7 @@ func New(confPaths []string) *Fedora30 {
 		KernelOptions: "ro biosdevname=0 net.ifnames=0",
 		Bootable:      false,
 		DefaultSize:   2 * GigaByte,
-		Assembler:     func(uefi bool, size uint64) *pipeline.Assembler { return r.tarAssembler("root.tar.xz", "xz") },
+		Assembler:     func(uefi bool, size uint64) *osbuild.Assembler { return r.tarAssembler("root.tar.xz", "xz") },
 	}
 
 	r.outputs["vhd"] = output{
@@ -267,7 +279,7 @@ func New(confPaths []string) *Fedora30 {
 		KernelOptions: "ro biosdevname=0 rootdelay=300 console=ttyS0 earlyprintk=ttyS0 net.ifnames=0",
 		Bootable:      true,
 		DefaultSize:   2 * GigaByte,
-		Assembler: func(uefi bool, size uint64) *pipeline.Assembler {
+		Assembler: func(uefi bool, size uint64) *osbuild.Assembler {
 			return r.qemuAssembler("vpc", "disk.vhd", uefi, size)
 		},
 	}
@@ -290,7 +302,7 @@ func New(confPaths []string) *Fedora30 {
 		KernelOptions: "ro biosdevname=0 net.ifnames=0",
 		Bootable:      true,
 		DefaultSize:   2 * GigaByte,
-		Assembler: func(uefi bool, size uint64) *pipeline.Assembler {
+		Assembler: func(uefi bool, size uint64) *osbuild.Assembler {
 			return r.qemuAssembler("vmdk", "disk.vmdk", uefi, size)
 		},
 	}
@@ -310,6 +322,10 @@ func (r *Fedora30) Distribution() common.Distribution {
 	return Distro
 }
 
+func (r *Fedora30) ModulePlatformID() string {
+	return ModulePlatformID
+}
+
 func (r *Fedora30) Repositories(arch string) []rpmmd.RepoConfig {
 	return r.arches[arch].Repositories
 }
@@ -342,7 +358,35 @@ func (r *Fedora30) GetSizeForOutputType(outputFormat string, size uint64) uint64
 	return size
 }
 
-func (r *Fedora30) Pipeline(b *blueprint.Blueprint, additionalRepos []rpmmd.RepoConfig, checksums map[string]string, outputArchitecture, outputFormat string, size uint64) (*pipeline.Pipeline, error) {
+func (r *Fedora30) BasePackages(outputFormat string, outputArchitecture string) ([]string, []string, error) {
+	output, exists := r.outputs[outputFormat]
+	if !exists {
+		return nil, nil, errors.New("invalid output format: " + outputFormat)
+	}
+
+	packages := output.Packages
+	if output.Bootable {
+		arch, exists := r.arches[outputArchitecture]
+		if !exists {
+			return nil, nil, errors.New("invalid architecture: " + outputArchitecture)
+		}
+
+		packages = append(packages, arch.BootloaderPackages...)
+	}
+
+	return packages, output.ExcludedPackages, nil
+}
+
+func (r *Fedora30) BuildPackages(outputArchitecture string) ([]string, error) {
+	arch, exists := r.arches[outputArchitecture]
+	if !exists {
+		return nil, errors.New("invalid architecture: " + outputArchitecture)
+	}
+
+	return append(r.buildPackages, arch.BuildPackages...), nil
+}
+
+func (r *Fedora30) Pipeline(b *blueprint.Blueprint, additionalRepos []rpmmd.RepoConfig, packageSpecs, buildPackageSpecs []rpmmd.PackageSpec, checksums map[string]string, outputArchitecture, outputFormat string, size uint64) (*osbuild.Pipeline, error) {
 	output, exists := r.outputs[outputFormat]
 	if !exists {
 		return nil, errors.New("invalid output format: " + outputFormat)
@@ -353,42 +397,44 @@ func (r *Fedora30) Pipeline(b *blueprint.Blueprint, additionalRepos []rpmmd.Repo
 		return nil, errors.New("invalid architecture: " + outputArchitecture)
 	}
 
-	p := &pipeline.Pipeline{}
+	p := &osbuild.Pipeline{}
 	p.SetBuild(r.buildPipeline(arch, checksums), "org.osbuild.fedora30")
 
-	packages := append(output.Packages, b.GetPackages()...)
-	if output.Bootable {
-		packages = append(packages, arch.BootloaderPackages...)
+	packages, excludedPackages, err := r.BasePackages(outputFormat, outputArchitecture)
+	if err != nil {
+		return nil, err
 	}
-	p.AddStage(pipeline.NewDNFStage(r.dnfStageOptions(arch, additionalRepos, checksums, packages, output.ExcludedPackages)))
-	p.AddStage(pipeline.NewFixBLSStage())
+	packages = append(packages, b.GetPackages()...)
+
+	p.AddStage(osbuild.NewDNFStage(r.dnfStageOptions(arch, additionalRepos, checksums, packages, excludedPackages)))
+	p.AddStage(osbuild.NewFixBLSStage())
 
 	// TODO support setting all languages and install corresponding langpack-* package
 	language, keyboard := b.GetPrimaryLocale()
 
 	if language != nil {
-		p.AddStage(pipeline.NewLocaleStage(&pipeline.LocaleStageOptions{*language}))
+		p.AddStage(osbuild.NewLocaleStage(&osbuild.LocaleStageOptions{*language}))
 	} else {
-		p.AddStage(pipeline.NewLocaleStage(&pipeline.LocaleStageOptions{"en_US"}))
+		p.AddStage(osbuild.NewLocaleStage(&osbuild.LocaleStageOptions{"en_US"}))
 	}
 
 	if keyboard != nil {
-		p.AddStage(pipeline.NewKeymapStage(&pipeline.KeymapStageOptions{*keyboard}))
+		p.AddStage(osbuild.NewKeymapStage(&osbuild.KeymapStageOptions{*keyboard}))
 	}
 
 	if hostname := b.GetHostname(); hostname != nil {
-		p.AddStage(pipeline.NewHostnameStage(&pipeline.HostnameStageOptions{*hostname}))
+		p.AddStage(osbuild.NewHostnameStage(&osbuild.HostnameStageOptions{*hostname}))
 	}
 
 	timezone, ntpServers := b.GetTimezoneSettings()
 
 	// TODO install chrony when this is set?
 	if timezone != nil {
-		p.AddStage(pipeline.NewTimezoneStage(&pipeline.TimezoneStageOptions{*timezone}))
+		p.AddStage(osbuild.NewTimezoneStage(&osbuild.TimezoneStageOptions{*timezone}))
 	}
 
 	if len(ntpServers) > 0 {
-		p.AddStage(pipeline.NewChronyStage(&pipeline.ChronyStageOptions{ntpServers}))
+		p.AddStage(osbuild.NewChronyStage(&osbuild.ChronyStageOptions{ntpServers}))
 	}
 
 	if users := b.GetUsers(); len(users) > 0 {
@@ -396,62 +442,61 @@ func (r *Fedora30) Pipeline(b *blueprint.Blueprint, additionalRepos []rpmmd.Repo
 		if err != nil {
 			return nil, err
 		}
-		p.AddStage(pipeline.NewUsersStage(options))
+		p.AddStage(osbuild.NewUsersStage(options))
 	}
 
 	if groups := b.GetGroups(); len(groups) > 0 {
-		p.AddStage(pipeline.NewGroupsStage(r.groupStageOptions(groups)))
+		p.AddStage(osbuild.NewGroupsStage(r.groupStageOptions(groups)))
 	}
 
 	if output.Bootable {
-		p.AddStage(pipeline.NewFSTabStage(r.fsTabStageOptions(arch.UEFI)))
+		p.AddStage(osbuild.NewFSTabStage(r.fsTabStageOptions(arch.UEFI)))
 	}
-	p.AddStage(pipeline.NewGRUB2Stage(r.grub2StageOptions(output.KernelOptions, b.GetKernel(), arch.UEFI)))
+	p.AddStage(osbuild.NewGRUB2Stage(r.grub2StageOptions(output.KernelOptions, b.GetKernel(), arch.UEFI)))
 
 	if services := b.GetServices(); services != nil || output.EnabledServices != nil {
-		p.AddStage(pipeline.NewSystemdStage(r.systemdStageOptions(output.EnabledServices, output.DisabledServices, services)))
+		p.AddStage(osbuild.NewSystemdStage(r.systemdStageOptions(output.EnabledServices, output.DisabledServices, services)))
 	}
 
 	if firewall := b.GetFirewall(); firewall != nil {
-		p.AddStage(pipeline.NewFirewallStage(r.firewallStageOptions(firewall)))
+		p.AddStage(osbuild.NewFirewallStage(r.firewallStageOptions(firewall)))
 	}
 
-	p.AddStage(pipeline.NewSELinuxStage(r.selinuxStageOptions()))
+	p.AddStage(osbuild.NewSELinuxStage(r.selinuxStageOptions()))
 
 	p.Assembler = output.Assembler(arch.UEFI, size)
 
 	return p, nil
 }
 
+func (r *Fedora30) Sources(packages []rpmmd.PackageSpec) *osbuild.Sources {
+	return &osbuild.Sources{}
+}
+
 func (r *Fedora30) Runner() string {
 	return "org.osbuild.fedora30"
 }
 
-func (r *Fedora30) buildPipeline(arch arch, checksums map[string]string) *pipeline.Pipeline {
-	packages := []string{
-		"dnf",
-		"dosfstools",
-		"e2fsprogs",
-		"policycoreutils",
-		"qemu-img",
-		"systemd",
-		"tar",
-	}
-	packages = append(packages, arch.BuildPackages...)
-	p := &pipeline.Pipeline{}
-	p.AddStage(pipeline.NewDNFStage(r.dnfStageOptions(arch, nil, checksums, packages, nil)))
+func (r *Fedora30) buildPipeline(arch arch, checksums map[string]string) *osbuild.Pipeline {
+	packages, err := r.BuildPackages(arch.Name)
+	if err != nil {
+		panic("impossibly invalid arch")
+	}
+
+	p := &osbuild.Pipeline{}
+	p.AddStage(osbuild.NewDNFStage(r.dnfStageOptions(arch, nil, checksums, packages, nil)))
 	return p
 }
 
-func (r *Fedora30) dnfStageOptions(arch arch, additionalRepos []rpmmd.RepoConfig, checksums map[string]string, packages, excludedPackages []string) *pipeline.DNFStageOptions {
-	options := &pipeline.DNFStageOptions{
+func (r *Fedora30) dnfStageOptions(arch arch, additionalRepos []rpmmd.RepoConfig, checksums map[string]string, packages, excludedPackages []string) *osbuild.DNFStageOptions {
+	options := &osbuild.DNFStageOptions{
 		ReleaseVersion:   "30",
 		BaseArchitecture: arch.Name,
-		ModulePlatformId: "platform:f30",
+		ModulePlatformId: ModulePlatformID,
 	}
 
 	for _, repo := range append(arch.Repositories, additionalRepos...) {
-		options.AddRepository(&pipeline.DNFRepository{
+		options.AddRepository(&osbuild.DNFRepository{
 			BaseURL:    repo.BaseURL,
 			MetaLink:   repo.Metalink,
 			MirrorList: repo.MirrorList,
@@ -473,9 +518,9 @@ func (r *Fedora30) dnfStageOptions(arch arch, additionalRepos []rpmmd.RepoConfig
 	return options
 }
 
-func (r *Fedora30) userStageOptions(users []blueprint.UserCustomization) (*pipeline.UsersStageOptions, error) {
-	options := pipeline.UsersStageOptions{
-		Users: make(map[string]pipeline.UsersStageOptionsUser),
+func (r *Fedora30) userStageOptions(users []blueprint.UserCustomization) (*osbuild.UsersStageOptions, error) {
+	options := osbuild.UsersStageOptions{
+		Users: make(map[string]osbuild.UsersStageOptionsUser),
 	}
 
 	for _, c := range users {
@@ -488,7 +533,7 @@ func (r *Fedora30) userStageOptions(users []blueprint.UserCustomization) (*pipel
 			c.Password = &cryptedPassword
 		}
 
-		user := pipeline.UsersStageOptionsUser{
+		user := osbuild.UsersStageOptionsUser{
 			Groups:      c.Groups,
 			Description: c.Description,
 			Home:        c.Home,
@@ -513,13 +558,13 @@ func (r *Fedora30) userStageOptions(users []blueprint.UserCustomization) (*pipel
 	return &options, nil
 }
 
-func (r *Fedora30) groupStageOptions(groups []blueprint.GroupCustomization) *pipeline.GroupsStageOptions {
-	options := pipeline.GroupsStageOptions{
-		Groups: map[string]pipeline.GroupsStageOptionsGroup{},
+func (r *Fedora30) groupStageOptions(groups []blueprint.GroupCustomization) *osbuild.GroupsStageOptions {
+	options := osbuild.GroupsStageOptions{
+		Groups: map[string]osbuild.GroupsStageOptionsGroup{},
 	}
 
 	for _, group := range groups {
-		groupData := pipeline.GroupsStageOptionsGroup{
+		groupData := osbuild.GroupsStageOptionsGroup{
 			Name: group.Name,
 		}
 		if group.GID != nil {
@@ -533,8 +578,8 @@ func (r *Fedora30) groupStageOptions(groups []blueprint.GroupCustomization) *pip
 	return &options
 }
 
-func (r *Fedora30) firewallStageOptions(firewall *blueprint.FirewallCustomization) *pipeline.FirewallStageOptions {
-	options := pipeline.FirewallStageOptions{
+func (r *Fedora30) firewallStageOptions(firewall *blueprint.FirewallCustomization) *osbuild.FirewallStageOptions {
+	options := osbuild.FirewallStageOptions{
 		Ports: firewall.Ports,
 	}
 
@@ -546,19 +591,19 @@ func (r *Fedora30) firewallStageOptions(firewall *blueprint.FirewallCustomizatio
 	return &options
 }
 
-func (r *Fedora30) systemdStageOptions(enabledServices, disabledServices []string, s *blueprint.ServicesCustomization) *pipeline.SystemdStageOptions {
+func (r *Fedora30) systemdStageOptions(enabledServices, disabledServices []string, s *blueprint.ServicesCustomization) *osbuild.SystemdStageOptions {
 	if s != nil {
 		enabledServices = append(enabledServices, s.Enabled...)
-		enabledServices = append(disabledServices, s.Disabled...)
+		disabledServices = append(disabledServices, s.Disabled...)
 	}
-	return &pipeline.SystemdStageOptions{
+	return &osbuild.SystemdStageOptions{
 		EnabledServices:  enabledServices,
 		DisabledServices: disabledServices,
 	}
 }
 
-func (r *Fedora30) fsTabStageOptions(uefi bool) *pipeline.FSTabStageOptions {
-	options := pipeline.FSTabStageOptions{}
+func (r *Fedora30) fsTabStageOptions(uefi bool) *osbuild.FSTabStageOptions {
+	options := osbuild.FSTabStageOptions{}
 	options.AddFilesystem("76a22bf4-f153-4541-b6c7-0332c0dfaeac", "ext4", "/", "defaults", 1, 1)
 	if uefi {
 		options.AddFilesystem("46BB-8120", "vfat", "/boot/efi", "umask=0077,shortname=winnt", 0, 2)
@@ -566,7 +611,7 @@ func (r *Fedora30) fsTabStageOptions(uefi bool) *pipeline.FSTabStageOptions {
 	return &options
 }
 
-func (r *Fedora30) grub2StageOptions(kernelOptions string, kernel *blueprint.KernelCustomization, uefi bool) *pipeline.GRUB2StageOptions {
+func (r *Fedora30) grub2StageOptions(kernelOptions string, kernel *blueprint.KernelCustomization, uefi bool) *osbuild.GRUB2StageOptions {
 	id, err := uuid.Parse("76a22bf4-f153-4541-b6c7-0332c0dfaeac")
 	if err != nil {
 		panic("invalid UUID")
@@ -576,14 +621,14 @@ func (r *Fedora30) grub2StageOptions(kernelOptions string, kernel *blueprint.Ker
 		kernelOptions += " " + kernel.Append
 	}
 
-	var uefiOptions *pipeline.GRUB2UEFI
+	var uefiOptions *osbuild.GRUB2UEFI
 	if uefi {
-		uefiOptions = &pipeline.GRUB2UEFI{
+		uefiOptions = &osbuild.GRUB2UEFI{
 			Vendor: "fedora",
 		}
 	}
 
-	return &pipeline.GRUB2StageOptions{
+	return &osbuild.GRUB2StageOptions{
 		RootFilesystemUUID: id,
 		KernelOptions:      kernelOptions,
 		Legacy:             !uefi,
@@ -591,28 +636,28 @@ func (r *Fedora30) grub2StageOptions(kernelOptions string, kernel *blueprint.Ker
 	}
 }
 
-func (r *Fedora30) selinuxStageOptions() *pipeline.SELinuxStageOptions {
-	return &pipeline.SELinuxStageOptions{
+func (r *Fedora30) selinuxStageOptions() *osbuild.SELinuxStageOptions {
+	return &osbuild.SELinuxStageOptions{
 		FileContexts: "etc/selinux/targeted/contexts/files/file_contexts",
 	}
 }
 
-func (r *Fedora30) qemuAssembler(format string, filename string, uefi bool, size uint64) *pipeline.Assembler {
-	var options pipeline.QEMUAssemblerOptions
+func (r *Fedora30) qemuAssembler(format string, filename string, uefi bool, size uint64) *osbuild.Assembler {
+	var options osbuild.QEMUAssemblerOptions
 	if uefi {
 		fstype := uuid.MustParse("C12A7328-F81F-11D2-BA4B-00A0C93EC93B")
-		options = pipeline.QEMUAssemblerOptions{
+		options = osbuild.QEMUAssemblerOptions{
 			Format:   format,
 			Filename: filename,
 			Size:     size,
 			PTUUID:   "8DFDFF87-C96E-EA48-A3A6-9408F1F6B1EF",
 			PTType:   "gpt",
-			Partitions: []pipeline.QEMUPartition{
+			Partitions: []osbuild.QEMUPartition{
 				{
 					Start: 2048,
 					Size:  972800,
 					Type:  &fstype,
-					Filesystem: pipeline.QEMUFilesystem{
+					Filesystem: osbuild.QEMUFilesystem{
 						Type:       "vfat",
 						UUID:       "46BB-8120",
 						Label:      "EFI System Partition",
@@ -621,7 +666,7 @@ func (r *Fedora30) qemuAssembler(format string, filename string, uefi bool, size
 				},
 				{
 					Start: 976896,
-					Filesystem: pipeline.QEMUFilesystem{
+					Filesystem: osbuild.QEMUFilesystem{
 						Type:       "ext4",
 						UUID:       "76a22bf4-f153-4541-b6c7-0332c0dfaeac",
 						Mountpoint: "/",
@@ -630,17 +675,17 @@ func (r *Fedora30) qemuAssembler(format string, filename string, uefi bool, size
 			},
 		}
 	} else {
-		options = pipeline.QEMUAssemblerOptions{
+		options = osbuild.QEMUAssemblerOptions{
 			Format:   format,
 			Filename: filename,
 			Size:     size,
 			PTUUID:   "0x14fc63d2",
 			PTType:   "mbr",
-			Partitions: []pipeline.QEMUPartition{
+			Partitions: []osbuild.QEMUPartition{
 				{
 					Start:    2048,
 					Bootable: true,
-					Filesystem: pipeline.QEMUFilesystem{
+					Filesystem: osbuild.QEMUFilesystem{
 						Type:       "ext4",
 						UUID:       "76a22bf4-f153-4541-b6c7-0332c0dfaeac",
 						Mountpoint: "/",
@@ -649,24 +694,24 @@ func (r *Fedora30) qemuAssembler(format string, filename string, uefi bool, size
 			},
 		}
 	}
-	return pipeline.NewQEMUAssembler(&options)
+	return osbuild.NewQEMUAssembler(&options)
 }
 
-func (r *Fedora30) tarAssembler(filename, compression string) *pipeline.Assembler {
-	return pipeline.NewTarAssembler(
-		&pipeline.TarAssemblerOptions{
+func (r *Fedora30) tarAssembler(filename, compression string) *osbuild.Assembler {
+	return osbuild.NewTarAssembler(
+		&osbuild.TarAssemblerOptions{
 			Filename:    filename,
 			Compression: compression,
 		})
 }
 
-func (r *Fedora30) rawFSAssembler(filename string, size uint64) *pipeline.Assembler {
+func (r *Fedora30) rawFSAssembler(filename string, size uint64) *osbuild.Assembler {
 	id, err := uuid.Parse("76a22bf4-f153-4541-b6c7-0332c0dfaeac")
 	if err != nil {
 		panic("invalid UUID")
 	}
-	return pipeline.NewRawFSAssembler(
-		&pipeline.RawFSAssemblerOptions{
+	return osbuild.NewRawFSAssembler(
+		&osbuild.RawFSAssemblerOptions{
 			Filename:           filename,
 			RootFilesystemUUDI: id,
 			Size:               size,
diff --git a/internal/distro/rhel82/distro.go b/internal/distro/rhel82/distro.go
index 031b05551..657e97fb2 100644
--- a/internal/distro/rhel82/distro.go
+++ b/internal/distro/rhel82/distro.go
@@ -2,22 +2,24 @@ package rhel82
 
 import (
 	"errors"
-	"github.com/osbuild/osbuild-composer/internal/common"
 	"log"
 	"sort"
 	"strconv"
 
+	"github.com/osbuild/osbuild-composer/internal/common"
+	"github.com/osbuild/osbuild-composer/internal/osbuild"
+
 	"github.com/google/uuid"
 
 	"github.com/osbuild/osbuild-composer/internal/blueprint"
 	"github.com/osbuild/osbuild-composer/internal/crypt"
-	"github.com/osbuild/osbuild-composer/internal/pipeline"
 	"github.com/osbuild/osbuild-composer/internal/rpmmd"
 )
 
 type RHEL82 struct {
-	arches  map[string]arch
-	outputs map[string]output
+	arches        map[string]arch
+	outputs       map[string]output
+	buildPackages []string
 }
 
 type arch struct {
@@ -39,10 +41,11 @@ type output struct {
 	DefaultTarget    string
 	KernelOptions    string
 	DefaultSize      uint64
-	Assembler        func(uefi bool, size uint64) *pipeline.Assembler
+	Assembler        func(uefi bool, size uint64) *osbuild.Assembler
 }
 
 const Distro = common.RHEL82
+const ModulePlatformID = "platform:el8"
 
 func New(confPaths []string) *RHEL82 {
 	const GigaByte = 1024 * 1024 * 1024
@@ -50,6 +53,19 @@ func New(confPaths []string) *RHEL82 {
 	r := RHEL82{
 		arches:  map[string]arch{},
 		outputs: map[string]output{},
+		buildPackages: []string{
+			"dnf",
+			"dosfstools",
+			"dracut-config-generic",
+			"e2fsprogs",
+			"glibc",
+			"policycoreutils",
+			"python36",
+			"qemu-img",
+			"systemd",
+			"tar",
+			"xfsprogs",
+		},
 	}
 
 	repoMap, err := rpmmd.LoadRepositories(confPaths, r.Name())
@@ -164,7 +180,7 @@ func New(confPaths []string) *RHEL82 {
 		Bootable:      true,
 		KernelOptions: "ro console=ttyS0,115200n8 console=tty0 net.ifnames=0 rd.blacklist=nouveau nvme_core.io_timeout=4294967295 crashkernel=auto",
 		DefaultSize:   6 * GigaByte,
-		Assembler: func(uefi bool, size uint64) *pipeline.Assembler {
+		Assembler: func(uefi bool, size uint64) *osbuild.Assembler {
 			return r.qemuAssembler("raw.xz", "image.raw.xz", uefi, size)
 		},
 	}
@@ -191,7 +207,7 @@ func New(confPaths []string) *RHEL82 {
 		Bootable:      false,
 		KernelOptions: "ro net.ifnames=0",
 		DefaultSize:   2 * GigaByte,
-		Assembler:     func(uefi bool, size uint64) *pipeline.Assembler { return r.rawFSAssembler("filesystem.img", size) },
+		Assembler:     func(uefi bool, size uint64) *osbuild.Assembler { return r.rawFSAssembler("filesystem.img", size) },
 	}
 
 	r.outputs["partitioned-disk"] = output{
@@ -216,7 +232,7 @@ func New(confPaths []string) *RHEL82 {
 		Bootable:      true,
 		KernelOptions: "ro net.ifnames=0",
 		DefaultSize:   2 * GigaByte,
-		Assembler: func(uefi bool, size uint64) *pipeline.Assembler {
+		Assembler: func(uefi bool, size uint64) *osbuild.Assembler {
 			return r.qemuAssembler("raw", "disk.img", uefi, size)
 		},
 	}
@@ -300,7 +316,7 @@ func New(confPaths []string) *RHEL82 {
 		Bootable:      true,
 		KernelOptions: "console=ttyS0 console=ttyS0,115200n8 no_timer_check crashkernel=auto net.ifnames=0",
 		DefaultSize:   2 * GigaByte,
-		Assembler: func(uefi bool, size uint64) *pipeline.Assembler {
+		Assembler: func(uefi bool, size uint64) *osbuild.Assembler {
 			return r.qemuAssembler("qcow2", "disk.qcow2", uefi, size)
 		},
 	}
@@ -330,7 +346,7 @@ func New(confPaths []string) *RHEL82 {
 		Bootable:      true,
 		KernelOptions: "ro net.ifnames=0",
 		DefaultSize:   2 * GigaByte,
-		Assembler: func(uefi bool, size uint64) *pipeline.Assembler {
+		Assembler: func(uefi bool, size uint64) *osbuild.Assembler {
 			return r.qemuAssembler("qcow2", "disk.qcow2", uefi, size)
 		},
 	}
@@ -356,7 +372,7 @@ func New(confPaths []string) *RHEL82 {
 		},
 		Bootable:      false,
 		KernelOptions: "ro net.ifnames=0",
-		Assembler:     func(uefi bool, size uint64) *pipeline.Assembler { return r.tarAssembler("root.tar.xz", "xz") },
+		Assembler:     func(uefi bool, size uint64) *osbuild.Assembler { return r.tarAssembler("root.tar.xz", "xz") },
 	}
 
 	r.outputs["vhd"] = output{
@@ -397,7 +413,7 @@ func New(confPaths []string) *RHEL82 {
 		Bootable:      true,
 		KernelOptions: "ro biosdevname=0 rootdelay=300 console=ttyS0 earlyprintk=ttyS0 net.ifnames=0",
 		DefaultSize:   2 * GigaByte,
-		Assembler: func(uefi bool, size uint64) *pipeline.Assembler {
+		Assembler: func(uefi bool, size uint64) *osbuild.Assembler {
 			return r.qemuAssembler("vpc", "disk.vhd", uefi, size)
 		},
 	}
@@ -425,7 +441,7 @@ func New(confPaths []string) *RHEL82 {
 		Bootable:      true,
 		KernelOptions: "ro net.ifnames=0",
 		DefaultSize:   2 * GigaByte,
-		Assembler: func(uefi bool, size uint64) *pipeline.Assembler {
+		Assembler: func(uefi bool, size uint64) *osbuild.Assembler {
 			return r.qemuAssembler("vmdk", "disk.vmdk", uefi, size)
 		},
 	}
@@ -441,11 +457,14 @@ func (r *RHEL82) Name() string {
 	return name
 }
 
-
 func (r *RHEL82) Distribution() common.Distribution {
 	return Distro
 }
 
+func (r *RHEL82) ModulePlatformID() string {
+	return ModulePlatformID
+}
+
 func (r *RHEL82) Repositories(arch string) []rpmmd.RepoConfig {
 	return r.arches[arch].Repositories
 }
@@ -478,7 +497,35 @@ func (r *RHEL82) GetSizeForOutputType(outputFormat string, size uint64) uint64 {
 	return size
 }
 
-func (r *RHEL82) Pipeline(b *blueprint.Blueprint, additionalRepos []rpmmd.RepoConfig, checksums map[string]string, outputArchitecture, outputFormat string, size uint64) (*pipeline.Pipeline, error) {
+func (r *RHEL82) BasePackages(outputFormat string, outputArchitecture string) ([]string, []string, error) {
+	output, exists := r.outputs[outputFormat]
+	if !exists {
+		return nil, nil, errors.New("invalid output format: " + outputFormat)
+	}
+
+	packages := output.Packages
+	if output.Bootable {
+		arch, exists := r.arches[outputArchitecture]
+		if !exists {
+			return nil, nil, errors.New("invalid architecture: " + outputArchitecture)
+		}
+
+		packages = append(packages, arch.BootloaderPackages...)
+	}
+
+	return packages, output.ExcludedPackages, nil
+}
+
+func (r *RHEL82) BuildPackages(outputArchitecture string) ([]string, error) {
+	arch, exists := r.arches[outputArchitecture]
+	if !exists {
+		return nil, errors.New("invalid architecture: " + outputArchitecture)
+	}
+
+	return append(r.buildPackages, arch.BuildPackages...), nil
+}
+
+func (r *RHEL82) Pipeline(b *blueprint.Blueprint, additionalRepos []rpmmd.RepoConfig, packageSpecs, buildPackageSpecs []rpmmd.PackageSpec, checksums map[string]string, outputArchitecture, outputFormat string, size uint64) (*osbuild.Pipeline, error) {
 	output, exists := r.outputs[outputFormat]
 	if !exists {
 		return nil, errors.New("invalid output format: " + outputFormat)
@@ -489,52 +536,53 @@ func (r *RHEL82) Pipeline(b *blueprint.Blueprint, additionalRepos []rpmmd.RepoCo
 		return nil, errors.New("invalid architecture: " + outputArchitecture)
 	}
 
-	p := &pipeline.Pipeline{}
+	p := &osbuild.Pipeline{}
 	p.SetBuild(r.buildPipeline(arch, checksums), "org.osbuild.rhel82")
 
-	packages := append(output.Packages, b.GetPackages()...)
-	if output.Bootable {
-		packages = append(packages, arch.BootloaderPackages...)
+	packages, excludedPackages, err := r.BasePackages(outputFormat, outputArchitecture)
+	if err != nil {
+		return nil, err
 	}
-	p.AddStage(pipeline.NewDNFStage(r.dnfStageOptions(arch, additionalRepos, checksums, packages, output.ExcludedPackages)))
-	p.AddStage(pipeline.NewFixBLSStage())
+	packages = append(packages, b.GetPackages()...)
+	p.AddStage(osbuild.NewDNFStage(r.dnfStageOptions(arch, additionalRepos, checksums, packages, excludedPackages)))
+	p.AddStage(osbuild.NewFixBLSStage())
 
 	if output.Bootable {
-		p.AddStage(pipeline.NewFSTabStage(r.fsTabStageOptions(arch.UEFI)))
+		p.AddStage(osbuild.NewFSTabStage(r.fsTabStageOptions(arch.UEFI)))
 	}
 
 	kernelOptions := output.KernelOptions
 	if kernel := b.GetKernel(); kernel != nil {
 		kernelOptions += " " + kernel.Append
 	}
-	p.AddStage(pipeline.NewGRUB2Stage(r.grub2StageOptions(kernelOptions, arch.UEFI)))
+	p.AddStage(osbuild.NewGRUB2Stage(r.grub2StageOptions(kernelOptions, arch.UEFI)))
 
 	// TODO support setting all languages and install corresponding langpack-* package
 	language, keyboard := b.GetPrimaryLocale()
 
 	if language != nil {
-		p.AddStage(pipeline.NewLocaleStage(&pipeline.LocaleStageOptions{*language}))
+		p.AddStage(osbuild.NewLocaleStage(&osbuild.LocaleStageOptions{*language}))
 	} else {
-		p.AddStage(pipeline.NewLocaleStage(&pipeline.LocaleStageOptions{"en_US"}))
+		p.AddStage(osbuild.NewLocaleStage(&osbuild.LocaleStageOptions{"en_US"}))
 	}
 
 	if keyboard != nil {
-		p.AddStage(pipeline.NewKeymapStage(&pipeline.KeymapStageOptions{*keyboard}))
+		p.AddStage(osbuild.NewKeymapStage(&osbuild.KeymapStageOptions{*keyboard}))
 	}
 
 	if hostname := b.GetHostname(); hostname != nil {
-		p.AddStage(pipeline.NewHostnameStage(&pipeline.HostnameStageOptions{*hostname}))
+		p.AddStage(osbuild.NewHostnameStage(&osbuild.HostnameStageOptions{*hostname}))
 	}
 
 	timezone, ntpServers := b.GetTimezoneSettings()
 
 	// TODO install chrony when this is set?
 	if timezone != nil {
-		p.AddStage(pipeline.NewTimezoneStage(&pipeline.TimezoneStageOptions{*timezone}))
+		p.AddStage(osbuild.NewTimezoneStage(&osbuild.TimezoneStageOptions{*timezone}))
 	}
 
 	if len(ntpServers) > 0 {
-		p.AddStage(pipeline.NewChronyStage(&pipeline.ChronyStageOptions{ntpServers}))
+		p.AddStage(osbuild.NewChronyStage(&osbuild.ChronyStageOptions{ntpServers}))
 	}
 
 	if users := b.GetUsers(); len(users) > 0 {
@@ -542,60 +590,55 @@ func (r *RHEL82) Pipeline(b *blueprint.Blueprint, additionalRepos []rpmmd.RepoCo
 		if err != nil {
 			return nil, err
 		}
-		p.AddStage(pipeline.NewUsersStage(options))
+		p.AddStage(osbuild.NewUsersStage(options))
 	}
 
 	if groups := b.GetGroups(); len(groups) > 0 {
-		p.AddStage(pipeline.NewGroupsStage(r.groupStageOptions(groups)))
+		p.AddStage(osbuild.NewGroupsStage(r.groupStageOptions(groups)))
 	}
 
 	if services := b.GetServices(); services != nil || output.EnabledServices != nil {
-		p.AddStage(pipeline.NewSystemdStage(r.systemdStageOptions(output.EnabledServices, output.DisabledServices, services, output.DefaultTarget)))
+		p.AddStage(osbuild.NewSystemdStage(r.systemdStageOptions(output.EnabledServices, output.DisabledServices, services, output.DefaultTarget)))
 	}
 
 	if firewall := b.GetFirewall(); firewall != nil {
-		p.AddStage(pipeline.NewFirewallStage(r.firewallStageOptions(firewall)))
+		p.AddStage(osbuild.NewFirewallStage(r.firewallStageOptions(firewall)))
 	}
 
-	p.AddStage(pipeline.NewSELinuxStage(r.selinuxStageOptions()))
+	p.AddStage(osbuild.NewSELinuxStage(r.selinuxStageOptions()))
 
 	p.Assembler = output.Assembler(arch.UEFI, size)
 
 	return p, nil
 }
 
+func (r *RHEL82) Sources(packages []rpmmd.PackageSpec) *osbuild.Sources {
+	return &osbuild.Sources{}
+}
+
 func (r *RHEL82) Runner() string {
 	return "org.osbuild.rhel82"
 }
 
-func (r *RHEL82) buildPipeline(arch arch, checksums map[string]string) *pipeline.Pipeline {
-	packages := []string{
-		"dnf",
-		"dosfstools",
-		"dracut-config-generic",
-		"e2fsprogs",
-		"glibc",
-		"policycoreutils",
-		"python36",
-		"qemu-img",
-		"systemd",
-		"tar",
-		"xfsprogs",
-	}
-	packages = append(packages, arch.BuildPackages...)
-	p := &pipeline.Pipeline{}
-	p.AddStage(pipeline.NewDNFStage(r.dnfStageOptions(arch, nil, checksums, packages, nil)))
+func (r *RHEL82) buildPipeline(arch arch, checksums map[string]string) *osbuild.Pipeline {
+	packages, err := r.BuildPackages(arch.Name)
+	if err != nil {
+		panic("impossibly invalid arch")
+	}
+
+	p := &osbuild.Pipeline{}
+	p.AddStage(osbuild.NewDNFStage(r.dnfStageOptions(arch, nil, checksums, packages, nil)))
 	return p
 }
 
-func (r *RHEL82) dnfStageOptions(arch arch, additionalRepos []rpmmd.RepoConfig, checksums map[string]string, packages, excludedPackages []string) *pipeline.DNFStageOptions {
-	options := &pipeline.DNFStageOptions{
+func (r *RHEL82) dnfStageOptions(arch arch, additionalRepos []rpmmd.RepoConfig, checksums map[string]string, packages, excludedPackages []string) *osbuild.DNFStageOptions {
+	options := &osbuild.DNFStageOptions{
 		ReleaseVersion:   "8",
 		BaseArchitecture: arch.Name,
-		ModulePlatformId: "platform:el8",
+		ModulePlatformId: ModulePlatformID,
 	}
 	for _, repo := range append(arch.Repositories, additionalRepos...) {
-		options.AddRepository(&pipeline.DNFRepository{
+		options.AddRepository(&osbuild.DNFRepository{
 			BaseURL:    repo.BaseURL,
 			MetaLink:   repo.Metalink,
 			MirrorList: repo.MirrorList,
@@ -616,9 +659,9 @@ func (r *RHEL82) dnfStageOptions(arch arch, additionalRepos []rpmmd.RepoConfig,
 	return options
 }
 
-func (r *RHEL82) userStageOptions(users []blueprint.UserCustomization) (*pipeline.UsersStageOptions, error) {
-	options := pipeline.UsersStageOptions{
-		Users: make(map[string]pipeline.UsersStageOptionsUser),
+func (r *RHEL82) userStageOptions(users []blueprint.UserCustomization) (*osbuild.UsersStageOptions, error) {
+	options := osbuild.UsersStageOptions{
+		Users: make(map[string]osbuild.UsersStageOptionsUser),
 	}
 
 	for _, c := range users {
@@ -631,7 +674,7 @@ func (r *RHEL82) userStageOptions(users []blueprint.UserCustomization) (*pipelin
 			c.Password = &cryptedPassword
 		}
 
-		user := pipeline.UsersStageOptionsUser{
+		user := osbuild.UsersStageOptionsUser{
 			Groups:      c.Groups,
 			Description: c.Description,
 			Home:        c.Home,
@@ -656,13 +699,13 @@ func (r *RHEL82) userStageOptions(users []blueprint.UserCustomization) (*pipelin
 	return &options, nil
 }
 
-func (r *RHEL82) groupStageOptions(groups []blueprint.GroupCustomization) *pipeline.GroupsStageOptions {
-	options := pipeline.GroupsStageOptions{
-		Groups: map[string]pipeline.GroupsStageOptionsGroup{},
+func (r *RHEL82) groupStageOptions(groups []blueprint.GroupCustomization) *osbuild.GroupsStageOptions {
+	options := osbuild.GroupsStageOptions{
+		Groups: map[string]osbuild.GroupsStageOptionsGroup{},
 	}
 
 	for _, group := range groups {
-		groupData := pipeline.GroupsStageOptionsGroup{
+		groupData := osbuild.GroupsStageOptionsGroup{
 			Name: group.Name,
 		}
 		if group.GID != nil {
@@ -676,8 +719,8 @@ func (r *RHEL82) groupStageOptions(groups []blueprint.GroupCustomization) *pipel
 	return &options
 }
 
-func (r *RHEL82) firewallStageOptions(firewall *blueprint.FirewallCustomization) *pipeline.FirewallStageOptions {
-	options := pipeline.FirewallStageOptions{
+func (r *RHEL82) firewallStageOptions(firewall *blueprint.FirewallCustomization) *osbuild.FirewallStageOptions {
+	options := osbuild.FirewallStageOptions{
 		Ports: firewall.Ports,
 	}
 
@@ -689,20 +732,20 @@ func (r *RHEL82) firewallStageOptions(firewall *blueprint.FirewallCustomization)
 	return &options
 }
 
-func (r *RHEL82) systemdStageOptions(enabledServices, disabledServices []string, s *blueprint.ServicesCustomization, target string) *pipeline.SystemdStageOptions {
+func (r *RHEL82) systemdStageOptions(enabledServices, disabledServices []string, s *blueprint.ServicesCustomization, target string) *osbuild.SystemdStageOptions {
 	if s != nil {
 		enabledServices = append(enabledServices, s.Enabled...)
-		enabledServices = append(disabledServices, s.Disabled...)
+		disabledServices = append(disabledServices, s.Disabled...)
 	}
-	return &pipeline.SystemdStageOptions{
+	return &osbuild.SystemdStageOptions{
 		EnabledServices:  enabledServices,
 		DisabledServices: disabledServices,
 		DefaultTarget:    target,
 	}
 }
 
-func (r *RHEL82) fsTabStageOptions(uefi bool) *pipeline.FSTabStageOptions {
-	options := pipeline.FSTabStageOptions{}
+func (r *RHEL82) fsTabStageOptions(uefi bool) *osbuild.FSTabStageOptions {
+	options := osbuild.FSTabStageOptions{}
 	options.AddFilesystem("0bd700f8-090f-4556-b797-b340297ea1bd", "xfs", "/", "defaults", 0, 0)
 	if uefi {
 		options.AddFilesystem("46BB-8120", "vfat", "/boot/efi", "umask=0077,shortname=winnt", 0, 2)
@@ -710,20 +753,20 @@ func (r *RHEL82) fsTabStageOptions(uefi bool) *pipeline.FSTabStageOptions {
 	return &options
 }
 
-func (r *RHEL82) grub2StageOptions(kernelOptions string, uefi bool) *pipeline.GRUB2StageOptions {
+func (r *RHEL82) grub2StageOptions(kernelOptions string, uefi bool) *osbuild.GRUB2StageOptions {
 	id, err := uuid.Parse("0bd700f8-090f-4556-b797-b340297ea1bd")
 	if err != nil {
 		panic("invalid UUID")
 	}
 
-	var uefiOptions *pipeline.GRUB2UEFI
+	var uefiOptions *osbuild.GRUB2UEFI
 	if uefi {
-		uefiOptions = &pipeline.GRUB2UEFI{
+		uefiOptions = &osbuild.GRUB2UEFI{
 			Vendor: "redhat",
 		}
 	}
 
-	return &pipeline.GRUB2StageOptions{
+	return &osbuild.GRUB2StageOptions{
 		RootFilesystemUUID: id,
 		KernelOptions:      kernelOptions,
 		Legacy:             !uefi,
@@ -731,28 +774,28 @@ func (r *RHEL82) grub2StageOptions(kernelOptions string, uefi bool) *pipeline.GR
 	}
 }
 
-func (r *RHEL82) selinuxStageOptions() *pipeline.SELinuxStageOptions {
-	return &pipeline.SELinuxStageOptions{
+func (r *RHEL82) selinuxStageOptions() *osbuild.SELinuxStageOptions {
+	return &osbuild.SELinuxStageOptions{
 		FileContexts: "etc/selinux/targeted/contexts/files/file_contexts",
 	}
 }
 
-func (r *RHEL82) qemuAssembler(format string, filename string, uefi bool, size uint64) *pipeline.Assembler {
-	var options pipeline.QEMUAssemblerOptions
+func (r *RHEL82) qemuAssembler(format string, filename string, uefi bool, size uint64) *osbuild.Assembler {
+	var options osbuild.QEMUAssemblerOptions
 	if uefi {
 		fstype := uuid.MustParse("C12A7328-F81F-11D2-BA4B-00A0C93EC93B")
-		options = pipeline.QEMUAssemblerOptions{
+		options = osbuild.QEMUAssemblerOptions{
 			Format:   format,
 			Filename: filename,
 			Size:     size,
 			PTUUID:   "8DFDFF87-C96E-EA48-A3A6-9408F1F6B1EF",
 			PTType:   "gpt",
-			Partitions: []pipeline.QEMUPartition{
+			Partitions: []osbuild.QEMUPartition{
 				{
 					Start: 2048,
 					Size:  972800,
 					Type:  &fstype,
-					Filesystem: pipeline.QEMUFilesystem{
+					Filesystem: osbuild.QEMUFilesystem{
 						Type:       "vfat",
 						UUID:       "46BB-8120",
 						Label:      "EFI System Partition",
@@ -761,7 +804,7 @@ func (r *RHEL82) qemuAssembler(format string, filename string, uefi bool, size u
 				},
 				{
 					Start: 976896,
-					Filesystem: pipeline.QEMUFilesystem{
+					Filesystem: osbuild.QEMUFilesystem{
 						Type:       "xfs",
 						UUID:       "0bd700f8-090f-4556-b797-b340297ea1bd",
 						Mountpoint: "/",
@@ -770,17 +813,17 @@ func (r *RHEL82) qemuAssembler(format string, filename string, uefi bool, size u
 			},
 		}
 	} else {
-		options = pipeline.QEMUAssemblerOptions{
+		options = osbuild.QEMUAssemblerOptions{
 			Format:   format,
 			Filename: filename,
 			Size:     size,
 			PTUUID:   "0x14fc63d2",
 			PTType:   "mbr",
-			Partitions: []pipeline.QEMUPartition{
+			Partitions: []osbuild.QEMUPartition{
 				{
 					Start:    2048,
 					Bootable: true,
-					Filesystem: pipeline.QEMUFilesystem{
+					Filesystem: osbuild.QEMUFilesystem{
 						Type:       "xfs",
 						UUID:       "0bd700f8-090f-4556-b797-b340297ea1bd",
 						Mountpoint: "/",
@@ -789,24 +832,24 @@ func (r *RHEL82) qemuAssembler(format string, filename string, uefi bool, size u
 			},
 		}
 	}
-	return pipeline.NewQEMUAssembler(&options)
+	return osbuild.NewQEMUAssembler(&options)
 }
 
-func (r *RHEL82) tarAssembler(filename, compression string) *pipeline.Assembler {
-	return pipeline.NewTarAssembler(
-		&pipeline.TarAssemblerOptions{
+func (r *RHEL82) tarAssembler(filename, compression string) *osbuild.Assembler {
+	return osbuild.NewTarAssembler(
+		&osbuild.TarAssemblerOptions{
 			Filename:    filename,
 			Compression: compression,
 		})
 }
 
-func (r *RHEL82) rawFSAssembler(filename string, size uint64) *pipeline.Assembler {
+func (r *RHEL82) rawFSAssembler(filename string, size uint64) *osbuild.Assembler {
 	id, err := uuid.Parse("0bd700f8-090f-4556-b797-b340297ea1bd")
 	if err != nil {
 		panic("invalid UUID")
 	}
-	return pipeline.NewRawFSAssembler(
-		&pipeline.RawFSAssemblerOptions{
+	return osbuild.NewRawFSAssembler(
+		&osbuild.RawFSAssemblerOptions{
 			Filename:           filename,
 			RootFilesystemUUDI: id,
 			Size:               size,
diff --git a/internal/jobqueue/api.go b/internal/jobqueue/api.go
index faa386e31..de3b001fd 100644
--- a/internal/jobqueue/api.go
+++ b/internal/jobqueue/api.go
@@ -5,6 +5,7 @@ import (
 	"log"
 	"net"
 	"net/http"
+	"strconv"
 
 	"github.com/osbuild/osbuild-composer/internal/store"
 
@@ -31,7 +32,8 @@ func New(logger *log.Logger, store *store.Store) *API {
 	api.router.NotFound = http.HandlerFunc(notFoundHandler)
 
 	api.router.POST("/job-queue/v1/jobs", api.addJobHandler)
-	api.router.PATCH("/job-queue/v1/jobs/:id", api.updateJobHandler)
+	api.router.PATCH("/job-queue/v1/jobs/:job_id/builds/:build_id", api.updateJobHandler)
+	api.router.POST("/job-queue/v1/jobs/:job_id/builds/:build_id/image", api.addJobImageHandler)
 
 	return api
 }
@@ -70,6 +72,10 @@ func statusResponseOK(writer http.ResponseWriter) {
 
 func statusResponseError(writer http.ResponseWriter, code int, errors ...string) {
 	writer.WriteHeader(code)
+
+	for _, err := range errors {
+		writer.Write([]byte(err))
+	}
 }
 
 func (api *API) addJobHandler(writer http.ResponseWriter, request *http.Request, _ httprouter.Params) {
@@ -94,12 +100,12 @@ func (api *API) addJobHandler(writer http.ResponseWriter, request *http.Request,
 	writer.WriteHeader(http.StatusCreated)
 	// FIXME: handle or comment this possible error
 	_ = json.NewEncoder(writer).Encode(Job{
-		ID: nextJob.ComposeID,
+		ID:           nextJob.ComposeID,
 		ImageBuildID: nextJob.ImageBuildID,
-		Distro: nextJob.Distro,
-		Pipeline: nextJob.Pipeline,
-		Targets: nextJob.Targets,
-		OutputType: nextJob.ImageType,
+		Distro:       nextJob.Distro,
+		Pipeline:     nextJob.Pipeline,
+		Targets:      nextJob.Targets,
+		OutputType:   nextJob.ImageType,
 	})
 }
 
@@ -110,12 +116,19 @@ func (api *API) updateJobHandler(writer http.ResponseWriter, request *http.Reque
 		return
 	}
 
-	id, err := uuid.Parse(params.ByName("id"))
+	id, err := uuid.Parse(params.ByName("job_id"))
 	if err != nil {
 		statusResponseError(writer, http.StatusBadRequest, "invalid compose id: "+err.Error())
 		return
 	}
 
+	imageBuildId, err := strconv.Atoi(params.ByName("build_id"))
+
+	if err != nil {
+		statusResponseError(writer, http.StatusBadRequest, "invalid image build id: "+err.Error())
+		return
+	}
+
 	var body JobStatus
 	err = json.NewDecoder(request.Body).Decode(&body)
 	if err != nil {
@@ -123,7 +136,7 @@ func (api *API) updateJobHandler(writer http.ResponseWriter, request *http.Reque
 		return
 	}
 
-	err = api.store.UpdateImageBuildInCompose(id, body.ImageBuildID, body.Status, body.Image, body.Result)
+	err = api.store.UpdateImageBuildInCompose(id, imageBuildId, body.Status, body.Result)
 	if err != nil {
 		switch err.(type) {
 		case *store.NotFoundError:
@@ -141,3 +154,34 @@ func (api *API) updateJobHandler(writer http.ResponseWriter, request *http.Reque
 	}
 	statusResponseOK(writer)
 }
+
+func (api *API) addJobImageHandler(writer http.ResponseWriter, request *http.Request, params httprouter.Params) {
+	id, err := uuid.Parse(params.ByName("job_id"))
+	if err != nil {
+		statusResponseError(writer, http.StatusBadRequest, "invalid compose id: "+err.Error())
+		return
+	}
+
+	imageBuildId, err := strconv.Atoi(params.ByName("build_id"))
+
+	if err != nil {
+		statusResponseError(writer, http.StatusBadRequest, "invalid build id: "+err.Error())
+		return
+	}
+
+	err = api.store.AddImageToImageUpload(id, imageBuildId, request.Body)
+
+	if err != nil {
+		switch err.(type) {
+		case *store.NotFoundError:
+			statusResponseError(writer, http.StatusNotFound, err.Error())
+		case *store.NoLocalTargetError:
+			statusResponseError(writer, http.StatusBadRequest, err.Error())
+		default:
+			statusResponseError(writer, http.StatusInternalServerError, err.Error())
+		}
+		return
+	}
+
+	statusResponseOK(writer)
+}
diff --git a/internal/jobqueue/job.go b/internal/jobqueue/job.go
index 5d6911887..d0c8cb93c 100644
--- a/internal/jobqueue/job.go
+++ b/internal/jobqueue/job.go
@@ -3,34 +3,33 @@ package jobqueue
 import (
 	"encoding/json"
 	"fmt"
-	"github.com/osbuild/osbuild-composer/internal/compose"
 	"io/ioutil"
 	"os"
 	"os/exec"
 
+	"github.com/osbuild/osbuild-composer/internal/compose"
+	"github.com/osbuild/osbuild-composer/internal/osbuild"
+
 	"github.com/google/uuid"
 
 	"github.com/osbuild/osbuild-composer/internal/common"
 	"github.com/osbuild/osbuild-composer/internal/distro"
-	"github.com/osbuild/osbuild-composer/internal/pipeline"
 	"github.com/osbuild/osbuild-composer/internal/target"
 	"github.com/osbuild/osbuild-composer/internal/upload/awsupload"
 )
 
 type Job struct {
-	ID           uuid.UUID          `json:"id"`
-	ImageBuildID int                `json:"image_build_id"`
-	Distro       string             `json:"distro"`
-	Pipeline     *pipeline.Pipeline `json:"pipeline"`
-	Targets      []*target.Target   `json:"targets"`
-	OutputType   string             `json:"output_type"`
+	ID           uuid.UUID         `json:"id"`
+	ImageBuildID int               `json:"image_build_id"`
+	Distro       string            `json:"distro"`
+	Pipeline     *osbuild.Pipeline `json:"pipeline"`
+	Targets      []*target.Target  `json:"targets"`
+	OutputType   string            `json:"output_type"`
 }
 
 type JobStatus struct {
-	Status       common.ImageBuildState `json:"status"`
-	ImageBuildID int                    `json:"image_build_id"`
-	Image        *compose.Image         `json:"image"`
-	Result       *common.ComposeResult  `json:"result"`
+	Status common.ImageBuildState `json:"status"`
+	Result *common.ComposeResult  `json:"result"`
 }
 
 type TargetsError struct {
@@ -47,32 +46,32 @@ func (e *TargetsError) Error() string {
 	return errString
 }
 
-func (job *Job) Run() (*compose.Image, *common.ComposeResult, error) {
+func (job *Job) Run(uploader LocalTargetUploader) (*common.ComposeResult, error) {
 	distros := distro.NewRegistry([]string{"/etc/osbuild-composer", "/usr/share/osbuild-composer"})
 	d := distros.GetDistro(job.Distro)
 	if d == nil {
-		return nil, nil, fmt.Errorf("unknown distro: %s", job.Distro)
+		return nil, fmt.Errorf("unknown distro: %s", job.Distro)
 	}
 
-	build := pipeline.Build{
+	build := osbuild.Build{
 		Runner: d.Runner(),
 	}
 
 	buildFile, err := ioutil.TempFile("", "osbuild-worker-build-env-*")
 	if err != nil {
-		return nil, nil, err
+		return nil, err
 	}
 	// FIXME: how to handle errors in defer?
 	defer os.Remove(buildFile.Name())
 
 	err = json.NewEncoder(buildFile).Encode(build)
 	if err != nil {
-		return nil, nil, fmt.Errorf("error encoding build environment: %v", err)
+		return nil, fmt.Errorf("error encoding build environment: %v", err)
 	}
 
 	tmpStore, err := ioutil.TempDir("/var/tmp", "osbuild-store")
 	if err != nil {
-		return nil, nil, fmt.Errorf("error setting up osbuild store: %v", err)
+		return nil, fmt.Errorf("error setting up osbuild store: %v", err)
 	}
 	// FIXME: how to handle errors in defer?
 	defer os.RemoveAll(tmpStore)
@@ -87,22 +86,22 @@ func (job *Job) Run() (*compose.Image, *common.ComposeResult, error) {
 
 	stdin, err := cmd.StdinPipe()
 	if err != nil {
-		return nil, nil, fmt.Errorf("error setting up stdin for osbuild: %v", err)
+		return nil, fmt.Errorf("error setting up stdin for osbuild: %v", err)
 	}
 
 	stdout, err := cmd.StdoutPipe()
 	if err != nil {
-		return nil, nil, fmt.Errorf("error setting up stdout for osbuild: %v", err)
+		return nil, fmt.Errorf("error setting up stdout for osbuild: %v", err)
 	}
 
 	err = cmd.Start()
 	if err != nil {
-		return nil, nil, fmt.Errorf("error starting osbuild: %v", err)
+		return nil, fmt.Errorf("error starting osbuild: %v", err)
 	}
 
 	err = json.NewEncoder(stdin).Encode(job.Pipeline)
 	if err != nil {
-		return nil, nil, fmt.Errorf("error encoding osbuild pipeline: %v", err)
+		return nil, fmt.Errorf("error encoding osbuild pipeline: %v", err)
 	}
 	// FIXME: handle or comment this possible error
 	_ = stdin.Close()
@@ -110,57 +109,37 @@ func (job *Job) Run() (*compose.Image, *common.ComposeResult, error) {
 	var result common.ComposeResult
 	err = json.NewDecoder(stdout).Decode(&result)
 	if err != nil {
-		return nil, nil, fmt.Errorf("error decoding osbuild output: %#v", err)
+		return nil, fmt.Errorf("error decoding osbuild output: %#v", err)
 	}
 
 	err = cmd.Wait()
 	if err != nil {
-		return nil, &result, err
+		return &result, err
 	}
 
-	filename, mimeType, err := d.FilenameFromType(job.OutputType)
-	if err != nil {
-		return nil, &result, fmt.Errorf("cannot fetch information about output type %s: %v", job.OutputType, err)
-	}
-
-	var image compose.Image
-
 	var r []error
 
 	for _, t := range job.Targets {
 		switch options := t.Options.(type) {
 		case *target.LocalTargetOptions:
-
-			err = runCommand("cp", "-a", "-L", tmpStore+"/refs/"+result.OutputID+"/.", options.Location)
+			filename, _, err := d.FilenameFromType(job.OutputType)
 			if err != nil {
 				r = append(r, err)
 				continue
 			}
 
-			err = runCommand("chown", "-R", "_osbuild-composer:_osbuild-composer", options.Location)
+			f, err := os.Open(tmpStore + "/refs/" + result.OutputID + "/" + filename)
 			if err != nil {
 				r = append(r, err)
 				continue
 			}
 
-			imagePath := options.Location + "/" + filename
-			file, err := os.Open(imagePath)
+			err = uploader.UploadImage(job, f)
 			if err != nil {
 				r = append(r, err)
 				continue
 			}
 
-			fileStat, err := file.Stat()
-			if err != nil {
-				return nil, &result, err
-			}
-
-			image = compose.Image{
-				Path: imagePath,
-				Mime: mimeType,
-				Size: uint64(fileStat.Size()),
-			}
-
 		case *target.AWSTargetOptions:
 
 			a, err := awsupload.New(options.Region, options.AccessKeyID, options.SecretAccessKey)
@@ -192,10 +171,10 @@ func (job *Job) Run() (*compose.Image, *common.ComposeResult, error) {
 	}
 
 	if len(r) > 0 {
-		return nil, &result, &TargetsError{r}
+		return &result, &TargetsError{r}
 	}
 
-	return &image, &result, nil
+	return &result, nil
 }
 
 func runCommand(command string, params ...string) error {
diff --git a/internal/jobqueue/local_target_uploader.go b/internal/jobqueue/local_target_uploader.go
new file mode 100644
index 000000000..292b34733
--- /dev/null
+++ b/internal/jobqueue/local_target_uploader.go
@@ -0,0 +1,7 @@
+package jobqueue
+
+import "io"
+
+type LocalTargetUploader interface {
+	UploadImage(job *Job, reader io.Reader) error
+}
diff --git a/internal/mocks/rpmmd/fixtures.go b/internal/mocks/rpmmd/fixtures.go
index 11976cccd..60f7b07cb 100644
--- a/internal/mocks/rpmmd/fixtures.go
+++ b/internal/mocks/rpmmd/fixtures.go
@@ -75,9 +75,7 @@ func createBaseStoreFixture() *store.Store {
 		ImageName: "localimage",
 		Created:   date,
 		Status:    common.IBWaiting,
-		Options: &target.LocalTargetOptions{
-			Location: "/tmp/localimage",
-		},
+		Options:   &target.LocalTargetOptions{},
 	}
 
 	var awsTarget = &target.Target{
diff --git a/internal/mocks/rpmmd/rpmmd_mock.go b/internal/mocks/rpmmd/rpmmd_mock.go
index d5d3162fb..3bc8fe40e 100644
--- a/internal/mocks/rpmmd/rpmmd_mock.go
+++ b/internal/mocks/rpmmd/rpmmd_mock.go
@@ -6,14 +6,14 @@ import (
 )
 
 type fetchPackageList struct {
-	ret rpmmd.PackageList
+	ret       rpmmd.PackageList
 	checksums map[string]string
-	err error
+	err       error
 }
 type depsolve struct {
-	ret []rpmmd.PackageSpec
+	ret       []rpmmd.PackageSpec
 	checksums map[string]string
-	err error
+	err       error
 }
 
 type Fixture struct {
@@ -30,10 +30,10 @@ func NewRPMMDMock(fixture Fixture) rpmmd.RPMMD {
 	return &rpmmdMock{Fixture: fixture}
 }
 
-func (r *rpmmdMock) FetchPackageList(repos []rpmmd.RepoConfig) (rpmmd.PackageList, map[string]string, error) {
+func (r *rpmmdMock) FetchPackageList(repos []rpmmd.RepoConfig, modulePlatformID string) (rpmmd.PackageList, map[string]string, error) {
 	return r.Fixture.fetchPackageList.ret, r.Fixture.fetchPackageList.checksums, r.Fixture.fetchPackageList.err
 }
 
-func (r *rpmmdMock) Depsolve(specs []string, repos []rpmmd.RepoConfig, clean bool) ([]rpmmd.PackageSpec, map[string]string, error) {
+func (r *rpmmdMock) Depsolve(specs, excludeSpecs []string, repos []rpmmd.RepoConfig, modulePlatformID string, clean bool) ([]rpmmd.PackageSpec, map[string]string, error) {
 	return r.Fixture.depsolve.ret, r.Fixture.fetchPackageList.checksums, r.Fixture.depsolve.err
 }
diff --git a/internal/pipeline/assembler.go b/internal/osbuild/assembler.go
similarity index 98%
rename from internal/pipeline/assembler.go
rename to internal/osbuild/assembler.go
index bbf5c2c7a..44e8324c7 100644
--- a/internal/pipeline/assembler.go
+++ b/internal/osbuild/assembler.go
@@ -1,4 +1,4 @@
-package pipeline
+package osbuild
 
 import (
 	"encoding/json"
diff --git a/internal/pipeline/chrony_stage.go b/internal/osbuild/chrony_stage.go
similarity index 93%
rename from internal/pipeline/chrony_stage.go
rename to internal/osbuild/chrony_stage.go
index bcbd334ef..1be51c2dc 100644
--- a/internal/pipeline/chrony_stage.go
+++ b/internal/osbuild/chrony_stage.go
@@ -1,4 +1,4 @@
-package pipeline
+package osbuild
 
 type ChronyStageOptions struct {
 	Timeservers []string `json:"timeservers"`
diff --git a/internal/pipeline/dnf_stage.go b/internal/osbuild/dnf_stage.go
similarity index 97%
rename from internal/pipeline/dnf_stage.go
rename to internal/osbuild/dnf_stage.go
index aadeb3d9a..d8a26ccd0 100644
--- a/internal/pipeline/dnf_stage.go
+++ b/internal/osbuild/dnf_stage.go
@@ -1,4 +1,4 @@
-package pipeline
+package osbuild
 
 // The DNFStageOptions describe the operations of the DNF stage.
 //
@@ -13,7 +13,7 @@ type DNFStageOptions struct {
 	ExcludedPackages []string         `json:"exclude_packages,omitempty"`
 	ReleaseVersion   string           `json:"releasever"`
 	BaseArchitecture string           `json:"basearch"`
-	ModulePlatformId string		  `json:"module_platform_id,omitempty"`
+	ModulePlatformId string           `json:"module_platform_id,omitempty"`
 }
 
 func (DNFStageOptions) isStageOptions() {}
diff --git a/internal/osbuild/files_source.go b/internal/osbuild/files_source.go
new file mode 100644
index 000000000..b3b946873
--- /dev/null
+++ b/internal/osbuild/files_source.go
@@ -0,0 +1,8 @@
+package osbuild
+
+// The FilesSourceOptions specifies a custom script to run in the image
+type FilesSource struct {
+	URLs map[string]string `json:"urls"`
+}
+
+func (FilesSource) isSource() {}
diff --git a/internal/pipeline/firewall_stage.go b/internal/osbuild/firewall_stage.go
similarity index 95%
rename from internal/pipeline/firewall_stage.go
rename to internal/osbuild/firewall_stage.go
index 0479f388c..fd74fb150 100644
--- a/internal/pipeline/firewall_stage.go
+++ b/internal/osbuild/firewall_stage.go
@@ -1,4 +1,4 @@
-package pipeline
+package osbuild
 
 type FirewallStageOptions struct {
 	Ports            []string `json:"ports,omitempty"`
diff --git a/internal/pipeline/fix_bls_stage.go b/internal/osbuild/fix_bls_stage.go
similarity index 97%
rename from internal/pipeline/fix_bls_stage.go
rename to internal/osbuild/fix_bls_stage.go
index 769514226..b96074c6b 100644
--- a/internal/pipeline/fix_bls_stage.go
+++ b/internal/osbuild/fix_bls_stage.go
@@ -1,4 +1,4 @@
-package pipeline
+package osbuild
 
 // A FixBLSStageOptions struct is empty, as the stage takes no options.
 //
diff --git a/internal/pipeline/fstab_stage.go b/internal/osbuild/fstab_stage.go
similarity index 98%
rename from internal/pipeline/fstab_stage.go
rename to internal/osbuild/fstab_stage.go
index 70abc468f..8c0d6fc1c 100644
--- a/internal/pipeline/fstab_stage.go
+++ b/internal/osbuild/fstab_stage.go
@@ -1,4 +1,4 @@
-package pipeline
+package osbuild
 
 // The FSTabStageOptions describe the content of the /etc/fstab file.
 //
diff --git a/internal/pipeline/groups_stage.go b/internal/osbuild/groups_stage.go
similarity index 95%
rename from internal/pipeline/groups_stage.go
rename to internal/osbuild/groups_stage.go
index 42f3724b1..0d900fbbb 100644
--- a/internal/pipeline/groups_stage.go
+++ b/internal/osbuild/groups_stage.go
@@ -1,4 +1,4 @@
-package pipeline
+package osbuild
 
 type GroupsStageOptions struct {
 	Groups map[string]GroupsStageOptionsGroup `json:"groups"`
diff --git a/internal/pipeline/grub2_stage.go b/internal/osbuild/grub2_stage.go
similarity index 98%
rename from internal/pipeline/grub2_stage.go
rename to internal/osbuild/grub2_stage.go
index 64fd69eee..5c3cbf981 100644
--- a/internal/pipeline/grub2_stage.go
+++ b/internal/osbuild/grub2_stage.go
@@ -1,4 +1,4 @@
-package pipeline
+package osbuild
 
 import "github.com/google/uuid"
 
diff --git a/internal/pipeline/hostname_stage.go b/internal/osbuild/hostname_stage.go
similarity index 93%
rename from internal/pipeline/hostname_stage.go
rename to internal/osbuild/hostname_stage.go
index 095474b31..71d7a44a4 100644
--- a/internal/pipeline/hostname_stage.go
+++ b/internal/osbuild/hostname_stage.go
@@ -1,4 +1,4 @@
-package pipeline
+package osbuild
 
 type HostnameStageOptions struct {
 	Hostname string `json:"hostname"`
diff --git a/internal/pipeline/keymap_stage.go b/internal/osbuild/keymap_stage.go
similarity index 93%
rename from internal/pipeline/keymap_stage.go
rename to internal/osbuild/keymap_stage.go
index 8c5e33c64..9fd4fb700 100644
--- a/internal/pipeline/keymap_stage.go
+++ b/internal/osbuild/keymap_stage.go
@@ -1,4 +1,4 @@
-package pipeline
+package osbuild
 
 type KeymapStageOptions struct {
 	Keymap string `json:"keymap"`
diff --git a/internal/pipeline/locale_stage.go b/internal/osbuild/locale_stage.go
similarity index 97%
rename from internal/pipeline/locale_stage.go
rename to internal/osbuild/locale_stage.go
index 411431e20..0454ef8a9 100644
--- a/internal/pipeline/locale_stage.go
+++ b/internal/osbuild/locale_stage.go
@@ -1,4 +1,4 @@
-package pipeline
+package osbuild
 
 // The LocaleStageOptions describes the image's locale.
 //
diff --git a/internal/pipeline/pipeline.go b/internal/osbuild/osbuild.go
similarity index 89%
rename from internal/pipeline/pipeline.go
rename to internal/osbuild/osbuild.go
index 7a838f79d..aa282c986 100644
--- a/internal/pipeline/pipeline.go
+++ b/internal/osbuild/osbuild.go
@@ -1,6 +1,6 @@
-// Package pipeline provides primitives for representing and (un)marshalling
-// OSBuild pipelines.
-package pipeline
+// Package osbuild provides primitives for representing and (un)marshalling
+// OSBuild types.
+package osbuild
 
 // A Pipeline represents an OSBuild pipeline
 type Pipeline struct {
@@ -25,7 +25,7 @@ type Build struct {
 func (p *Pipeline) SetBuild(pipeline *Pipeline, runner string) {
 	p.Build = &Build{
 		Pipeline: pipeline,
-		Runner: runner,
+		Runner:   runner,
 	}
 }
 
diff --git a/internal/pipeline/qemu_assembler.go b/internal/osbuild/qemu_assembler.go
similarity index 98%
rename from internal/pipeline/qemu_assembler.go
rename to internal/osbuild/qemu_assembler.go
index d478f31aa..22699e6cb 100644
--- a/internal/pipeline/qemu_assembler.go
+++ b/internal/osbuild/qemu_assembler.go
@@ -1,4 +1,4 @@
-package pipeline
+package osbuild
 
 import "github.com/google/uuid"
 
diff --git a/internal/pipeline/rawfs_assembler.go b/internal/osbuild/rawfs_assembler.go
similarity index 97%
rename from internal/pipeline/rawfs_assembler.go
rename to internal/osbuild/rawfs_assembler.go
index b6408ca4c..5bb0c0817 100644
--- a/internal/pipeline/rawfs_assembler.go
+++ b/internal/osbuild/rawfs_assembler.go
@@ -1,4 +1,4 @@
-package pipeline
+package osbuild
 
 import "github.com/google/uuid"
 
diff --git a/internal/osbuild/rpm_stage.go b/internal/osbuild/rpm_stage.go
new file mode 100644
index 000000000..37f8e54bf
--- /dev/null
+++ b/internal/osbuild/rpm_stage.go
@@ -0,0 +1,21 @@
+package osbuild
+
+// The RPMStageOptions describe the operations of the RPM stage.
+//
+// The RPM stage installs a given set of packages, identified by their
+// content hash. This ensures that given a set of RPM stage options,
+// the output is be reproducible, if the underlying tools are.
+type RPMStageOptions struct {
+	GPGKeys  []string `json:"gpgkeys,omitempty"`
+	Packages []string `json:"packages"`
+}
+
+func (RPMStageOptions) isStageOptions() {}
+
+// NewRPMStage creates a new RPM stage.
+func NewRPMStage(options *RPMStageOptions) *Stage {
+	return &Stage{
+		Name:    "org.osbuild.rpm",
+		Options: options,
+	}
+}
diff --git a/internal/pipeline/script_stage.go b/internal/osbuild/script_stage.go
similarity index 97%
rename from internal/pipeline/script_stage.go
rename to internal/osbuild/script_stage.go
index a6b194a07..4d2bc766e 100644
--- a/internal/pipeline/script_stage.go
+++ b/internal/osbuild/script_stage.go
@@ -1,4 +1,4 @@
-package pipeline
+package osbuild
 
 // The ScriptStageOptions specifies a custom script to run in the image
 type ScriptStageOptions struct {
diff --git a/internal/pipeline/selinux_stage.go b/internal/osbuild/selinux_stage.go
similarity index 97%
rename from internal/pipeline/selinux_stage.go
rename to internal/osbuild/selinux_stage.go
index 747169e30..80aa93b1f 100644
--- a/internal/pipeline/selinux_stage.go
+++ b/internal/osbuild/selinux_stage.go
@@ -1,4 +1,4 @@
-package pipeline
+package osbuild
 
 // The SELinuxStageOptions describe how to apply selinux labels.
 //
diff --git a/internal/osbuild/source.go b/internal/osbuild/source.go
new file mode 100644
index 000000000..ca763210a
--- /dev/null
+++ b/internal/osbuild/source.go
@@ -0,0 +1,43 @@
+package osbuild
+
+import (
+	"encoding/json"
+	"errors"
+)
+
+// A Sources map contains all the sources made available to an osbuild run
+type Sources map[string]Source
+
+// Source specifies the operations of a given source-type.
+type Source interface {
+	isSource()
+}
+
+type rawSources map[string]json.RawMessage
+
+// UnmarshalJSON unmarshals JSON into a Source object. Each type of source has
+// a custom unmarshaller for its options, selected based on the source name.
+func (sources *Sources) UnmarshalJSON(data []byte) error {
+	var rawSources rawSources
+	err := json.Unmarshal(data, &rawSources)
+	if err != nil {
+		return err
+	}
+	*sources = make(map[string]Source)
+	for name, rawSource := range rawSources {
+		var source Source
+		switch name {
+		case "org.osbuild.files":
+			source = new(FilesSource)
+		default:
+			return errors.New("unexpected suorce name" + name)
+		}
+		err = json.Unmarshal(rawSource, source)
+		if err != nil {
+			return err
+		}
+		(*sources)[name] = source
+	}
+
+	return nil
+}
diff --git a/internal/pipeline/stage.go b/internal/osbuild/stage.go
similarity index 96%
rename from internal/pipeline/stage.go
rename to internal/osbuild/stage.go
index 3f6e2c56d..571bbb488 100644
--- a/internal/pipeline/stage.go
+++ b/internal/osbuild/stage.go
@@ -1,4 +1,4 @@
-package pipeline
+package osbuild
 
 import (
 	"encoding/json"
@@ -62,6 +62,8 @@ func (stage *Stage) UnmarshalJSON(data []byte) error {
 		options = new(KeymapStageOptions)
 	case "org.osbuild.firewall":
 		options = new(FirewallStageOptions)
+	case "org.osbuild.rpm":
+		options = new(RPMStageOptions)
 	case "org.osbuild.systemd":
 		options = new(SystemdStageOptions)
 	case "org.osbuild.script":
diff --git a/internal/pipeline/systemd_stage.go b/internal/osbuild/systemd_stage.go
similarity index 96%
rename from internal/pipeline/systemd_stage.go
rename to internal/osbuild/systemd_stage.go
index cc4aeb57f..b0c6043ee 100644
--- a/internal/pipeline/systemd_stage.go
+++ b/internal/osbuild/systemd_stage.go
@@ -1,4 +1,4 @@
-package pipeline
+package osbuild
 
 type SystemdStageOptions struct {
 	EnabledServices  []string `json:"enabled_services,omitempty"`
diff --git a/internal/pipeline/tar_assembler.go b/internal/osbuild/tar_assembler.go
similarity index 98%
rename from internal/pipeline/tar_assembler.go
rename to internal/osbuild/tar_assembler.go
index 993572e0d..01f735af9 100644
--- a/internal/pipeline/tar_assembler.go
+++ b/internal/osbuild/tar_assembler.go
@@ -1,4 +1,4 @@
-package pipeline
+package osbuild
 
 // TarAssemblerOptions desrcibe how to assemble a tree into a tar ball.
 //
diff --git a/internal/pipeline/timezone_stage.go b/internal/osbuild/timezone_stage.go
similarity index 93%
rename from internal/pipeline/timezone_stage.go
rename to internal/osbuild/timezone_stage.go
index 08db9dc3d..130ac5f16 100644
--- a/internal/pipeline/timezone_stage.go
+++ b/internal/osbuild/timezone_stage.go
@@ -1,4 +1,4 @@
-package pipeline
+package osbuild
 
 type TimezoneStageOptions struct {
 	Zone string `json:"zone"`
diff --git a/internal/pipeline/users_stage.go b/internal/osbuild/users_stage.go
similarity index 97%
rename from internal/pipeline/users_stage.go
rename to internal/osbuild/users_stage.go
index f9072e8b0..dc1ac5fbe 100644
--- a/internal/pipeline/users_stage.go
+++ b/internal/osbuild/users_stage.go
@@ -1,4 +1,4 @@
-package pipeline
+package osbuild
 
 type UsersStageOptions struct {
 	Users map[string]UsersStageOptionsUser `json:"users"`
diff --git a/internal/rcm/api.go b/internal/rcm/api.go
index da77035aa..aecdf9c14 100644
--- a/internal/rcm/api.go
+++ b/internal/rcm/api.go
@@ -130,7 +130,7 @@ func (api *API) submit(writer http.ResponseWriter, request *http.Request, _ http
 	composeUUID := uuid.New()
 	// nil is used as an upload target, because LocalTarget is already used in the PushCompose function
 	// TODO: replace this with generalized version of push compose
-	err = api.store.PushCompose(composeUUID, &blueprint.Blueprint{}, make(map[string]string), composeRequest.Architectures[0], composeRequest.ImageTypes[0], 0, nil)
+	err = api.store.PushCompose(composeUUID, &blueprint.Blueprint{}, nil, nil, make(map[string]string), composeRequest.Architectures[0], composeRequest.ImageTypes[0], 0, nil)
 	if err != nil {
 		if api.logger != nil {
 			api.logger.Println("RCM API failed to push compose:", err)
diff --git a/internal/rpmmd/repository.go b/internal/rpmmd/repository.go
index 04f747666..5227a8ee9 100644
--- a/internal/rpmmd/repository.go
+++ b/internal/rpmmd/repository.go
@@ -62,11 +62,13 @@ func (pkg Package) ToPackageInfo() PackageInfo {
 }
 
 type PackageSpec struct {
-	Name    string `json:"name"`
-	Epoch   uint   `json:"epoch"`
-	Version string `json:"version,omitempty"`
-	Release string `json:"release,omitempty"`
-	Arch    string `json:"arch,omitempty"`
+	Name           string `json:"name"`
+	Epoch          uint   `json:"epoch"`
+	Version        string `json:"version,omitempty"`
+	Release        string `json:"release,omitempty"`
+	Arch           string `json:"arch,omitempty"`
+	RemoteLocation string `json:"remote_location,omitempty"`
+	Checksum       string `json:"checksum,omitempty"`
 }
 
 type PackageSource struct {
@@ -93,8 +95,8 @@ type PackageInfo struct {
 }
 
 type RPMMD interface {
-	FetchPackageList(repos []RepoConfig) (PackageList, map[string]string, error)
-	Depsolve(specs []string, repos []RepoConfig, clean bool) ([]PackageSpec, map[string]string, error)
+	FetchPackageList(repos []RepoConfig, modulePlatformID string) (PackageList, map[string]string, error)
+	Depsolve(specs, excludeSpecs []string, repos []RepoConfig, modulePlatformID string, clean bool) ([]PackageSpec, map[string]string, error)
 }
 
 type DNFError struct {
@@ -197,10 +199,11 @@ func NewRPMMD() RPMMD {
 	return &rpmmdImpl{}
 }
 
-func (*rpmmdImpl) FetchPackageList(repos []RepoConfig) (PackageList, map[string]string, error) {
+func (*rpmmdImpl) FetchPackageList(repos []RepoConfig, modulePlatformID string) (PackageList, map[string]string, error) {
 	var arguments = struct {
-		Repos []RepoConfig `json:"repos"`
-	}{repos}
+		Repos            []RepoConfig `json:"repos"`
+		ModulePlatformID string       `json:"module_platform_id"`
+	}{repos, modulePlatformID}
 	var reply struct {
 		Checksums map[string]string `json:"checksums"`
 		Packages  PackageList       `json:"packages"`
@@ -212,12 +215,14 @@ func (*rpmmdImpl) FetchPackageList(repos []RepoConfig) (PackageList, map[string]
 	return reply.Packages, reply.Checksums, err
 }
 
-func (*rpmmdImpl) Depsolve(specs []string, repos []RepoConfig, clean bool) ([]PackageSpec, map[string]string, error) {
+func (*rpmmdImpl) Depsolve(specs, excludeSpecs []string, repos []RepoConfig, modulePlatformID string, clean bool) ([]PackageSpec, map[string]string, error) {
 	var arguments = struct {
-		PackageSpecs []string     `json:"package-specs"`
-		Repos        []RepoConfig `json:"repos"`
-		Clean        bool         `json:"clean,omitempty"`
-	}{specs, repos, clean}
+		PackageSpecs     []string     `json:"package-specs"`
+		ExcludSpecs      []string     `json:"exclude-specs"`
+		Repos            []RepoConfig `json:"repos"`
+		ModulePlatformID string       `json:"module_platform_id"`
+		Clean            bool         `json:"clean,omitempty"`
+	}{specs, excludeSpecs, repos, modulePlatformID, clean}
 	var reply struct {
 		Checksums    map[string]string `json:"checksums"`
 		Dependencies []PackageSpec     `json:"dependencies"`
@@ -278,7 +283,7 @@ func (packages PackageList) ToPackageInfos() []PackageInfo {
 	return results
 }
 
-func (pkg *PackageInfo) FillDependencies(rpmmd RPMMD, repos []RepoConfig) (err error) {
-	pkg.Dependencies, _, err = rpmmd.Depsolve([]string{pkg.Name}, repos, false)
+func (pkg *PackageInfo) FillDependencies(rpmmd RPMMD, repos []RepoConfig, modulePlatformID string) (err error) {
+	pkg.Dependencies, _, err = rpmmd.Depsolve([]string{pkg.Name}, nil, repos, modulePlatformID, false)
 	return
 }
diff --git a/internal/store/store.go b/internal/store/store.go
index 2408e22c5..db157348e 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -10,7 +10,6 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
-	"github.com/osbuild/osbuild-composer/internal/compose"
 	"io"
 	"io/ioutil"
 	"log"
@@ -22,10 +21,12 @@ import (
 	"sync"
 	"time"
 
+	"github.com/osbuild/osbuild-composer/internal/compose"
+	"github.com/osbuild/osbuild-composer/internal/osbuild"
+
 	"github.com/osbuild/osbuild-composer/internal/blueprint"
 	"github.com/osbuild/osbuild-composer/internal/common"
 	"github.com/osbuild/osbuild-composer/internal/distro"
-	"github.com/osbuild/osbuild-composer/internal/pipeline"
 	"github.com/osbuild/osbuild-composer/internal/rpmmd"
 	"github.com/osbuild/osbuild-composer/internal/target"
 
@@ -54,7 +55,7 @@ type Job struct {
 	ComposeID    uuid.UUID
 	ImageBuildID int
 	Distro       string
-	Pipeline     *pipeline.Pipeline
+	Pipeline     *osbuild.Pipeline
 	Targets      []*target.Target
 	ImageType    string
 }
@@ -100,6 +101,14 @@ func (e *InvalidRequestError) Error() string {
 	return e.message
 }
 
+type NoLocalTargetError struct {
+	message string
+}
+
+func (e *NoLocalTargetError) Error() string {
+	return e.message
+}
+
 func New(stateDir *string, distroArg distro.Distro, distroRegistryArg distro.Registry) *Store {
 	var s Store
 
@@ -445,21 +454,59 @@ func (s *Store) GetAllComposes() map[uuid.UUID]compose.Compose {
 	return composes
 }
 
-func (s *Store) GetComposeResult(id uuid.UUID) (io.ReadCloser, error) {
+func (s *Store) GetImageBuildResult(composeId uuid.UUID, imageBuildId int) (io.ReadCloser, error) {
 	if s.stateDir == nil {
 		return ioutil.NopCloser(bytes.NewBuffer([]byte("{}"))), nil
 	}
-	return os.Open(*s.stateDir + "/outputs/" + id.String() + "/result.json")
+
+	return os.Open(s.getImageBuildDirectory(composeId, imageBuildId) + "/result.json")
+}
+
+func (s *Store) GetImageBuildImage(composeId uuid.UUID, imageBuildId int) (io.ReadCloser, int64, error) {
+	c, ok := s.Composes[composeId]
+
+	if !ok {
+		return nil, 0, &NotFoundError{"compose does not exist"}
+	}
+
+	imageBuild := c.ImageBuilds[imageBuildId]
+	if !imageBuild.HasLocalTarget() {
+		return nil, 0, &NoLocalTargetError{"compose does not have local target"}
+	}
+
+	compatString, _ := imageBuild.ImageType.ToCompatString()
+	filename, _, err := s.distro.FilenameFromType(compatString)
+	if err != nil {
+		panic(err)
+	}
+
+	path := fmt.Sprintf("%s/%s", s.getImageBuildDirectory(composeId, imageBuildId), filename)
+
+	f, err := os.Open(path)
+
+	if err != nil {
+		return nil, 0, err
+	}
+
+	fileInfo, err := f.Stat()
+
+	if err != nil {
+		return nil, 0, err
+	}
+
+	return f, fileInfo.Size(), err
+
 }
 
-func (s *Store) getImageLocationForLocalTarget(composeID uuid.UUID) string {
-	// This might result in conflicts because one compose can have multiple images, but (!) the Weldr API
-	// does not support multiple images per build and the RCM API does not support Local target, so it does
-	// not really matter any more.
-	return *s.stateDir + "/outputs/" + composeID.String()
+func (s *Store) getComposeDirectory(composeID uuid.UUID) string {
+	return fmt.Sprintf("%s/outputs/%s", *s.stateDir, composeID.String())
 }
 
-func (s *Store) PushCompose(composeID uuid.UUID, bp *blueprint.Blueprint, checksums map[string]string, arch, composeType string, size uint64, uploadTarget *target.Target) error {
+func (s *Store) getImageBuildDirectory(composeID uuid.UUID, imageBuildID int) string {
+	return fmt.Sprintf("%s/%d", s.getComposeDirectory(composeID), imageBuildID)
+}
+
+func (s *Store) PushCompose(composeID uuid.UUID, bp *blueprint.Blueprint, packages, buildPackages []rpmmd.PackageSpec, checksums map[string]string, arch, composeType string, size uint64, uploadTarget *target.Target) error {
 	targets := []*target.Target{}
 
 	// Compatibility layer for image types in Weldr API v0
@@ -469,7 +516,7 @@ func (s *Store) PushCompose(composeID uuid.UUID, bp *blueprint.Blueprint, checks
 	}
 
 	if s.stateDir != nil {
-		outputDir := s.getImageLocationForLocalTarget(composeID)
+		outputDir := s.getImageBuildDirectory(composeID, 0)
 
 		err := os.MkdirAll(outputDir, 0755)
 		if err != nil {
@@ -477,9 +524,7 @@ func (s *Store) PushCompose(composeID uuid.UUID, bp *blueprint.Blueprint, checks
 		}
 
 		targets = append(targets, target.NewLocalTarget(
-			&target.LocalTargetOptions{
-				Location: outputDir,
-			},
+			&target.LocalTargetOptions{},
 		))
 	}
 
@@ -494,7 +539,7 @@ func (s *Store) PushCompose(composeID uuid.UUID, bp *blueprint.Blueprint, checks
 		repos = append(repos, source.RepoConfig())
 	}
 
-	pipelineStruct, err := s.distro.Pipeline(bp, repos, checksums, arch, composeType, size)
+	pipelineStruct, err := s.distro.Pipeline(bp, repos, packages, buildPackages, checksums, arch, composeType, size)
 	if err != nil {
 		return err
 	}
@@ -554,7 +599,7 @@ func (s *Store) PushComposeRequest(request common.ComposeRequest) error {
 		panic("Multiple image requests are not yet properly implemented")
 	}
 
-	for n, imageRequest := range request.RequestedImages {
+	for imageBuildID, imageRequest := range request.RequestedImages {
 		// TODO: handle custom upload targets
 		// TODO: this requires changes in the Compose Request struct
 		// TODO: implment support for no checksums
@@ -563,15 +608,22 @@ func (s *Store) PushComposeRequest(request common.ComposeRequest) error {
 		if !exists {
 			panic("fatal error, image type should exist but it does not")
 		}
-		pipelineStruct, err := distroStruct.Pipeline(&request.Blueprint, request.Repositories, nil, arch, imgTypeCompatStr, 0)
+		pipelineStruct, err := distroStruct.Pipeline(&request.Blueprint, request.Repositories, nil, nil, nil, arch, imgTypeCompatStr, 0)
 		if err != nil {
 			return err
 		}
 
+		if s.stateDir != nil {
+			err = os.MkdirAll(s.getImageBuildDirectory(request.ComposeID, imageBuildID), 0755)
+			if err != nil {
+				return err
+			}
+		}
+
 		// This will make the job submission atomic: either all of them or none of them
 		newJobs = append(newJobs, Job{
 			ComposeID:    request.ComposeID,
-			ImageBuildID: n,
+			ImageBuildID: imageBuildID,
 			Distro:       distroString,
 			Pipeline:     pipelineStruct,
 			Targets:      []*target.Target{},
@@ -633,7 +685,7 @@ func (s *Store) DeleteCompose(id uuid.UUID) error {
 			// TODO: we need to rename the files as the compose will have multiple images
 			for _, imageBuild := range compose.ImageBuilds {
 				if imageBuild.QueueStatus == common.IBFinished || imageBuild.QueueStatus == common.IBFailed {
-					err = os.RemoveAll(s.getImageLocationForLocalTarget(id))
+					err = os.RemoveAll(s.getComposeDirectory(id))
 					if err != nil {
 						return err
 					}
@@ -683,7 +735,7 @@ func (s *Store) PopJob() Job {
 }
 
 // UpdateImageBuildInCompose sets the status and optionally also the final image.
-func (s *Store) UpdateImageBuildInCompose(composeID uuid.UUID, imageBuildID int, status common.ImageBuildState, image *compose.Image, result *common.ComposeResult) error {
+func (s *Store) UpdateImageBuildInCompose(composeID uuid.UUID, imageBuildID int, status common.ImageBuildState, result *common.ComposeResult) error {
 	return s.change(func() error {
 		// Check that the compose exists
 		currentCompose, exists := s.Composes[composeID]
@@ -697,7 +749,7 @@ func (s *Store) UpdateImageBuildInCompose(composeID uuid.UUID, imageBuildID int,
 
 		// write result into file
 		if s.stateDir != nil && result != nil {
-			f, err := os.Create(s.getImageLocationForLocalTarget(composeID) + "/result.json")
+			f, err := os.Create(s.getImageBuildDirectory(composeID, imageBuildID) + "/result.json")
 
 			if err != nil {
 				return fmt.Errorf("cannot open result.json for job %v: %#v", composeID, err)
@@ -717,9 +769,6 @@ func (s *Store) UpdateImageBuildInCompose(composeID uuid.UUID, imageBuildID int,
 		// In case the image build is done, store the time and possibly also the image
 		if status == common.IBFinished || status == common.IBFailed {
 			currentCompose.ImageBuilds[imageBuildID].JobFinished = time.Now()
-			if status == common.IBFinished {
-				currentCompose.ImageBuilds[imageBuildID].Image = image
-			}
 		}
 
 		s.Composes[composeID] = currentCompose
@@ -728,6 +777,40 @@ func (s *Store) UpdateImageBuildInCompose(composeID uuid.UUID, imageBuildID int,
 	})
 }
 
+func (s *Store) AddImageToImageUpload(composeID uuid.UUID, imageBuildID int, reader io.Reader) error {
+	currentCompose, exists := s.Composes[composeID]
+	if !exists {
+		return &NotFoundError{"compose does not exist"}
+	}
+
+	imageBuild := currentCompose.ImageBuilds[imageBuildID]
+	if !imageBuild.HasLocalTarget() {
+		return &NoLocalTargetError{fmt.Sprintf("image upload requested for compse %s and image build %d but it has no local target", composeID.String(), imageBuildID)}
+	}
+
+	imageType, _ := imageBuild.ImageType.ToCompatString()
+	filename, _, err := s.distro.FilenameFromType(imageType)
+
+	if err != nil {
+		return &InvalidRequestError{err.Error()}
+	}
+
+	path := fmt.Sprintf("%s/%s", s.getImageBuildDirectory(composeID, imageBuildID), filename)
+	f, err := os.Create(path)
+
+	if err != nil {
+		return err
+	}
+
+	_, err = io.Copy(f, reader)
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
 func (s *Store) PushSource(source SourceConfig) {
 	// FIXME: handle or comment this possible error
 	_ = s.change(func() error {
diff --git a/internal/target/local_target.go b/internal/target/local_target.go
index 35209d524..eb2fa9dbc 100644
--- a/internal/target/local_target.go
+++ b/internal/target/local_target.go
@@ -1,7 +1,6 @@
 package target
 
 type LocalTargetOptions struct {
-	Location string `json:"location"`
 }
 
 func (LocalTargetOptions) isTargetOptions() {}
diff --git a/internal/upload/awsupload/awsupload.go b/internal/upload/awsupload/awsupload.go
index 429ca886b..255307adf 100644
--- a/internal/upload/awsupload/awsupload.go
+++ b/internal/upload/awsupload/awsupload.go
@@ -132,6 +132,9 @@ func (a *AWS) Register(name, bucket, key string) (*string, error) {
 			},
 		},
 	)
+	if err != nil {
+		return nil, err
+	}
 
 	snapshotId := importOutput.ImportSnapshotTasks[0].SnapshotTaskDetail.SnapshotId
 
diff --git a/internal/weldr/api.go b/internal/weldr/api.go
index c7c8d9f84..cd1b09179 100644
--- a/internal/weldr/api.go
+++ b/internal/weldr/api.go
@@ -11,8 +11,6 @@ import (
 	"net"
 	"net/http"
 	"net/url"
-	"os"
-	"path/filepath"
 	"sort"
 	"strconv"
 	"strings"
@@ -617,7 +615,7 @@ func (api *API) modulesInfoHandler(writer http.ResponseWriter, request *http.Req
 
 	if modulesRequested {
 		for i, _ := range packageInfos {
-			err := packageInfos[i].FillDependencies(api.rpmmd, api.distro.Repositories(api.arch))
+			err := packageInfos[i].FillDependencies(api.rpmmd, api.distro.Repositories(api.arch), api.distro.ModulePlatformID())
 			if err != nil {
 				errors := responseError{
 					ID:  errorId,
@@ -647,7 +645,7 @@ func (api *API) projectsDepsolveHandler(writer http.ResponseWriter, request *htt
 
 	names := strings.Split(params.ByName("projects"), ",")
 
-	packages, _, err := api.rpmmd.Depsolve(names, api.distro.Repositories(api.arch), false)
+	packages, _, err := api.rpmmd.Depsolve(names, nil, api.distro.Repositories(api.arch), api.distro.ModulePlatformID(), false)
 
 	if err != nil {
 		errors := responseError{
@@ -829,7 +827,7 @@ func (api *API) blueprintsDepsolveHandler(writer http.ResponseWriter, request *h
 			return
 		}
 
-		dependencies, _, err := api.depsolveBlueprint(blueprint, false)
+		dependencies, _, _, err := api.depsolveBlueprint(blueprint, "", api.arch, false)
 
 		if err != nil {
 			errors := responseError{
@@ -891,7 +889,7 @@ func (api *API) blueprintsFreezeHandler(writer http.ResponseWriter, request *htt
 			break
 		}
 
-		dependencies, _, err := api.depsolveBlueprint(blueprint, false)
+		dependencies, _, _, err := api.depsolveBlueprint(blueprint, "", api.arch, false)
 		if err != nil {
 			rerr := responseError{
 				ID:  "BlueprintsError",
@@ -1265,7 +1263,7 @@ func (api *API) composeHandler(writer http.ResponseWriter, request *http.Request
 	}
 
 	if bp != nil {
-		_, checksums, err := api.depsolveBlueprint(bp, true)
+		packages, buildPackages, checksums, err := api.depsolveBlueprint(bp, cr.ComposeType, api.arch, true)
 		if err != nil {
 			errors := responseError{
 				ID:  "DepsolveError",
@@ -1275,7 +1273,7 @@ func (api *API) composeHandler(writer http.ResponseWriter, request *http.Request
 			return
 		}
 
-		err = api.store.PushCompose(reply.BuildID, bp, checksums, api.arch, cr.ComposeType, size, uploadTarget)
+		err = api.store.PushCompose(reply.BuildID, bp, packages, buildPackages, checksums, api.arch, cr.ComposeType, size, uploadTarget)
 
 		// TODO: we should probably do some kind of blueprint validation in future
 		// for now, let's just 500 and bail out
@@ -1522,9 +1520,7 @@ func (api *API) composeInfoHandler(writer http.ResponseWriter, request *http.Req
 	// 1st build is considered
 	reply.ComposeType, _ = compose.ImageBuilds[0].ImageType.ToCompatString()
 	reply.QueueStatus = compose.GetState().ToString()
-	if compose.ImageBuilds[0].Image != nil {
-		reply.ImageSize = compose.ImageBuilds[0].Size
-	}
+	reply.ImageSize = compose.ImageBuilds[0].Size
 
 	if isRequestVersionAtLeast(params, 1) {
 		reply.Uploads = TargetsToUploadResponses(compose.ImageBuilds[0].Targets)
@@ -1568,18 +1564,22 @@ func (api *API) composeImageHandler(writer http.ResponseWriter, request *http.Re
 		return
 	}
 
-	if compose.ImageBuilds[0].Image == nil {
+	imageBuild := compose.ImageBuilds[0]
+	imageType, _ := imageBuild.ImageType.ToCompatString()
+	imageName, imageMime, err := api.distro.FilenameFromType(imageType)
+
+	if err != nil {
 		errors := responseError{
-			ID:  "BuildMissingFile",
-			Msg: fmt.Sprintf("Compose %s doesn't have an image assigned", uuidString),
+			ID:  "BadCompose",
+			Msg: fmt.Sprintf("Compose %s is ill-formed: output type %v is invalid for distro %s", uuidString, imageBuild.ImageType, api.distro.Name()),
 		}
-		statusResponseError(writer, http.StatusBadRequest, errors)
+		statusResponseError(writer, http.StatusInternalServerError, errors)
 		return
 	}
 
-	imageName := filepath.Base(compose.ImageBuilds[0].Image.Path)
+	reader, fileSize, err := api.store.GetImageBuildImage(uuid, 0)
 
-	file, err := os.Open(compose.ImageBuilds[0].Image.Path)
+	// TODO: this might return misleading error
 	if err != nil {
 		errors := responseError{
 			ID:  "BuildMissingFile",
@@ -1590,10 +1590,10 @@ func (api *API) composeImageHandler(writer http.ResponseWriter, request *http.Re
 	}
 
 	writer.Header().Set("Content-Disposition", "attachment; filename="+uuid.String()+"-"+imageName)
-	writer.Header().Set("Content-Type", compose.ImageBuilds[0].Image.Mime)
-	writer.Header().Set("Content-Length", fmt.Sprintf("%d", compose.ImageBuilds[0].Image.Size))
+	writer.Header().Set("Content-Type", imageMime)
+	writer.Header().Set("Content-Length", fmt.Sprintf("%d", fileSize))
 
-	io.Copy(writer, file)
+	io.Copy(writer, reader)
 }
 
 func (api *API) composeLogsHandler(writer http.ResponseWriter, request *http.Request, params httprouter.Params) {
@@ -1631,7 +1631,7 @@ func (api *API) composeLogsHandler(writer http.ResponseWriter, request *http.Req
 		return
 	}
 
-	resultReader, err := api.store.GetComposeResult(id)
+	resultReader, err := api.store.GetImageBuildResult(id, 0)
 
 	if err != nil {
 		errors := responseError{
@@ -1716,7 +1716,7 @@ func (api *API) composeLogHandler(writer http.ResponseWriter, request *http.Requ
 		return
 	}
 
-	resultReader, err := api.store.GetComposeResult(id)
+	resultReader, err := api.store.GetImageBuildResult(id, 0)
 
 	if err != nil {
 		errors := responseError{
@@ -1792,11 +1792,18 @@ func (api *API) fetchPackageList() (rpmmd.PackageList, error) {
 		repos = append(repos, source.RepoConfig())
 	}
 
-	packages, _, err := api.rpmmd.FetchPackageList(repos)
+	packages, _, err := api.rpmmd.FetchPackageList(repos, api.distro.ModulePlatformID())
 	return packages, err
 }
 
-func (api *API) depsolveBlueprint(bp *blueprint.Blueprint, clean bool) ([]rpmmd.PackageSpec, map[string]string, error) {
+func (api *API) depsolveBlueprint(bp *blueprint.Blueprint, outputType, arch string, clean bool) ([]rpmmd.PackageSpec, []rpmmd.PackageSpec, map[string]string, error) {
+	var repos []rpmmd.RepoConfig
+	for _, repo := range api.distro.Repositories(api.arch) {
+		repos = append(repos, repo)
+	}
+	for _, source := range api.store.GetAllSources() {
+		repos = append(repos, source.RepoConfig())
+	}
 	specs := make([]string, len(bp.Packages))
 	for i, pkg := range bp.Packages {
 		specs[i] = pkg.Name
@@ -1808,16 +1815,33 @@ func (api *API) depsolveBlueprint(bp *blueprint.Blueprint, clean bool) ([]rpmmd.
 			specs[i] += "-*-*.*"
 		}
 	}
+	excludeSpecs := []string{}
+	if outputType != "" {
+		// When the output type is known, include the base packages in the depsolve
+		// transaction.
+		packages, excludePackages, err := api.distro.BasePackages(outputType, arch)
+		if err != nil {
+			return nil, nil, nil, err
+		}
+		specs = append(specs, packages...)
+		excludeSpecs = append(excludePackages, excludeSpecs...)
+	}
 
-	var repos []rpmmd.RepoConfig
-	for _, repo := range api.distro.Repositories(api.arch) {
-		repos = append(repos, repo)
+	packages, checksums, err := api.rpmmd.Depsolve(specs, excludeSpecs, repos, api.distro.ModulePlatformID(), clean)
+	if err != nil {
+		return nil, nil, nil, err
 	}
-	for _, source := range api.store.GetAllSources() {
-		repos = append(repos, source.RepoConfig())
+
+	buildPackages := []rpmmd.PackageSpec{}
+	if outputType != "" {
+		buildSpecs, err := api.distro.BuildPackages(arch)
+		if err != nil {
+			return nil, nil, nil, err
+		}
+		buildPackages, _, err = api.rpmmd.Depsolve(buildSpecs, nil, repos, api.distro.ModulePlatformID(), false)
 	}
 
-	return api.rpmmd.Depsolve(specs, repos, clean)
+	return packages, buildPackages, checksums, err
 }
 
 func (api *API) uploadsScheduleHandler(writer http.ResponseWriter, request *http.Request, params httprouter.Params) {
diff --git a/internal/weldr/compose.go b/internal/weldr/compose.go
index 404b846d4..1ca058a4e 100644
--- a/internal/weldr/compose.go
+++ b/internal/weldr/compose.go
@@ -4,7 +4,6 @@ import (
 	"github.com/google/uuid"
 	"github.com/osbuild/osbuild-composer/internal/common"
 	"github.com/osbuild/osbuild-composer/internal/compose"
-	"log"
 	"sort"
 )
 
@@ -13,7 +12,7 @@ type ComposeEntry struct {
 	Blueprint   string                 `json:"blueprint"`
 	Version     string                 `json:"version"`
 	ComposeType common.ImageType       `json:"compose_type"`
-	ImageSize   uint64                 `json:"image_size"`
+	ImageSize   uint64                 `json:"image_size"` // This is user-provided image size, not actual file size
 	QueueStatus common.ImageBuildState `json:"queue_status"`
 	JobCreated  float64                `json:"job_created"`
 	JobStarted  float64                `json:"job_started,omitempty"`
@@ -43,13 +42,7 @@ func composeToComposeEntry(id uuid.UUID, compose compose.Compose, includeUploads
 		composeEntry.JobStarted = float64(compose.ImageBuilds[0].JobStarted.UnixNano()) / 1000000000
 
 	case common.IBFinished:
-		if compose.ImageBuilds[0].Image != nil {
-			composeEntry.ImageSize = compose.ImageBuilds[0].Size
-		} else {
-			log.Printf("finished compose with id %s has nil image\n", id.String())
-			composeEntry.ImageSize = 0
-		}
-
+		composeEntry.ImageSize = compose.ImageBuilds[0].Size
 		composeEntry.JobCreated = float64(compose.ImageBuilds[0].JobCreated.UnixNano()) / 1000000000
 		composeEntry.JobStarted = float64(compose.ImageBuilds[0].JobStarted.UnixNano()) / 1000000000
 		composeEntry.JobFinished = float64(compose.ImageBuilds[0].JobFinished.UnixNano()) / 1000000000


Test patch:
diff --git a/internal/distro/distro_test.go b/internal/distro/distro_test.go
index ba4f578fc..26ae738a0 100644
--- a/internal/distro/distro_test.go
+++ b/internal/distro/distro_test.go
@@ -8,7 +8,7 @@ import (
 
 	"github.com/osbuild/osbuild-composer/internal/blueprint"
 	"github.com/osbuild/osbuild-composer/internal/distro"
-	"github.com/osbuild/osbuild-composer/internal/pipeline"
+	"github.com/osbuild/osbuild-composer/internal/osbuild"
 )
 
 func TestDistro_Pipeline(t *testing.T) {
@@ -26,8 +26,8 @@ func TestDistro_Pipeline(t *testing.T) {
 			Blueprint    *blueprint.Blueprint `json:"blueprint"`
 		}
 		var tt struct {
-			Compose  *compose           `json:"compose"`
-			Pipeline *pipeline.Pipeline `json:"pipeline,omitempty"`
+			Compose  *compose          `json:"compose"`
+			Pipeline *osbuild.Pipeline `json:"pipeline,omitempty"`
 		}
 		file, err := ioutil.ReadFile(pipelinePath + fileInfo.Name())
 		if err != nil {
@@ -49,7 +49,7 @@ func TestDistro_Pipeline(t *testing.T) {
 				return
 			}
 			size := d.GetSizeForOutputType(tt.Compose.OutputFormat, 0)
-			got, err := d.Pipeline(tt.Compose.Blueprint, nil, tt.Compose.Checksums, tt.Compose.Arch, tt.Compose.OutputFormat, size)
+			got, err := d.Pipeline(tt.Compose.Blueprint, nil, nil, nil, tt.Compose.Checksums, tt.Compose.Arch, tt.Compose.OutputFormat, size)
 			if (err != nil) != (tt.Pipeline == nil) {
 				t.Errorf("distro.Pipeline() error = %v", err)
 				return
diff --git a/internal/distro/fedoratest/distro.go b/internal/distro/fedoratest/distro.go
index 43e3a01f9..7ab90d28a 100644
--- a/internal/distro/fedoratest/distro.go
+++ b/internal/distro/fedoratest/distro.go
@@ -2,14 +2,17 @@ package fedoratest
 
 import (
 	"errors"
+
 	"github.com/osbuild/osbuild-composer/internal/blueprint"
 	"github.com/osbuild/osbuild-composer/internal/common"
-	"github.com/osbuild/osbuild-composer/internal/pipeline"
+	"github.com/osbuild/osbuild-composer/internal/osbuild"
 	"github.com/osbuild/osbuild-composer/internal/rpmmd"
 )
 
 type FedoraTestDistro struct{}
 
+const ModulePlatformID = "platform:f30"
+
 func New() *FedoraTestDistro {
 	return &FedoraTestDistro{}
 }
@@ -22,6 +25,10 @@ func (d *FedoraTestDistro) Distribution() common.Distribution {
 	return common.Fedora30
 }
 
+func (d *FedoraTestDistro) ModulePlatformID() string {
+	return ModulePlatformID
+}
+
 func (d *FedoraTestDistro) Repositories(arch string) []rpmmd.RepoConfig {
 	return []rpmmd.RepoConfig{
 		{
@@ -48,15 +55,26 @@ func (r *FedoraTestDistro) GetSizeForOutputType(outputFormat string, size uint64
 	return 0
 }
 
-func (d *FedoraTestDistro) Pipeline(b *blueprint.Blueprint, additionalRepos []rpmmd.RepoConfig, checksums map[string]string, outputArch, outputFormat string, size uint64) (*pipeline.Pipeline, error) {
+func (d *FedoraTestDistro) BasePackages(outputFormat string, outputArchitecture string) ([]string, []string, error) {
+	return nil, nil, nil
+}
+
+func (d *FedoraTestDistro) BuildPackages(outputArchitecture string) ([]string, error) {
+	return nil, nil
+}
+
+func (d *FedoraTestDistro) Pipeline(b *blueprint.Blueprint, additionalRepos []rpmmd.RepoConfig, buildPackages, basePackages []rpmmd.PackageSpec, checksums map[string]string, outputArch, outputFormat string, size uint64) (*osbuild.Pipeline, error) {
 	if outputFormat == "qcow2" && outputArch == "x86_64" {
-		return &pipeline.Pipeline{}, nil
+		return &osbuild.Pipeline{}, nil
 	} else {
 		return nil, errors.New("invalid output format or arch: " + outputFormat + " @ " + outputArch)
 	}
 }
 
+func (r *FedoraTestDistro) Sources(packages []rpmmd.PackageSpec) *osbuild.Sources {
+	return &osbuild.Sources{}
+}
+
 func (d *FedoraTestDistro) Runner() string {
 	return "org.osbuild.test"
 }
-
diff --git a/internal/distro/test/distro.go b/internal/distro/test/distro.go
new file mode 100644
index 000000000..92fda8bde
--- /dev/null
+++ b/internal/distro/test/distro.go
@@ -0,0 +1,72 @@
+package test
+
+import (
+	"errors"
+
+	"github.com/osbuild/osbuild-composer/internal/blueprint"
+	"github.com/osbuild/osbuild-composer/internal/osbuild"
+	"github.com/osbuild/osbuild-composer/internal/rpmmd"
+)
+
+type TestDistro struct{}
+
+const Name = "test-distro"
+const ModulePlatformID = "platform:test"
+
+func New() *TestDistro {
+	return &TestDistro{}
+}
+
+func (d *TestDistro) Name() string {
+	return Name
+}
+
+func (d *TestDistro) ModulePlatformID() string {
+	return ModulePlatformID
+}
+
+func (d *TestDistro) Repositories(arch string) []rpmmd.RepoConfig {
+	return []rpmmd.RepoConfig{
+		{
+			Id:      "test-id",
+			Name:    "Test Name",
+			BaseURL: "http://example.com/test/os/" + arch,
+		},
+	}
+}
+
+func (d *TestDistro) ListOutputFormats() []string {
+	return []string{"test_format"}
+}
+
+func (d *TestDistro) FilenameFromType(outputFormat string) (string, string, error) {
+	if outputFormat == "test_format" {
+		return "test.img", "application/x-test", nil
+	}
+
+	return "", "", errors.New("invalid output format: " + outputFormat)
+}
+
+func (d *TestDistro) BasePackages(outputFormat, outputArchitecture string) ([]string, []string, error) {
+	return nil, nil, nil
+}
+
+func (d *TestDistro) BuildPackages(outputArchitecture string) ([]string, error) {
+	return nil, nil
+}
+
+func (d *TestDistro) Pipeline(b *blueprint.Blueprint, additionalRepos []rpmmd.RepoConfig, packageSpecs, buildPackageSpecs []rpmmd.PackageSpec, checksums map[string]string, outputArch, outputFormat string, size uint64) (*osbuild.Pipeline, error) {
+	if outputFormat == "test_output" && outputArch == "test_arch" {
+		return &osbuild.Pipeline{}, nil
+	}
+
+	return nil, errors.New("invalid output format or arch: " + outputFormat + " @ " + outputArch)
+}
+
+func (d *TestDistro) Sources(packages []rpmmd.PackageSpec) *osbuild.Sources {
+	return &osbuild.Sources{}
+}
+
+func (d *TestDistro) Runner() string {
+	return "org.osbuild.test"
+}
diff --git a/internal/jobqueue/api_test.go b/internal/jobqueue/api_test.go
index 5e11fefe5..8fc3ed205 100644
--- a/internal/jobqueue/api_test.go
+++ b/internal/jobqueue/api_test.go
@@ -16,20 +16,20 @@ import (
 
 func TestBasic(t *testing.T) {
 	var cases = []struct {
-		Method         string
-		Path           string
-		Body           string
-		ExpectedStatus int
-		ExpectedJSON   string
+		Method           string
+		Path             string
+		Body             string
+		ExpectedStatus   int
+		ExpectedResponse string
 	}{
 		// Create job with invalid body
-		{"POST", "/job-queue/v1/jobs", ``, http.StatusBadRequest, ``},
+		{"POST", "/job-queue/v1/jobs", ``, http.StatusBadRequest, `invalid request: EOF`},
 		// Update job with invalid ID
-		{"PATCH", "/job-queue/v1/jobs/foo", `{"status":"RUNNING"}`, http.StatusBadRequest, ``},
+		{"PATCH", "/job-queue/v1/jobs/foo/builds/0", `{"status":"RUNNING"}`, http.StatusBadRequest, `invalid compose id: invalid UUID length: 3`},
 		// Update job that does not exist, with invalid body
-		{"PATCH", "/job-queue/v1/jobs/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", ``, http.StatusBadRequest, ``},
+		{"PATCH", "/job-queue/v1/jobs/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/builds/0", ``, http.StatusBadRequest, `invalid status: EOF`},
 		// Update job that does not exist
-		{"PATCH", "/job-queue/v1/jobs/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", `{"image_build_id": 0, "status":"RUNNING"}`, http.StatusNotFound, ``},
+		{"PATCH", "/job-queue/v1/jobs/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/builds/0", `{"status":"RUNNING"}`, http.StatusNotFound, `compose does not exist`},
 	}
 
 	for _, c := range cases {
@@ -37,7 +37,7 @@ func TestBasic(t *testing.T) {
 		registry := distro.NewRegistry([]string{"."})
 		api := jobqueue.New(nil, store.New(nil, distroStruct, *registry))
 
-		test.TestRoute(t, api, false, c.Method, c.Path, c.Body, c.ExpectedStatus, c.ExpectedJSON)
+		test.TestNonJsonRoute(t, api, false, c.Method, c.Path, c.Body, c.ExpectedStatus, c.ExpectedResponse)
 	}
 }
 
@@ -48,7 +48,7 @@ func TestCreate(t *testing.T) {
 	store := store.New(nil, distroStruct, *registry)
 	api := jobqueue.New(nil, store)
 
-	err := store.PushCompose(id, &blueprint.Blueprint{}, map[string]string{"test-repo": "test:foo"}, "x86_64", "qcow2", 0, nil)
+	err := store.PushCompose(id, &blueprint.Blueprint{}, nil, nil, map[string]string{"test-repo": "test:foo"}, "x86_64", "qcow2", 0, nil)
 	if err != nil {
 		t.Fatalf("error pushing compose: %v", err)
 	}
@@ -57,7 +57,7 @@ func TestCreate(t *testing.T) {
 		`{"distro":"fedora-30","id":"ffffffff-ffff-ffff-ffff-ffffffffffff","image_build_id":0,"output_type":"qcow2","pipeline":{},"targets":[]}`, "created", "uuid")
 }
 
-func testUpdateTransition(t *testing.T, from, to string, expectedStatus int) {
+func testUpdateTransition(t *testing.T, from, to string, expectedStatus int, expectedResponse string) {
 	id, _ := uuid.Parse("ffffffff-ffff-ffff-ffff-ffffffffffff")
 	distroStruct := test_distro.New()
 	registry := distro.NewRegistry([]string{"."})
@@ -65,50 +65,51 @@ func testUpdateTransition(t *testing.T, from, to string, expectedStatus int) {
 	api := jobqueue.New(nil, store)
 
 	if from != "VOID" {
-		err := store.PushCompose(id, &blueprint.Blueprint{}, map[string]string{"test": "test:foo"}, "x86_64", "qcow2", 0, nil)
+		err := store.PushCompose(id, &blueprint.Blueprint{}, nil, nil, map[string]string{"test": "test:foo"}, "x86_64", "qcow2", 0, nil)
 		if err != nil {
 			t.Fatalf("error pushing compose: %v", err)
 		}
 		if from != "WAITING" {
 			test.SendHTTP(api, false, "POST", "/job-queue/v1/jobs", `{}`)
 			if from != "RUNNING" {
-				test.SendHTTP(api, false, "PATCH", "/job-queue/v1/jobs/ffffffff-ffff-ffff-ffff-ffffffffffff", `{"status":"`+from+`"}`)
+				test.SendHTTP(api, false, "PATCH", "/job-queue/v1/jobs/ffffffff-ffff-ffff-ffff-ffffffffffff/builds/0", `{"status":"`+from+`"}`)
 			}
 		}
 	}
 
-	test.TestRoute(t, api, false, "PATCH", "/job-queue/v1/jobs/ffffffff-ffff-ffff-ffff-ffffffffffff", `{"status":"`+to+`"}`, expectedStatus, ``)
+	test.TestNonJsonRoute(t, api, false, "PATCH", "/job-queue/v1/jobs/ffffffff-ffff-ffff-ffff-ffffffffffff/builds/0", `{"status":"`+to+`"}`, expectedStatus, expectedResponse)
 }
 
 func TestUpdate(t *testing.T) {
 	var cases = []struct {
-		From           string
-		To             string
-		ExpectedStatus int
+		From             string
+		To               string
+		ExpectedStatus   int
+		ExpectedResponse string
 	}{
-		{"VOID", "WAITING", http.StatusNotFound},
-		{"VOID", "RUNNING", http.StatusNotFound},
-		{"VOID", "FINISHED", http.StatusNotFound},
-		{"VOID", "FAILED", http.StatusNotFound},
-		{"WAITING", "WAITING", http.StatusNotFound},
-		{"WAITING", "RUNNING", http.StatusNotFound},
-		{"WAITING", "FINISHED", http.StatusNotFound},
-		{"WAITING", "FAILED", http.StatusNotFound},
-		{"RUNNING", "WAITING", http.StatusBadRequest},
-		{"RUNNING", "RUNNING", http.StatusOK},
-		{"RUNNING", "FINISHED", http.StatusOK},
-		{"RUNNING", "FAILED", http.StatusOK},
-		{"FINISHED", "WAITING", http.StatusBadRequest},
-		{"FINISHED", "RUNNING", http.StatusBadRequest},
-		{"FINISHED", "FINISHED", http.StatusBadRequest},
-		{"FINISHED", "FAILED", http.StatusBadRequest},
-		{"FAILED", "WAITING", http.StatusBadRequest},
-		{"FAILED", "RUNNING", http.StatusBadRequest},
-		{"FAILED", "FINISHED", http.StatusBadRequest},
-		{"FAILED", "FAILED", http.StatusBadRequest},
+		{"VOID", "WAITING", http.StatusNotFound, "compose does not exist"},
+		{"VOID", "RUNNING", http.StatusNotFound, "compose does not exist"},
+		{"VOID", "FINISHED", http.StatusNotFound, "compose does not exist"},
+		{"VOID", "FAILED", http.StatusNotFound, "compose does not exist"},
+		{"WAITING", "WAITING", http.StatusNotFound, "compose has not been popped"},
+		{"WAITING", "RUNNING", http.StatusNotFound, "compose has not been popped"},
+		{"WAITING", "FINISHED", http.StatusNotFound, "compose has not been popped"},
+		{"WAITING", "FAILED", http.StatusNotFound, "compose has not been popped"},
+		{"RUNNING", "WAITING", http.StatusBadRequest, "invalid state transition: image build cannot be moved into waiting state"},
+		{"RUNNING", "RUNNING", http.StatusOK, ""},
+		{"RUNNING", "FINISHED", http.StatusOK, ""},
+		{"RUNNING", "FAILED", http.StatusOK, ""},
+		{"FINISHED", "WAITING", http.StatusBadRequest, "invalid state transition: image build cannot be moved into waiting state"},
+		{"FINISHED", "RUNNING", http.StatusBadRequest, "invalid state transition: only waiting image build can be transitioned into running state"},
+		{"FINISHED", "FINISHED", http.StatusBadRequest, "invalid state transition: only running image build can be transitioned into finished or failed state"},
+		{"FINISHED", "FAILED", http.StatusBadRequest, "invalid state transition: only running image build can be transitioned into finished or failed state"},
+		{"FAILED", "WAITING", http.StatusBadRequest, "invalid state transition: image build cannot be moved into waiting state"},
+		{"FAILED", "RUNNING", http.StatusBadRequest, "invalid state transition: only waiting image build can be transitioned into running state"},
+		{"FAILED", "FINISHED", http.StatusBadRequest, "invalid state transition: only running image build can be transitioned into finished or failed state"},
+		{"FAILED", "FAILED", http.StatusBadRequest, "invalid state transition: only running image build can be transitioned into finished or failed state"},
 	}
 
 	for _, c := range cases {
-		testUpdateTransition(t, c.From, c.To, c.ExpectedStatus)
+		testUpdateTransition(t, c.From, c.To, c.ExpectedStatus, c.ExpectedResponse)
 	}
 }
diff --git a/internal/osbuild/source_test.go b/internal/osbuild/source_test.go
new file mode 100644
index 000000000..f24b434cb
--- /dev/null
+++ b/internal/osbuild/source_test.go
@@ -0,0 +1,97 @@
+package osbuild
+
+import (
+	"bytes"
+	"encoding/json"
+	"reflect"
+	"testing"
+)
+
+func TestSource_UnmarshalJSON(t *testing.T) {
+	type fields struct {
+		Name   string
+		Source Source
+	}
+	type args struct {
+		data []byte
+	}
+	tests := []struct {
+		name    string
+		fields  fields
+		args    args
+		wantErr bool
+	}{
+		{
+			name: "invalid json",
+			args: args{
+				data: []byte(`{"name":"org.osbuild.foo","options":{"bar":null}`),
+			},
+			wantErr: true,
+		},
+		{
+			name: "unknown source",
+			args: args{
+				data: []byte(`{"name":"org.osbuild.foo","options":{"bar":null}}`),
+			},
+			wantErr: true,
+		},
+		{
+			name: "missing options",
+			args: args{
+				data: []byte(`{"name":"org.osbuild.files"}`),
+			},
+			wantErr: true,
+		},
+		{
+			name: "missing name",
+			args: args{
+				data: []byte(`{"foo":null,"options":{"bar":null}}`),
+			},
+			wantErr: true,
+		},
+		{
+			name: "files-empty",
+			fields: fields{
+				Name:   "org.osbuild.files",
+				Source: &FilesSource{URLs: map[string]string{}},
+			},
+			args: args{
+				data: []byte(`{"org.osbuild.files":{"urls":{}}}`),
+			},
+		},
+		{
+			name: "files",
+			fields: fields{
+				Name:   "org.osbuild.files",
+				Source: &FilesSource{URLs: map[string]string{"checksum1": "url1", "checksum2": "url2"}},
+			},
+			args: args{
+				data: []byte(`{"org.osbuild.files":{"urls":{"checksum1":"url1","checksum2":"url2"}}}`),
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			sources := &Sources{
+				tt.fields.Name: tt.fields.Source,
+			}
+			var gotSources Sources
+			if err := gotSources.UnmarshalJSON(tt.args.data); (err != nil) != tt.wantErr {
+				t.Errorf("Sources.UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr)
+			}
+			if tt.wantErr {
+				return
+			}
+			gotBytes, err := json.Marshal(sources)
+			if err != nil {
+				t.Errorf("Could not marshal source: %v", err)
+			}
+			if bytes.Compare(gotBytes, tt.args.data) != 0 {
+				t.Errorf("Expected '%v', got '%v'", string(tt.args.data), string(gotBytes))
+			}
+			if !reflect.DeepEqual(&gotSources, sources) {
+				t.Errorf("got '%v', expected '%v'", &gotSources, sources)
+			}
+		})
+	}
+}
diff --git a/internal/pipeline/stage_test.go b/internal/osbuild/stage_test.go
similarity index 88%
rename from internal/pipeline/stage_test.go
rename to internal/osbuild/stage_test.go
index 33423dd0b..e8be19c3d 100644
--- a/internal/pipeline/stage_test.go
+++ b/internal/osbuild/stage_test.go
@@ -1,4 +1,4 @@
-package pipeline
+package osbuild
 
 import (
 	"bytes"
@@ -10,7 +10,7 @@ import (
 )
 
 func TestStage_UnmarshalJSON(t *testing.T) {
-	null_uuid := uuid.MustParse("00000000-0000-0000-0000-000000000000")
+	nullUUID := uuid.MustParse("00000000-0000-0000-0000-000000000000")
 	type fields struct {
 		Name    string
 		Options StageOptions
@@ -117,7 +117,7 @@ func TestStage_UnmarshalJSON(t *testing.T) {
 			fields: fields{
 				Name: "org.osbuild.grub2",
 				Options: &GRUB2StageOptions{
-					RootFilesystemUUID: null_uuid,
+					RootFilesystemUUID: nullUUID,
 				},
 			},
 			args: args{
@@ -129,7 +129,7 @@ func TestStage_UnmarshalJSON(t *testing.T) {
 			fields: fields{
 				Name: "org.osbuild.grub2",
 				Options: &GRUB2StageOptions{
-					RootFilesystemUUID: null_uuid,
+					RootFilesystemUUID: nullUUID,
 					UEFI: &GRUB2UEFI{
 						Vendor: "vendor",
 					},
@@ -144,8 +144,8 @@ func TestStage_UnmarshalJSON(t *testing.T) {
 			fields: fields{
 				Name: "org.osbuild.grub2",
 				Options: &GRUB2StageOptions{
-					RootFilesystemUUID: null_uuid,
-					BootFilesystemUUID: &null_uuid,
+					RootFilesystemUUID: nullUUID,
+					BootFilesystemUUID: &nullUUID,
 				},
 			},
 			args: args{
@@ -182,6 +182,29 @@ func TestStage_UnmarshalJSON(t *testing.T) {
 				data: []byte(`{"name":"org.osbuild.locale","options":{"language":""}}`),
 			},
 		},
+		{
+			name: "rpm-empty",
+			fields: fields{
+				Name:    "org.osbuild.rpm",
+				Options: &RPMStageOptions{},
+			},
+			args: args{
+				data: []byte(`{"name":"org.osbuild.rpm","options":{"packages":null}}`),
+			},
+		},
+		{
+			name: "rpm",
+			fields: fields{
+				Name: "org.osbuild.rpm",
+				Options: &RPMStageOptions{
+					GPGKeys:  []string{"key1", "key2"},
+					Packages: []string{"checksum1", "checksum2"},
+				},
+			},
+			args: args{
+				data: []byte(`{"name":"org.osbuild.rpm","options":{"gpgkeys":["key1","key2"],"packages":["checksum1","checksum2"]}}`),
+			},
+		},
 		{
 			name: "script",
 			fields: fields{
diff --git a/internal/upload/awsupload/awsupload_test.go b/internal/upload/awsupload/awsupload_test.go
index 111e9f5bb..548eed6e9 100644
--- a/internal/upload/awsupload/awsupload_test.go
+++ b/internal/upload/awsupload/awsupload_test.go
@@ -43,13 +43,17 @@ func generateRandomFile(pattern string, length int) (string, []byte, error) {
 	cmd := exec.Command("dd", "bs=1", countArg, "if=/dev/urandom", fmt.Sprintf("of=%s", fileName))
 	err = cmd.Run()
 
+	if err != nil {
+		return "", []byte{}, err
+	}
+
 	contents, err := ioutil.ReadAll(f)
 	if err != nil {
 		return "", []byte{}, err
 	}
 
 	if err := f.Close(); err != nil {
-		return ",", []byte{}, err
+		return "", []byte{}, err
 	}
 
 	return fileName, contents, err