Repository: osbuild/osbuild-composer
Pull request #223 (2020-02-04 22:42:17)

Enforce semantic versions in new blueprints

bcl: Fixed, tests pass with PR #246 added.


Patch:
diff --git a/.gitignore b/.gitignore
index 85d481647..caa4b8e08 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,7 +5,12 @@ __pycache__
 /osbuild-pipeline
 /osbuild-upload-azure
 /osbuild-upload-aws
+/osbuild-dnf-json-tests
 /osbuild-tests
 
 /*.tar.gz
 /*.src.rpm
+
+/build
+/rpmbuild
+/output
diff --git a/Makefile b/Makefile
index e88a46199..7d190ecb1 100644
--- a/Makefile
+++ b/Makefile
@@ -8,7 +8,8 @@ build:
 	go build -o osbuild-pipeline ./cmd/osbuild-pipeline/
 	go build -o osbuild-upload-azure ./cmd/osbuild-upload-azure/
 	go build -o osbuild-upload-aws ./cmd/osbuild-upload-aws/
-	go build -o osbuild-tests ./cmd/osbuild-tests
+	go build -o osbuild-tests ./cmd/osbuild-tests/
+	go build -o osbuild-dnf-json-tests ./cmd/osbuild-dnf-json-tests/
 
 .PHONY: install
 install:
@@ -26,15 +27,41 @@ install:
 	cp distribution/*.socket /etc/systemd/system/
 	systemctl daemon-reload
 
+.PHONY: ca
+ca:
+ifneq (/etc/osbuild-composer/ca-key.pem/etc/osbuild-composer/ca-crt.pem,$(wildcard /etc/osbuild-composer/ca-key.pem)$(wildcard /etc/osbuild-composer/ca-crt.pem))
+	@echo CA key or certificate file is missing, generating a new pair...
+	- mkdir -p /etc/osbuild-composer
+	openssl req -new -nodes -x509 -days 365 -keyout /etc/osbuild-composer/ca-key.pem -out /etc/osbuild-composer/ca-crt.pem -subj "/CN=osbuild.org"
+else
+	@echo CA key and certificate files already exist, skipping...
+endif
+
+.PHONY: composer-key-pair
+composer-key-pair: ca
+	openssl genrsa -out /etc/osbuild-composer/composer-key.pem 2048
+	openssl req -new -sha256 -key /etc/osbuild-composer/composer-key.pem	-out /etc/osbuild-composer/composer-csr.pem -subj "/CN=localhost" # TODO: we need to generate certificates with another hostname
+	openssl x509 -req -in /etc/osbuild-composer/composer-csr.pem  -CA /etc/osbuild-composer/ca-crt.pem -CAkey /etc/osbuild-composer/ca-key.pem -CAcreateserial -out /etc/osbuild-composer/composer-crt.pem
+	chown _osbuild-composer:_osbuild-composer /etc/osbuild-composer/composer-key.pem /etc/osbuild-composer/composer-csr.pem /etc/osbuild-composer/composer-crt.pem
+
+.PHONY: worker-key-pair
+worker-key-pair: ca
+	openssl genrsa -out /etc/osbuild-composer/worker-key.pem 2048
+	openssl req -new -sha256 -key /etc/osbuild-composer/worker-key.pem	-out /etc/osbuild-composer/worker-csr.pem -subj "/CN=localhost"
+	openssl x509 -req -in /etc/osbuild-composer/worker-csr.pem  -CA /etc/osbuild-composer/ca-crt.pem -CAkey /etc/osbuild-composer/ca-key.pem -CAcreateserial -out /etc/osbuild-composer/worker-crt.pem
+
+.PHONY: tarball
 tarball:
 	git archive --prefix=$(PACKAGE_NAME)-$(VERSION)/ --format=tar.gz HEAD > $(PACKAGE_NAME)-$(VERSION).tar.gz
 
+.PHONY: srpm
 srpm: golang-github-$(PACKAGE_NAME).spec check-working-directory tarball
 	/usr/bin/rpmbuild -bs \
 	  --define "_sourcedir $(CURDIR)" \
 	  --define "_srcrpmdir $(CURDIR)" \
 	  golang-github-$(PACKAGE_NAME).spec
 
+.PHONY: rpm
 rpm: golang-github-$(PACKAGE_NAME).spec check-working-directory tarball 
 	- rm -r "`pwd`/output"
 	mkdir -p "`pwd`/output"
@@ -50,6 +77,7 @@ rpm: golang-github-$(PACKAGE_NAME).spec check-working-directory tarball
 	rm -r "`pwd`/rpmbuild"
 	rm -r "`pwd`/build"
 
+.PHONY: check-working-directory
 check-working-directory:
 	@if [ "`git status --porcelain --untracked-files=no | wc -l`" != "0" ]; then \
 	  echo "Uncommited changes, refusing (Use git add . && git commit or git stash to clean your working directory)."; \
diff --git a/cmd/osbuild-composer/main.go b/cmd/osbuild-composer/main.go
index e8d7fb07e..abc5ee745 100644
--- a/cmd/osbuild-composer/main.go
+++ b/cmd/osbuild-composer/main.go
@@ -1,14 +1,17 @@
 package main
 
 import (
+	"crypto/tls"
+	"crypto/x509"
 	"flag"
+	"github.com/osbuild/osbuild-composer/internal/rcm"
+	"io/ioutil"
 	"log"
 	"os"
 	"runtime"
 
 	"github.com/osbuild/osbuild-composer/internal/distro"
 	"github.com/osbuild/osbuild-composer/internal/jobqueue"
-	"github.com/osbuild/osbuild-composer/internal/rcm"
 	"github.com/osbuild/osbuild-composer/internal/rpmmd"
 	"github.com/osbuild/osbuild-composer/internal/store"
 	"github.com/osbuild/osbuild-composer/internal/weldr"
@@ -30,6 +33,35 @@ func currentArch() string {
 	}
 }
 
+type connectionConfig struct {
+	CACertFile     string
+	ServerKeyFile  string
+	ServerCertFile string
+}
+
+func createTLSConfig(c *connectionConfig) (*tls.Config, error) {
+	caCertPEM, err := ioutil.ReadFile(c.CACertFile)
+	if err != nil {
+		return nil, err
+	}
+
+	roots := x509.NewCertPool()
+	ok := roots.AppendCertsFromPEM(caCertPEM)
+	if !ok {
+		panic("failed to parse root certificate")
+	}
+
+	cert, err := tls.LoadX509KeyPair(c.ServerCertFile, c.ServerKeyFile)
+	if err != nil {
+		return nil, err
+	}
+	return &tls.Config{
+		Certificates: []tls.Certificate{cert},
+		ClientAuth:   tls.RequireAndVerifyClientCert,
+		ClientCAs:    roots,
+	}, nil
+}
+
 func main() {
 	var verbose bool
 	flag.BoolVar(&verbose, "v", false, "Print access log")
@@ -37,17 +69,23 @@ func main() {
 
 	stateDir := "/var/lib/osbuild-composer"
 
-	listeners, err := activation.Listeners()
+	listeners, err := activation.ListenersWithNames()
 	if err != nil {
 		log.Fatalf("Could not get listening sockets: " + err.Error())
 	}
 
-	if len(listeners) != 2 && len(listeners) != 3 {
-		log.Fatalf("Unexpected number of listening sockets (%d), expected 2 or 3", len(listeners))
+	if _, exists := listeners["osbuild-composer.socket"]; !exists {
+		log.Fatalf("osbuild-composer.socket doesn't exist")
+	}
+
+	composerListeners := listeners["osbuild-composer.socket"]
+
+	if len(composerListeners) != 2 && len(composerListeners) != 3 {
+		log.Fatalf("Unexpected number of listening sockets (%d), expected 2 or 3", len(composerListeners))
 	}
 
-	weldrListener := listeners[0]
-	jobListener := listeners[1]
+	weldrListener := composerListeners[0]
+	jobListener := composerListeners[1]
 
 	rpm := rpmmd.NewRPMMD()
 	distros := distro.NewRegistry([]string{"/etc/osbuild-composer", "/usr/share/osbuild-composer"})
@@ -70,10 +108,38 @@ func main() {
 	go jobAPI.Serve(jobListener)
 
 	// Optionally run RCM API as well as Weldr API
-	if len(listeners) == 3 {
-		rcmListener := listeners[2]
-		rcmAPI := rcm.New(logger, store)
-		go rcmAPI.Serve(rcmListener)
+	if rcmApiListeners, exists := listeners["osbuild-rcm.socket"]; exists {
+		if len(rcmApiListeners) != 1 {
+			// Use Fatal to call os.Exit with non-zero return value
+			log.Fatal("The RCM API socket unit is misconfigured. It should contain only one socket.")
+		}
+		rcmListener := rcmApiListeners[0]
+		rcmAPI := rcm.New(logger, store, rpm)
+		go func() {
+			err := rcmAPI.Serve(rcmListener)
+			// If the RCM API fails, take down the whole process, not just a single gorutine
+			log.Fatal("RCM API failed: ", err)
+		}()
+
+	}
+
+	if remoteWorkerListeners, exists := listeners["osbuild-remote-worker.socket"]; exists {
+		for _, listener := range remoteWorkerListeners {
+			log.Printf("Starting remote listener\n")
+
+			tlsConfig, err := createTLSConfig(&connectionConfig{
+				CACertFile:     "/etc/osbuild-composer/ca-crt.pem",
+				ServerKeyFile:  "/etc/osbuild-composer/composer-key.pem",
+				ServerCertFile: "/etc/osbuild-composer/composer-crt.pem",
+			})
+
+			if err != nil {
+				log.Fatalf("TLS configuration cannot be created: " + err.Error())
+			}
+
+			listener := tls.NewListener(listener, tlsConfig)
+			go jobAPI.Serve(listener)
+		}
 	}
 
 	weldrAPI.Serve(weldrListener)
diff --git a/cmd/osbuild-worker/main.go b/cmd/osbuild-worker/main.go
index ebadd1514..3a61b86bb 100644
--- a/cmd/osbuild-worker/main.go
+++ b/cmd/osbuild-worker/main.go
@@ -3,10 +3,14 @@ package main
 import (
 	"bytes"
 	"context"
+	"crypto/tls"
+	"crypto/x509"
 	"encoding/json"
 	"errors"
+	"flag"
 	"fmt"
 	"io"
+	"io/ioutil"
 	"log"
 	"net"
 	"net/http"
@@ -15,19 +19,80 @@ import (
 	"github.com/osbuild/osbuild-composer/internal/jobqueue"
 )
 
+const RemoteWorkerPort = 8700
+
 type ComposerClient struct {
-	client *http.Client
+	client   *http.Client
+	hostname string
+}
+
+type connectionConfig struct {
+	CACertFile     string
+	ClientKeyFile  string
+	ClientCertFile string
+}
+
+func createTLSConfig(config *connectionConfig) (*tls.Config, error) {
+	caCertPEM, err := ioutil.ReadFile(config.CACertFile)
+	if err != nil {
+		return nil, err
+	}
+
+	roots := x509.NewCertPool()
+	ok := roots.AppendCertsFromPEM(caCertPEM)
+	if !ok {
+		return nil, errors.New("failed to append root certificate")
+	}
+
+	cert, err := tls.LoadX509KeyPair(config.ClientCertFile, config.ClientKeyFile)
+	if err != nil {
+		return nil, err
+	}
+
+	return &tls.Config{
+		RootCAs:      roots,
+		Certificates: []tls.Certificate{cert},
+	}, nil
 }
 
-func NewClient() *ComposerClient {
+func newConnection(remoteAddress string) (net.Conn, error) {
+	if remoteAddress != "" {
+		conf, err := createTLSConfig(&connectionConfig{
+			CACertFile:     "/etc/osbuild-composer/ca-crt.pem",
+			ClientKeyFile:  "/etc/osbuild-composer/worker-key.pem",
+			ClientCertFile: "/etc/osbuild-composer/worker-crt.pem",
+		})
+
+		if err != nil {
+			return nil, err
+		}
+
+		address := fmt.Sprintf("%s:%d", remoteAddress, RemoteWorkerPort)
+
+		return tls.Dial("tcp", address, conf)
+	}
+
+	// plain non-encrypted connection
+	return net.Dial("unix", "/run/osbuild-composer/job.socket")
+
+}
+
+func NewClient(remoteAddress string) *ComposerClient {
 	client := &http.Client{
 		Transport: &http.Transport{
 			DialContext: func(context context.Context, network, addr string) (net.Conn, error) {
-				return net.Dial("unix", "/run/osbuild-composer/job.socket")
+				return newConnection(remoteAddress)
 			},
 		},
 	}
-	return &ComposerClient{client}
+	hostname := remoteAddress
+
+	// in case of unix domain socket, use localhost as hostname
+	if hostname == "" {
+		hostname = "localhost"
+	}
+
+	return &ComposerClient{client, hostname}
 }
 
 func (c *ComposerClient) AddJob() (*jobqueue.Job, error) {
@@ -36,14 +101,16 @@ func (c *ComposerClient) AddJob() (*jobqueue.Job, error) {
 
 	var b bytes.Buffer
 	json.NewEncoder(&b).Encode(request{})
-	response, err := c.client.Post("http://localhost/job-queue/v1/jobs", "application/json", &b)
+	response, err := c.client.Post(c.createURL("/job-queue/v1/jobs"), "application/json", &b)
 	if err != nil {
 		return nil, err
 	}
 	defer response.Body.Close()
 
 	if response.StatusCode != http.StatusCreated {
-		return nil, errors.New("couldn't create job")
+		rawR, _ := ioutil.ReadAll(response.Body)
+		r := string(rawR)
+		return nil, errors.New(fmt.Sprintf("couldn't create job, got %d: %s", response.StatusCode, r))
 	}
 
 	job := &jobqueue.Job{}
@@ -58,7 +125,8 @@ func (c *ComposerClient) AddJob() (*jobqueue.Job, 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, result})
-	url := fmt.Sprintf("http://localhost/job-queue/v1/jobs/%s/builds/%d", job.ID.String(), job.ImageBuildID)
+	urlPath := fmt.Sprintf("/job-queue/v1/jobs/%s/builds/%d", job.ID.String(), job.ImageBuildID)
+	url := c.createURL(urlPath)
 	req, err := http.NewRequest("PATCH", url, &b)
 	if err != nil {
 		return err
@@ -86,6 +154,10 @@ func (c *ComposerClient) UploadImage(job *jobqueue.Job, reader io.Reader) error
 	return err
 }
 
+func (c *ComposerClient) createURL(path string) string {
+	return "http://" + c.hostname + path
+}
+
 func handleJob(client *ComposerClient) error {
 	fmt.Println("Waiting for a new job...")
 	job, err := client.AddJob()
@@ -109,7 +181,11 @@ func handleJob(client *ComposerClient) error {
 }
 
 func main() {
-	client := NewClient()
+	var remoteAddress string
+	flag.StringVar(&remoteAddress, "remote", "", "Connect to a remote composer using the specified address")
+	flag.Parse()
+
+	client := NewClient(remoteAddress)
 	for {
 		if err := handleJob(client); err != nil {
 			log.Fatalf("Failed to handle job: " + err.Error())
diff --git a/distribution/osbuild-composer.service b/distribution/osbuild-composer.service
index 7ed35f68f..bd24805ce 100644
--- a/distribution/osbuild-composer.service
+++ b/distribution/osbuild-composer.service
@@ -7,6 +7,7 @@ Wants=osbuild-worker@1.service
 [Service]
 Type=simple
 ExecStart=/usr/libexec/osbuild-composer/osbuild-composer
+CacheDirectory=osbuild-composer
 StateDirectory=osbuild-composer
 WorkingDirectory=/usr/libexec/osbuild-composer/
 User=_osbuild-composer
diff --git a/distribution/osbuild-rcm.socket b/distribution/osbuild-rcm.socket
new file mode 100644
index 000000000..8b8055a93
--- /dev/null
+++ b/distribution/osbuild-rcm.socket
@@ -0,0 +1,9 @@
+[Unit]
+Description=OSBuild Composer RCM API socket
+
+[Socket]
+Service=osbuild-composer.service
+ListenStream=80
+
+[Install]
+WantedBy=sockets.target
diff --git a/distribution/osbuild-remote-worker.socket b/distribution/osbuild-remote-worker.socket
new file mode 100644
index 000000000..6753ec3e9
--- /dev/null
+++ b/distribution/osbuild-remote-worker.socket
@@ -0,0 +1,9 @@
+[Unit]
+Description=OSBuild Composer API sockets
+
+[Socket]
+Service=osbuild-composer.service
+ListenStream=8700
+
+[Install]
+WantedBy=sockets.target
diff --git a/distribution/osbuild-remote-worker@.service b/distribution/osbuild-remote-worker@.service
new file mode 100644
index 000000000..0b29910dd
--- /dev/null
+++ b/distribution/osbuild-remote-worker@.service
@@ -0,0 +1,13 @@
+[Unit]
+Description=OSBuild Composer Remote Worker (%i)
+After=multi-user.target
+
+[Service]
+Type=simple
+PrivateTmp=true
+ExecStart=/usr/libexec/osbuild-composer/osbuild-worker --remote %i
+CacheDirectory=osbuild-composer
+Restart=on-failure
+RestartSec=10s
+CPUSchedulingPolicy=batch
+IOSchedulingClass=idle
diff --git a/distribution/osbuild-worker@.service b/distribution/osbuild-worker@.service
index c963c8f9f..da8bcc708 100644
--- a/distribution/osbuild-worker@.service
+++ b/distribution/osbuild-worker@.service
@@ -6,8 +6,7 @@ After=multi-user.target osbuild-composer.socket
 [Service]
 Type=simple
 PrivateTmp=true
-ExecStart=/usr/libexec/osbuild-composer/osbuild-worker -C /var/lib/osbuild
-CacheDirectory=osbuild-composer
+ExecStart=/usr/libexec/osbuild-composer/osbuild-worker
 Restart=on-failure
 RestartSec=10s
 CPUSchedulingPolicy=batch
diff --git a/dnf-json b/dnf-json
index 4731675c4..e8547a0f7 100644
--- a/dnf-json
+++ b/dnf-json
@@ -43,8 +43,7 @@ def create_base(repos, module_platform_id, persistdir, cachedir, clean=False):
     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
+    base.conf.cachedir = cachedir
 
     if clean:
         shutil.rmtree(base.conf.cachedir, ignore_errors=True)
@@ -90,7 +89,7 @@ command = call["command"]
 arguments = call["arguments"]
 repos = arguments.get("repos", {})
 clean = arguments.get("clean", False)
-cachedir = arguments.get("cachedir", None)
+cachedir = arguments["cachedir"]
 module_platform_id = arguments["module_platform_id"]
 
 with tempfile.TemporaryDirectory() as persistdir:
diff --git a/go.mod b/go.mod
index d5373f16f..5dcdb516e 100644
--- a/go.mod
+++ b/go.mod
@@ -7,6 +7,7 @@ require (
 	github.com/Azure/go-autorest/autorest/adal v0.8.2 // indirect
 	github.com/BurntSushi/toml v0.3.1
 	github.com/aws/aws-sdk-go v1.25.37
+	github.com/coreos/go-semver v0.3.0
 	github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f
 	github.com/gobwas/glob v0.2.3
 	github.com/google/go-cmp v0.3.1
diff --git a/go.sum b/go.sum
index 86c9edcc1..32a4336ae 100644
--- a/go.sum
+++ b/go.sum
@@ -22,6 +22,8 @@ github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
 github.com/aws/aws-sdk-go v1.25.37 h1:gBtB/F3dophWpsUQKN/Kni+JzYEH2mGHF4hWNtfED1w=
 github.com/aws/aws-sdk-go v1.25.37/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
+github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM=
+github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
 github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f h1:JOrtw2xFKzlg+cbHpyrpLDmnN1HqhBfnX7WDiW7eG2c=
 github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
 github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
diff --git a/golang-github-osbuild-composer.spec b/golang-github-osbuild-composer.spec
index 78a15d572..505e30b4c 100644
--- a/golang-github-osbuild-composer.spec
+++ b/golang-github-osbuild-composer.spec
@@ -1,6 +1,6 @@
 %global goipath         github.com/osbuild/osbuild-composer
 
-Version:        5
+Version:        6
 
 %gometa
 
@@ -28,6 +28,7 @@ BuildRequires:  git
 BuildRequires:  golang(github.com/aws/aws-sdk-go)
 BuildRequires:  golang-github-azure-storage-blob-devel
 BuildRequires:  golang(github.com/BurntSushi/toml)
+BuildRequires:  golang(github.com/coreos/go-semver/semver)
 BuildRequires:  golang(github.com/coreos/go-systemd/activation)
 BuildRequires:  golang(github.com/google/uuid)
 BuildRequires:  golang(github.com/julienschmidt/httprouter)
@@ -35,8 +36,9 @@ BuildRequires:  golang(github.com/gobwas/glob)
 BuildRequires:  golang(github.com/google/go-cmp/cmp)
 %endif
 
+Requires: golang-github-osbuild-composer-worker
 Requires: systemd
-Requires: osbuild>=7
+Requires: osbuild >= 7
 
 Provides: osbuild-composer
 Provides: lorax-composer
@@ -65,6 +67,7 @@ export GOFLAGS=-mod=vendor
 %gobuild -o _bin/osbuild-composer %{goipath}/cmd/osbuild-composer
 %gobuild -o _bin/osbuild-worker %{goipath}/cmd/osbuild-worker
 %gobuild -o _bin/osbuild-tests %{goipath}/cmd/osbuild-tests
+%gobuild -o _bin/osbuild-dnf-json-tests %{goipath}/cmd/osbuild-dnf-json-tests
 
 %install
 install -m 0755 -vd                                         %{buildroot}%{_libexecdir}/osbuild-composer
@@ -74,6 +77,7 @@ install -m 0755 -vp dnf-json                                %{buildroot}%{_libex
 
 install -m 0755 -vd                                         %{buildroot}%{_libexecdir}/tests/osbuild-composer
 install -m 0755 -vp _bin/osbuild-tests                      %{buildroot}%{_libexecdir}/tests/osbuild-composer/
+install -m 0755 -vp _bin/osbuild-dnf-json-tests             %{buildroot}%{_libexecdir}/tests/osbuild-composer/
 
 install -m 0755 -vd                                         %{buildroot}%{_datadir}/osbuild-composer/repositories
 install -m 0644 -vp repositories/*                          %{buildroot}%{_datadir}/osbuild-composer/repositories/
@@ -96,32 +100,69 @@ export GOPATH=$PWD/_build:%{gopath}
 %endif
 
 %post
-%systemd_post osbuild-composer.service osbuild-composer.socket osbuild-worker@.service
+%systemd_post osbuild-composer.service osbuild-composer.socket osbuild-remote-worker.socket
 
 %preun
-%systemd_preun osbuild-composer.service osbuild-composer.socket osbuild-worker@.service
+%systemd_preun osbuild-composer.service osbuild-composer.socket osbuild-remote-worker.socket
 
 %postun
-%systemd_postun_with_restart osbuild-composer.service osbuild-composer.socket osbuild-worker@.service
+%systemd_postun_with_restart osbuild-composer.service osbuild-composer.socket osbuild-remote-worker.socket
 
 %files
 %license LICENSE
 %doc README.md
-%{_libexecdir}/osbuild-composer/
+%{_libexecdir}/osbuild-composer/osbuild-composer
+%{_libexecdir}/osbuild-composer/dnf-json
 %{_datadir}/osbuild-composer/
-%{_unitdir}/*.{service,socket}
+%{_unitdir}/osbuild-composer.service
+%{_unitdir}/osbuild-composer.socket
+%{_unitdir}/osbuild-remote-worker@.service
+%{_unitdir}/osbuild-remote-worker.socket
+%{_unitdir}/osbuild-worker@.service
 %{_sysusersdir}/osbuild-composer.conf
 
+%package rcm
+Summary:	RCM-specific version of osbuild-composer
+Requires: 	osbuild-composer
+
+%description rcm
+RCM-specific version of osbuild-composer not intended for public usage.
+
+%files rcm
+%{_unitdir}/osbuild-rcm.socket
+
 %package tests
 Summary:	Integration tests
 Requires: 	osbuild-composer
 Requires: 	composer-cli
+Requires:	createrepo_c
 
 %description tests
 Integration tests to be run on a pristine-dedicated system to test the osbuild-composer package.
 
 %files tests
 %{_libexecdir}/tests/osbuild-composer/osbuild-tests
+%{_libexecdir}/tests/osbuild-composer/osbuild-dnf-json-tests
+
+%package worker
+Summary:	The worker for osbuild-composer
+Requires:   systemd
+Requires:   osbuild
+
+%description worker
+The worker for osbuild-composer
+
+%files worker
+%{_libexecdir}/osbuild-composer/osbuild-worker
+
+%post worker
+%systemd_post osbuild-worker@.service osbuild-remote-worker@.service
+
+%preun worker
+%systemd_preun osbuild-worker@.service osbuild-remote-worker@.service
+
+%postun worker
+%systemd_postun_with_restart osbuild-worker@.service osbuild-remote-worker@.service
 
 %changelog
 * Sun Dec 1 11:00:00 CEST 2019 Ondrej Budai <obudai@redhat.com> - 4-1
diff --git a/internal/blueprint/blueprint.go b/internal/blueprint/blueprint.go
index dbe392371..81b6c32d9 100644
--- a/internal/blueprint/blueprint.go
+++ b/internal/blueprint/blueprint.go
@@ -3,6 +3,9 @@ package blueprint
 
 import (
 	"encoding/json"
+	"fmt"
+
+	"github.com/coreos/go-semver/semver"
 )
 
 // A Blueprint is a high-level description of an image.
@@ -51,6 +54,39 @@ func (b *Blueprint) DeepCopy() (Blueprint, error) {
 	return bp, nil
 }
 
+// Initialize ensures that the blueprint has sane defaults for any missing fields
+func (b *Blueprint) Initialize() error {
+	if b.Packages == nil {
+		b.Packages = []Package{}
+	}
+	if b.Modules == nil {
+		b.Modules = []Package{}
+	}
+	if b.Groups == nil {
+		b.Groups = []Group{}
+	}
+	// Return an error if the version is not valid
+	_, err := semver.NewVersion(b.Version)
+	if err != nil {
+		return fmt.Errorf("Invalid 'version', must use Semantic Versioning: %s", err.Error())
+	}
+	return nil
+}
+
+// BumpVersion increments the previous blueprint's version
+// If the old version string is not vaild semver it will use the new version as-is
+// This assumes that the new blueprint's version has already been validated via Initialize
+func (b *Blueprint) BumpVersion(old string) {
+	var ver *semver.Version
+	ver, err := semver.NewVersion(old)
+	if err != nil {
+		return
+	}
+
+	ver.BumpPatch()
+	b.Version = ver.String()
+}
+
 // packages, modules, and groups all resolve to rpm packages right now. This
 // function returns a combined list of "name-version" strings.
 func (b *Blueprint) GetPackages() []string {
diff --git a/internal/common/types.go b/internal/common/types.go
index c3b2af0c2..d62e4ea76 100644
--- a/internal/common/types.go
+++ b/internal/common/types.go
@@ -18,6 +18,16 @@ func (err *CustomJsonConversionError) Error() string {
 	return err.reason
 }
 
+// CustomTypeError is thrown when parsing strings into enumerations
+type CustomTypeError struct {
+	reason string
+}
+
+// Error returns the error as a string
+func (err *CustomTypeError) Error() string {
+	return err.reason
+}
+
 // Since Go has no generics, this is the only way to write common code for all the types present in this package.
 // It uses weakly typed maps to convert between strings and integers which are then wrapped into type aliases.
 // Specific implementations are bellow each type.
@@ -124,12 +134,12 @@ func ArchitectureExists(testedArch string) bool {
 
 // UnmarshalJSON is a custom unmarshaling function to limit the set of allowed values
 // in case the input is JSON.
-func (arch Architecture) UnmarshalJSON(data []byte) error {
+func (arch *Architecture) UnmarshalJSON(data []byte) error {
 	value, err := unmarshalHelper(data, " is not a valid JSON value", " is not a valid architecture", getArchMapping())
 	if err != nil {
 		return err
 	}
-	arch = Architecture(value)
+	*arch = Architecture(value)
 	return nil
 }
 
@@ -139,7 +149,7 @@ func (arch Architecture) MarshalJSON() ([]byte, error) {
 }
 
 func (arch Architecture) ToString() (string, bool) {
-	return toStringHelper(getDistributionMapping(), int(arch))
+	return toStringHelper(getArchMapping(), int(arch))
 }
 
 type ImageType int
@@ -199,12 +209,12 @@ func getCompatImageTypeMapping() map[int]string {
 	return mapping
 }
 
-func (imgType ImageType) UnmarshalJSON(data []byte) error {
+func (imgType *ImageType) UnmarshalJSON(data []byte) error {
 	value, err := unmarshalHelper(data, " is not a valid JSON value", " is not a valid image type", getImageTypeMapping())
 	if err != nil {
 		return err
 	}
-	imgType = ImageType(value)
+	*imgType = ImageType(value)
 	return nil
 }
 
@@ -243,6 +253,7 @@ const (
 // getArchMapping is a helper function that defines the conversion from JSON string value
 // to Distribution.
 func getDistributionMapping() map[string]int {
+	// Don't forget to add module-platform-id mapping below
 	mapping := map[string]int{
 		"fedora-30": int(Fedora30),
 		"fedora-31": int(Fedora31),
@@ -252,6 +263,22 @@ func getDistributionMapping() map[string]int {
 	return mapping
 }
 
+func (dist *Distribution) ModulePlatformID() (string, error) {
+	mapping := map[Distribution]string{
+		// TODO: this could be refactored so we don't have these strings hard coded in two places
+		Fedora30: "platform:f30",
+		Fedora31: "platform:f31",
+		Fedora32: "platform:f32",
+		RHEL82:   "platform:el8",
+	}
+	id, exists := mapping[*dist]
+	if !exists {
+		return "", &CustomTypeError{reason: "Distribution does not have a module platform ID assigned"}
+	} else {
+		return id, nil
+	}
+}
+
 func ListDistributions() []string {
 	return listHelper(getDistributionMapping())
 }
@@ -260,12 +287,12 @@ func DistributionExists(testedDistro string) bool {
 	return existsHelper(getDistributionMapping(), testedDistro)
 }
 
-func (distro Distribution) UnmarshalJSON(data []byte) error {
+func (distro *Distribution) UnmarshalJSON(data []byte) error {
 	value, err := unmarshalHelper(data, " is not a valid JSON value", " is not a valid distribution", getDistributionMapping())
 	if err != nil {
 		return err
 	}
-	distro = Distribution(value)
+	*distro = Distribution(value)
 	return nil
 }
 
@@ -300,12 +327,12 @@ func getUploadTargetMapping() map[string]int {
 	return mapping
 }
 
-func (ut UploadTarget) UnmarshalJSON(data []byte) error {
+func (ut *UploadTarget) UnmarshalJSON(data []byte) error {
 	value, err := unmarshalHelper(data, " is not a valid JSON value", " is not a valid upload target", getUploadTargetMapping())
 	if err != nil {
 		return err
 	}
-	ut = UploadTarget(value)
+	*ut = UploadTarget(value)
 	return nil
 }
 
@@ -325,5 +352,6 @@ type ComposeRequest struct {
 	Distro          Distribution        `json:"distro"`
 	Arch            Architecture        `json:"arch"`
 	Repositories    []rpmmd.RepoConfig  `json:"repositories"`
+	Checksums       map[string]string   `json:"checksums"`
 	RequestedImages []ImageRequest      `json:"requested_images"`
 }
diff --git a/internal/mocks/rpmmd/rpmmd_mock.go b/internal/mocks/rpmmd/rpmmd_mock.go
index 3bc8fe40e..fabbe7fda 100644
--- a/internal/mocks/rpmmd/rpmmd_mock.go
+++ b/internal/mocks/rpmmd/rpmmd_mock.go
@@ -30,7 +30,7 @@ func NewRPMMDMock(fixture Fixture) rpmmd.RPMMD {
 	return &rpmmdMock{Fixture: fixture}
 }
 
-func (r *rpmmdMock) FetchPackageList(repos []rpmmd.RepoConfig, modulePlatformID string) (rpmmd.PackageList, map[string]string, error) {
+func (r *rpmmdMock) FetchMetadata(repos []rpmmd.RepoConfig, modulePlatformID string) (rpmmd.PackageList, map[string]string, error) {
 	return r.Fixture.fetchPackageList.ret, r.Fixture.fetchPackageList.checksums, r.Fixture.fetchPackageList.err
 }
 
diff --git a/internal/rcm/api.go b/internal/rcm/api.go
index f319fba53..86b1f0742 100644
--- a/internal/rcm/api.go
+++ b/internal/rcm/api.go
@@ -4,6 +4,9 @@ package rcm
 
 import (
 	"encoding/json"
+	"fmt"
+	"github.com/osbuild/osbuild-composer/internal/common"
+	"github.com/osbuild/osbuild-composer/internal/rpmmd"
 	"log"
 	"net"
 	"net/http"
@@ -20,14 +23,18 @@ type API struct {
 	logger *log.Logger
 	store  *store.Store
 	router *httprouter.Router
+	// rpmMetadata is an interface to dnf-json and we include it here so that we can
+	// mock it in the unit tests
+	rpmMetadata rpmmd.RPMMD
 }
 
 // New creates new RCM API
-func New(logger *log.Logger, store *store.Store) *API {
+func New(logger *log.Logger, store *store.Store, rpmMetadata rpmmd.RPMMD) *API {
 	api := &API{
-		logger: logger,
-		store:  store,
-		router: httprouter.New(),
+		logger:      logger,
+		store:       store,
+		router:      httprouter.New(),
+		rpmMetadata: rpmMetadata,
 	}
 
 	api.router.RedirectTrailingSlash = false
@@ -85,10 +92,10 @@ func (api *API) submit(writer http.ResponseWriter, request *http.Request, _ http
 
 	// JSON structure expected from the client
 	var composeRequest struct {
-		Distribution  string       `json:"distribution"`
-		ImageTypes    []string     `json:"image_types"`
-		Architectures []string     `json:"architectures"`
-		Repositories  []Repository `json:"repositories"`
+		Distribution  common.Distribution   `json:"distribution"`
+		ImageTypes    []common.ImageType    `json:"image_types"`
+		Architectures []common.Architecture `json:"architectures"`
+		Repositories  []Repository          `json:"repositories"`
 	}
 	// JSON structure with error message
 	var errorReason struct {
@@ -98,15 +105,12 @@ func (api *API) submit(writer http.ResponseWriter, request *http.Request, _ http
 	decoder := json.NewDecoder(request.Body)
 	decoder.DisallowUnknownFields()
 	err := decoder.Decode(&composeRequest)
-	if err != nil || composeRequest.Distribution == "" || len(composeRequest.Architectures) == 0 || len(composeRequest.Repositories) == 0 {
+	if err != nil || len(composeRequest.Architectures) == 0 || len(composeRequest.Repositories) == 0 || len(composeRequest.ImageTypes) == 0 {
 		writer.WriteHeader(http.StatusBadRequest)
 		errors := []string{}
 		if err != nil {
 			errors = append(errors, err.Error())
 		}
-		if composeRequest.Distribution == "" {
-			errors = append(errors, "input must specify a distribution")
-		}
 		if len(composeRequest.ImageTypes) == 0 {
 			errors = append(errors, "input must specify an image type")
 		} else if len(composeRequest.ImageTypes) != 1 {
@@ -121,16 +125,68 @@ func (api *API) submit(writer http.ResponseWriter, request *http.Request, _ http
 			errors = append(errors, "input must specify repositories")
 		}
 		errorReason.Error = strings.Join(errors, ", ")
-		// TODO: handle error
-		_ = json.NewEncoder(writer).Encode(errorReason)
+		err = json.NewEncoder(writer).Encode(errorReason)
+		if err != nil {
+			// JSON encoding is clearly our fault.
+			panic("Failed to encode errors in RCM API. This is a bug.")
+		}
 		return
 	}
 
+	// Create repo configurations from the URLs in the request. Use made up repo id and name, because
+	// we don't want to bother clients of this API with details like this
+	repoConfigs := []rpmmd.RepoConfig{}
+	for n, repo := range composeRequest.Repositories {
+		repoConfigs = append(repoConfigs, rpmmd.RepoConfig{
+			Id:        fmt.Sprintf("repo-%d", n),
+			Name:      fmt.Sprintf("repo-%d", n),
+			BaseURL:   repo.URL,
+			IgnoreSSL: false,
+		})
+	}
+
+	// Image requests are derived from requested image types. All of them are uploaded to Koji, because
+	// this API is only for RCM.
+	requestedImages := []common.ImageRequest{}
+	for _, imgType := range composeRequest.ImageTypes {
+		requestedImages = append(requestedImages, common.ImageRequest{
+			ImgType: imgType,
+			// TODO: use koji upload type as soon as it is available
+			UpTarget: []common.UploadTarget{},
+		})
+	}
+
+	modulePlatformID, err := composeRequest.Distribution.ModulePlatformID()
+	if err != nil {
+		writer.WriteHeader(http.StatusBadRequest)
+		_, err := writer.Write([]byte(err.Error()))
+		if err != nil {
+			panic("Failed to write response")
+		}
+	}
+
+	// map( repo-id => checksum )
+	_, checksums, err := api.rpmMetadata.FetchMetadata(repoConfigs, modulePlatformID)
+	if err != nil {
+		writer.WriteHeader(http.StatusBadRequest)
+		_, err := writer.Write([]byte(err.Error()))
+		if err != nil {
+			panic("Failed to write response")
+		}
+	}
+
 	// Push the requested compose to the store
 	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{}, nil, nil, make(map[string]string), composeRequest.Architectures[0], composeRequest.ImageTypes[0], 0, nil)
+	err = api.store.PushComposeRequest(common.ComposeRequest{
+		Blueprint:       blueprint.Blueprint{},
+		ComposeID:       composeUUID,
+		Distro:          composeRequest.Distribution,
+		Arch:            composeRequest.Architectures[0],
+		Repositories:    repoConfigs,
+		Checksums:       checksums,
+		RequestedImages: requestedImages,
+	})
 	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 5227a8ee9..dd4f06591 100644
--- a/internal/rpmmd/repository.go
+++ b/internal/rpmmd/repository.go
@@ -6,6 +6,7 @@ import (
 	"io/ioutil"
 	"os"
 	"os/exec"
+	"path"
 	"sort"
 	"time"
 
@@ -95,7 +96,13 @@ type PackageInfo struct {
 }
 
 type RPMMD interface {
-	FetchPackageList(repos []RepoConfig, modulePlatformID string) (PackageList, map[string]string, error)
+	// FetchMetadata returns all metadata about the repositories we use in the code. Specifically it is a
+	// list of packages and dictionary of checksums of the repositories.
+	FetchMetadata(repos []RepoConfig, modulePlatformID string) (PackageList, map[string]string, error)
+
+	// Depsolve takes a list of required content (specs), explicitly unwanted content (excludeSpecs), list
+	// or repositories, and platform ID for modularity. It returns a list of all packages (with solved
+	// dependencies) that will be installed into the system.
 	Depsolve(specs, excludeSpecs []string, repos []RepoConfig, modulePlatformID string, clean bool) ([]PackageSpec, map[string]string, error)
 }
 
@@ -108,6 +115,14 @@ func (err *DNFError) Error() string {
 	return fmt.Sprintf("DNF error occured: %s: %s", err.Kind, err.Reason)
 }
 
+type RepositoryError struct {
+	msg string
+}
+
+func (re *RepositoryError) Error() string {
+	return re.msg
+}
+
 func LoadRepositories(confPaths []string, distro string) (map[string][]RepoConfig, error) {
 	var f *os.File
 	var err error
@@ -121,6 +136,9 @@ func LoadRepositories(confPaths []string, distro string) (map[string][]RepoConfi
 			return nil, err
 		}
 	}
+	if err != nil {
+		return nil, &RepositoryError{"LoadRepositories failed: none of the provided paths contain distro configuration"}
+	}
 	defer f.Close()
 
 	var repos map[string][]RepoConfig
@@ -199,11 +217,12 @@ func NewRPMMD() RPMMD {
 	return &rpmmdImpl{}
 }
 
-func (*rpmmdImpl) FetchPackageList(repos []RepoConfig, modulePlatformID string) (PackageList, map[string]string, error) {
+func (*rpmmdImpl) FetchMetadata(repos []RepoConfig, modulePlatformID string) (PackageList, map[string]string, error) {
 	var arguments = struct {
 		Repos            []RepoConfig `json:"repos"`
+		CacheDir         string       `json:"cachedir"`
 		ModulePlatformID string       `json:"module_platform_id"`
-	}{repos, modulePlatformID}
+	}{repos, path.Join(os.Getenv("CACHE_DIRECTORY"), "rpmmd"), modulePlatformID}
 	var reply struct {
 		Checksums map[string]string `json:"checksums"`
 		Packages  PackageList       `json:"packages"`
@@ -220,9 +239,10 @@ func (*rpmmdImpl) Depsolve(specs, excludeSpecs []string, repos []RepoConfig, mod
 		PackageSpecs     []string     `json:"package-specs"`
 		ExcludSpecs      []string     `json:"exclude-specs"`
 		Repos            []RepoConfig `json:"repos"`
+		CacheDir         string       `json:"cachedir"`
 		ModulePlatformID string       `json:"module_platform_id"`
 		Clean            bool         `json:"clean,omitempty"`
-	}{specs, excludeSpecs, repos, modulePlatformID, clean}
+	}{specs, excludeSpecs, repos, path.Join(os.Getenv("CACHE_DIRECTORY"), "rpmmd"), modulePlatformID, clean}
 	var reply struct {
 		Checksums    map[string]string `json:"checksums"`
 		Dependencies []PackageSpec     `json:"dependencies"`
diff --git a/internal/store/store.go b/internal/store/store.go
index db157348e..8afeca2f7 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -15,9 +15,8 @@ import (
 	"log"
 	"os"
 	"path/filepath"
+	"reflect"
 	"sort"
-	"strconv"
-	"strings"
 	"sync"
 	"time"
 
@@ -292,20 +291,6 @@ func (s *Store) GetBlueprint(name string) (*blueprint.Blueprint, bool) {
 		}
 	}
 
-	// cockpit-composer cannot deal with missing "packages" or "modules"
-	if bp.Packages == nil {
-		bp.Packages = []blueprint.Package{}
-	}
-	if bp.Modules == nil {
-		bp.Modules = []blueprint.Package{}
-	}
-	if bp.Groups == nil {
-		bp.Groups = []blueprint.Group{}
-	}
-	if bp.Version == "" {
-		bp.Version = "0.0.0"
-	}
-
 	return &bp, inWorkspace
 }
 
@@ -318,20 +303,6 @@ func (s *Store) GetBlueprintCommitted(name string) *blueprint.Blueprint {
 		return nil
 	}
 
-	// cockpit-composer cannot deal with missing "packages" or "modules"
-	if bp.Packages == nil {
-		bp.Packages = []blueprint.Package{}
-	}
-	if bp.Modules == nil {
-		bp.Modules = []blueprint.Package{}
-	}
-	if bp.Groups == nil {
-		bp.Groups = []blueprint.Group{}
-	}
-	if bp.Version == "" {
-		bp.Version = "0.0.0"
-	}
-
 	return &bp
 }
 
@@ -359,28 +330,19 @@ func (s *Store) GetBlueprintChanges(name string) []blueprint.Change {
 	return changes
 }
 
-func bumpVersion(str string) string {
-	v := [3]uint64{}
-	fields := strings.SplitN(str, ".", 3)
-	for i := 0; i < len(fields); i++ {
-		if n, err := strconv.ParseUint(fields[i], 10, 64); err == nil {
-			v[i] = n
-		} else {
-			// don't touch strings with invalid versions
-			return str
+func (s *Store) PushBlueprint(bp blueprint.Blueprint, commitMsg string) error {
+	return s.change(func() error {
+		commit, err := randomSHA1String()
+		if err != nil {
+			return err
 		}
-	}
-
-	return fmt.Sprintf("%d.%d.%d", v[0], v[1], v[2]+1)
-}
 
-func (s *Store) PushBlueprint(bp blueprint.Blueprint, commitMsg string) {
-	// FIXME: handle or comment this possible error
-	_ = s.change(func() error {
-		commit, err := randomSHA1String()
+		// Make sure the blueprint has default values and that the version is valid
+		err = bp.Initialize()
 		if err != nil {
 			return err
 		}
+
 		timestamp := time.Now().Format("2006-01-02T15:04:05Z")
 		change := blueprint.Change{
 			Commit:    commit,
@@ -397,7 +359,7 @@ func (s *Store) PushBlueprint(bp blueprint.Blueprint, commitMsg string) {
 
 		if old, ok := s.Blueprints[bp.Name]; ok {
 			if bp.Version == "" || bp.Version == old.Version {
-				bp.Version = bumpVersion(old.Version)
+				bp.BumpVersion(old.Version)
 			}
 		}
 		s.Blueprints[bp.Name] = bp
@@ -405,9 +367,14 @@ func (s *Store) PushBlueprint(bp blueprint.Blueprint, commitMsg string) {
 	})
 }
 
-func (s *Store) PushBlueprintToWorkspace(bp blueprint.Blueprint) {
-	// FIXME: handle or comment this possible error
-	_ = s.change(func() error {
+func (s *Store) PushBlueprintToWorkspace(bp blueprint.Blueprint) error {
+	return s.change(func() error {
+		// Make sure the blueprint has default values and that the version is valid
+		err := bp.Initialize()
+		if err != nil {
+			return err
+		}
+
 		s.Workspace[bp.Name] = bp
 		return nil
 	})
@@ -586,7 +553,7 @@ func (s *Store) PushComposeRequest(request common.ComposeRequest) error {
 	}
 
 	distroStruct := s.distroRegistry.GetDistro(distroString)
-	if distroStruct == nil {
+	if distroStruct == nil || (reflect.ValueOf(distroStruct).Kind() == reflect.Ptr && reflect.ValueOf(distroStruct).IsNil()) {
 		panic("fatal error, distro should exist but it is not in the registry")
 	}
 
@@ -602,13 +569,12 @@ func (s *Store) PushComposeRequest(request common.ComposeRequest) error {
 	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
 		// This should never happen and once distro.Pipeline is refactored this check will go away
 		imgTypeCompatStr, exists := imageRequest.ImgType.ToCompatString()
 		if !exists {
 			panic("fatal error, image type should exist but it does not")
 		}
-		pipelineStruct, err := distroStruct.Pipeline(&request.Blueprint, request.Repositories, nil, nil, nil, arch, imgTypeCompatStr, 0)
+		pipelineStruct, err := distroStruct.Pipeline(&request.Blueprint, request.Repositories, nil, nil, request.Checksums, arch, imgTypeCompatStr, 0)
 		if err != nil {
 			return err
 		}
diff --git a/internal/weldr/api.go b/internal/weldr/api.go
index 1b1c82a21..cb6f3ef42 100644
--- a/internal/weldr/api.go
+++ b/internal/weldr/api.go
@@ -1161,7 +1161,15 @@ func (api *API) blueprintsNewHandler(writer http.ResponseWriter, request *http.R
 	}
 
 	commitMsg := "Recipe " + blueprint.Name + ", version " + blueprint.Version + " saved."
-	api.store.PushBlueprint(blueprint, commitMsg)
+	err = api.store.PushBlueprint(blueprint, commitMsg)
+	if err != nil {
+		errors := responseError{
+			ID:  "BlueprintsError",
+			Msg: err.Error(),
+		}
+		statusResponseError(writer, http.StatusBadRequest, errors)
+		return
+	}
 
 	statusResponseOK(writer)
 }
@@ -1200,7 +1208,15 @@ func (api *API) blueprintsWorkspaceHandler(writer http.ResponseWriter, request *
 		return
 	}
 
-	api.store.PushBlueprintToWorkspace(blueprint)
+	err = api.store.PushBlueprintToWorkspace(blueprint)
+	if err != nil {
+		errors := responseError{
+			ID:  "BlueprintsError",
+			Msg: err.Error(),
+		}
+		statusResponseError(writer, http.StatusBadRequest, errors)
+		return
+	}
 
 	statusResponseOK(writer)
 }
@@ -1839,7 +1855,7 @@ func (api *API) fetchPackageList() (rpmmd.PackageList, error) {
 		repos = append(repos, source.RepoConfig())
 	}
 
-	packages, _, err := api.rpmmd.FetchPackageList(repos, api.distro.ModulePlatformID())
+	packages, _, err := api.rpmmd.FetchMetadata(repos, api.distro.ModulePlatformID())
 	return packages, err
 }
 
diff --git a/osbuild b/osbuild
index e0bb65dd7..bd6e6bde5 160000
--- a/osbuild
+++ b/osbuild
@@ -1,1 +1,1 @@
-Subproject commit e0bb65dd711e5ec2201d2b1347d80556499ef532
+Subproject commit bd6e6bde56d048e1ea994b7239644529a0262e1a
diff --git a/vendor/github.com/coreos/go-semver/LICENSE b/vendor/github.com/coreos/go-semver/LICENSE
new file mode 100644
index 000000000..d64569567
--- /dev/null
+++ b/vendor/github.com/coreos/go-semver/LICENSE
@@ -0,0 +1,202 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
diff --git a/vendor/github.com/coreos/go-semver/NOTICE b/vendor/github.com/coreos/go-semver/NOTICE
new file mode 100644
index 000000000..23a0ada2f
--- /dev/null
+++ b/vendor/github.com/coreos/go-semver/NOTICE
@@ -0,0 +1,5 @@
+CoreOS Project
+Copyright 2018 CoreOS, Inc
+
+This product includes software developed at CoreOS, Inc.
+(http://www.coreos.com/).
diff --git a/vendor/github.com/coreos/go-semver/semver/semver.go b/vendor/github.com/coreos/go-semver/semver/semver.go
new file mode 100644
index 000000000..76cf4852c
--- /dev/null
+++ b/vendor/github.com/coreos/go-semver/semver/semver.go
@@ -0,0 +1,296 @@
+// Copyright 2013-2015 CoreOS, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Semantic Versions http://semver.org
+package semver
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"regexp"
+	"strconv"
+	"strings"
+)
+
+type Version struct {
+	Major      int64
+	Minor      int64
+	Patch      int64
+	PreRelease PreRelease
+	Metadata   string
+}
+
+type PreRelease string
+
+func splitOff(input *string, delim string) (val string) {
+	parts := strings.SplitN(*input, delim, 2)
+
+	if len(parts) == 2 {
+		*input = parts[0]
+		val = parts[1]
+	}
+
+	return val
+}
+
+func New(version string) *Version {
+	return Must(NewVersion(version))
+}
+
+func NewVersion(version string) (*Version, error) {
+	v := Version{}
+
+	if err := v.Set(version); err != nil {
+		return nil, err
+	}
+
+	return &v, nil
+}
+
+// Must is a helper for wrapping NewVersion and will panic if err is not nil.
+func Must(v *Version, err error) *Version {
+	if err != nil {
+		panic(err)
+	}
+	return v
+}
+
+// Set parses and updates v from the given version string. Implements flag.Value
+func (v *Version) Set(version string) error {
+	metadata := splitOff(&version, "+")
+	preRelease := PreRelease(splitOff(&version, "-"))
+	dotParts := strings.SplitN(version, ".", 3)
+
+	if len(dotParts) != 3 {
+		return fmt.Errorf("%s is not in dotted-tri format", version)
+	}
+
+	if err := validateIdentifier(string(preRelease)); err != nil {
+		return fmt.Errorf("failed to validate pre-release: %v", err)
+	}
+
+	if err := validateIdentifier(metadata); err != nil {
+		return fmt.Errorf("failed to validate metadata: %v", err)
+	}
+
+	parsed := make([]int64, 3, 3)
+
+	for i, v := range dotParts[:3] {
+		val, err := strconv.ParseInt(v, 10, 64)
+		parsed[i] = val
+		if err != nil {
+			return err
+		}
+	}
+
+	v.Metadata = metadata
+	v.PreRelease = preRelease
+	v.Major = parsed[0]
+	v.Minor = parsed[1]
+	v.Patch = parsed[2]
+	return nil
+}
+
+func (v Version) String() string {
+	var buffer bytes.Buffer
+
+	fmt.Fprintf(&buffer, "%d.%d.%d", v.Major, v.Minor, v.Patch)
+
+	if v.PreRelease != "" {
+		fmt.Fprintf(&buffer, "-%s", v.PreRelease)
+	}
+
+	if v.Metadata != "" {
+		fmt.Fprintf(&buffer, "+%s", v.Metadata)
+	}
+
+	return buffer.String()
+}
+
+func (v *Version) UnmarshalYAML(unmarshal func(interface{}) error) error {
+	var data string
+	if err := unmarshal(&data); err != nil {
+		return err
+	}
+	return v.Set(data)
+}
+
+func (v Version) MarshalJSON() ([]byte, error) {
+	return []byte(`"` + v.String() + `"`), nil
+}
+
+func (v *Version) UnmarshalJSON(data []byte) error {
+	l := len(data)
+	if l == 0 || string(data) == `""` {
+		return nil
+	}
+	if l < 2 || data[0] != '"' || data[l-1] != '"' {
+		return errors.New("invalid semver string")
+	}
+	return v.Set(string(data[1 : l-1]))
+}
+
+// Compare tests if v is less than, equal to, or greater than versionB,
+// returning -1, 0, or +1 respectively.
+func (v Version) Compare(versionB Version) int {
+	if cmp := recursiveCompare(v.Slice(), versionB.Slice()); cmp != 0 {
+		return cmp
+	}
+	return preReleaseCompare(v, versionB)
+}
+
+// Equal tests if v is equal to versionB.
+func (v Version) Equal(versionB Version) bool {
+	return v.Compare(versionB) == 0
+}
+
+// LessThan tests if v is less than versionB.
+func (v Version) LessThan(versionB Version) bool {
+	return v.Compare(versionB) < 0
+}
+
+// Slice converts the comparable parts of the semver into a slice of integers.
+func (v Version) Slice() []int64 {
+	return []int64{v.Major, v.Minor, v.Patch}
+}
+
+func (p PreRelease) Slice() []string {
+	preRelease := string(p)
+	return strings.Split(preRelease, ".")
+}
+
+func preReleaseCompare(versionA Version, versionB Version) int {
+	a := versionA.PreRelease
+	b := versionB.PreRelease
+
+	/* Handle the case where if two versions are otherwise equal it is the
+	 * one without a PreRelease that is greater */
+	if len(a) == 0 && (len(b) > 0) {
+		return 1
+	} else if len(b) == 0 && (len(a) > 0) {
+		return -1
+	}
+
+	// If there is a prerelease, check and compare each part.
+	return recursivePreReleaseCompare(a.Slice(), b.Slice())
+}
+
+func recursiveCompare(versionA []int64, versionB []int64) int {
+	if len(versionA) == 0 {
+		return 0
+	}
+
+	a := versionA[0]
+	b := versionB[0]
+
+	if a > b {
+		return 1
+	} else if a < b {
+		return -1
+	}
+
+	return recursiveCompare(versionA[1:], versionB[1:])
+}
+
+func recursivePreReleaseCompare(versionA []string, versionB []string) int {
+	// A larger set of pre-release fields has a higher precedence than a smaller set,
+	// if all of the preceding identifiers are equal.
+	if len(versionA) == 0 {
+		if len(versionB) > 0 {
+			return -1
+		}
+		return 0
+	} else if len(versionB) == 0 {
+		// We're longer than versionB so return 1.
+		return 1
+	}
+
+	a := versionA[0]
+	b := versionB[0]
+
+	aInt := false
+	bInt := false
+
+	aI, err := strconv.Atoi(versionA[0])
+	if err == nil {
+		aInt = true
+	}
+
+	bI, err := strconv.Atoi(versionB[0])
+	if err == nil {
+		bInt = true
+	}
+
+	// Numeric identifiers always have lower precedence than non-numeric identifiers.
+	if aInt && !bInt {
+		return -1
+	} else if !aInt && bInt {
+		return 1
+	}
+
+	// Handle Integer Comparison
+	if aInt && bInt {
+		if aI > bI {
+			return 1
+		} else if aI < bI {
+			return -1
+		}
+	}
+
+	// Handle String Comparison
+	if a > b {
+		return 1
+	} else if a < b {
+		return -1
+	}
+
+	return recursivePreReleaseCompare(versionA[1:], versionB[1:])
+}
+
+// BumpMajor increments the Major field by 1 and resets all other fields to their default values
+func (v *Version) BumpMajor() {
+	v.Major += 1
+	v.Minor = 0
+	v.Patch = 0
+	v.PreRelease = PreRelease("")
+	v.Metadata = ""
+}
+
+// BumpMinor increments the Minor field by 1 and resets all other fields to their default values
+func (v *Version) BumpMinor() {
+	v.Minor += 1
+	v.Patch = 0
+	v.PreRelease = PreRelease("")
+	v.Metadata = ""
+}
+
+// BumpPatch increments the Patch field by 1 and resets all other fields to their default values
+func (v *Version) BumpPatch() {
+	v.Patch += 1
+	v.PreRelease = PreRelease("")
+	v.Metadata = ""
+}
+
+// validateIdentifier makes sure the provided identifier satisfies semver spec
+func validateIdentifier(id string) error {
+	if id != "" && !reIdentifier.MatchString(id) {
+		return fmt.Errorf("%s is not a valid semver identifier", id)
+	}
+	return nil
+}
+
+// reIdentifier is a regular expression used to check that pre-release and metadata
+// identifiers satisfy the spec requirements
+var reIdentifier = regexp.MustCompile(`^[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*$`)
diff --git a/vendor/github.com/coreos/go-semver/semver/sort.go b/vendor/github.com/coreos/go-semver/semver/sort.go
new file mode 100644
index 000000000..e256b41a5
--- /dev/null
+++ b/vendor/github.com/coreos/go-semver/semver/sort.go
@@ -0,0 +1,38 @@
+// Copyright 2013-2015 CoreOS, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package semver
+
+import (
+	"sort"
+)
+
+type Versions []*Version
+
+func (s Versions) Len() int {
+	return len(s)
+}
+
+func (s Versions) Swap(i, j int) {
+	s[i], s[j] = s[j], s[i]
+}
+
+func (s Versions) Less(i, j int) bool {
+	return s[i].LessThan(*s[j])
+}
+
+// Sort sorts the given slice of Version
+func Sort(versions []*Version) {
+	sort.Sort(Versions(versions))
+}
diff --git a/vendor/modules.txt b/vendor/modules.txt
index 161ea4f37..78a9f180c 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -46,6 +46,8 @@ github.com/aws/aws-sdk-go/service/s3/s3iface
 github.com/aws/aws-sdk-go/service/s3/s3manager
 github.com/aws/aws-sdk-go/service/sts
 github.com/aws/aws-sdk-go/service/sts/stsiface
+# github.com/coreos/go-semver v0.3.0
+github.com/coreos/go-semver/semver
 # github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f
 github.com/coreos/go-systemd/activation
 # github.com/gobwas/glob v0.2.3


Test patch:
diff --git a/cmd/osbuild-dnf-json-tests/main.go b/cmd/osbuild-dnf-json-tests/main.go
new file mode 100644
index 000000000..4c56b1845
--- /dev/null
+++ b/cmd/osbuild-dnf-json-tests/main.go
@@ -0,0 +1,71 @@
+// This package contains tests related to dnf-json and rpmmd package.
+package main
+
+import (
+	"fmt"
+	"github.com/osbuild/osbuild-composer/internal/rpmmd"
+	"io/ioutil"
+	"log"
+	"os"
+	"os/exec"
+	"path"
+)
+
+func main() {
+	// Tests that the package wrapping dnf-json works as expected
+	dir, err := setUpTemporaryRepository()
+	defer func(dir string) {
+		err := tearDownTemporaryRepository(dir)
+		if err != nil {
+			log.Print("Warning: failed to clean up temporary repository.")
+		}
+	}(dir)
+	if err != nil {
+		log.Panic("Failed to set up temporary repository:", err)
+	}
+	TestFetchChecksum(false, dir)
+}
+
+func setUpTemporaryRepository() (string, error) {
+	dir, err := ioutil.TempDir("/tmp", "osbuild-composer-test-")
+	if err != nil {
+		return "", err
+	}
+	cmd := exec.Command("createrepo_c", path.Join(dir))
+	err = cmd.Start()
+	if err != nil {
+		return "", err
+	}
+	err = cmd.Wait()
+	if err != nil {
+		return "", err
+	}
+	return dir, nil
+}
+
+func tearDownTemporaryRepository(dir string) error {
+	return os.RemoveAll(dir)
+}
+
+func TestFetchChecksum(quiet bool, dir string) {
+	repoCfg := rpmmd.RepoConfig{
+		Id:        "repo",
+		Name:      "repo",
+		BaseURL:   fmt.Sprintf("file://%s", dir),
+		IgnoreSSL: true,
+	}
+	if !quiet {
+		log.Println("Running TestFetchChecksum on:", dir)
+	}
+	rpmMetadata := rpmmd.NewRPMMD()
+	_, c, err := rpmMetadata.FetchMetadata([]rpmmd.RepoConfig{repoCfg}, "platform:f31")
+	if err != nil {
+		log.Panic("Failed to fetch checksum:", err)
+	}
+	if c["repo"] == "" {
+		log.Panic("The checksum is empty")
+	}
+	if !quiet {
+		log.Println("TestFetchChecksum: SUCCESS")
+	}
+}
diff --git a/cmd/osbuild-tests/main.go b/cmd/osbuild-tests/main.go
index 8b1592623..8f2ef955b 100644
--- a/cmd/osbuild-tests/main.go
+++ b/cmd/osbuild-tests/main.go
@@ -60,7 +60,14 @@ func main() {
 	runComposerCLI(false, "status", "show")
 
 	// Full integration tests
+	testCompose("ami")
+	testCompose("ext4-filesystem")
+	testCompose("openstack")
+	testCompose("partitioned-disk")
 	testCompose("qcow2")
+	testCompose("tar")
+	testCompose("vhd")
+	testCompose("vmdk")
 }
 
 func testCompose(outputType string) {
diff --git a/internal/blueprint/blueprint_test.go b/internal/blueprint/blueprint_test.go
index 76b663448..19358ef7b 100644
--- a/internal/blueprint/blueprint_test.go
+++ b/internal/blueprint/blueprint_test.go
@@ -36,3 +36,53 @@ func TestDeepCopy(t *testing.T) {
 		t.Fatalf("Blueprint.DeepCopy failed, copy modified")
 	}
 }
+
+func TestBlueprintInitialize(t *testing.T) {
+	cases := []struct {
+		NewBlueprint  Blueprint
+		ExpectedError bool
+	}{
+		{Blueprint{Name: "bp-test-1", Description: "Empty version", Version: ""}, true},
+		{Blueprint{Name: "bp-test-2", Description: "Invalid version 1", Version: "0"}, true},
+		{Blueprint{Name: "bp-test-2", Description: "Invalid version 2", Version: "0.0"}, true},
+		{Blueprint{Name: "bp-test-3", Description: "Invalid version 3", Version: "0.0.0.0"}, true},
+		{Blueprint{Name: "bp-test-4", Description: "Invalid version 4", Version: "0.a.0"}, true},
+		{Blueprint{Name: "bp-test-5", Description: "Invalid version 5", Version: "foo"}, true},
+		{Blueprint{Name: "bp-test-7", Description: "Zero version", Version: "0.0.0"}, false},
+		{Blueprint{Name: "bp-test-8", Description: "X.Y.Z version", Version: "2.1.3"}, false},
+	}
+
+	for _, c := range cases {
+		bp := c.NewBlueprint
+		err := bp.Initialize()
+		if (err != nil) != c.ExpectedError {
+			t.Errorf("Initialize(%#v) returned an unexpected error: %s", c.NewBlueprint, err.Error())
+		}
+	}
+}
+
+func TestBumpVersion(t *testing.T) {
+	cases := []struct {
+		NewBlueprint    Blueprint
+		OldVersion      string
+		ExpectedVersion string
+	}{
+		{Blueprint{Name: "bp-test-1", Description: "Empty version", Version: "0.0.1"}, "", "0.0.1"},
+		{Blueprint{Name: "bp-test-2", Description: "Invalid version 1", Version: "0.0.1"}, "0", "0.0.1"},
+		{Blueprint{Name: "bp-test-3", Description: "Invalid version 2", Version: "0.0.1"}, "0.0.0.0", "0.0.1"},
+		{Blueprint{Name: "bp-test-4", Description: "Invalid version 3", Version: "0.0.1"}, "0.a.0", "0.0.1"},
+		{Blueprint{Name: "bp-test-5", Description: "Invalid version 4", Version: "0.0.1"}, "foo", "0.0.1"},
+		{Blueprint{Name: "bp-test-6", Description: "Invalid version 5", Version: "0.0.1"}, "0.0", "0.0.1"},
+		{Blueprint{Name: "bp-test-8", Description: "Same version", Version: "4.2.0"}, "4.2.0", "4.2.1"},
+	}
+
+	for _, c := range cases {
+		bp := c.NewBlueprint
+		bp.Initialize()
+
+		bp.BumpVersion(c.OldVersion)
+		if bp.Version != c.ExpectedVersion {
+			t.Errorf("BumpVersion(%#v) is expected to return %#v, but instead returned %#v", c.OldVersion, c.ExpectedVersion, bp.Version)
+		}
+	}
+}
diff --git a/internal/common/types_test.go b/internal/common/types_test.go
index 20a8b67e8..f7ad573ee 100644
--- a/internal/common/types_test.go
+++ b/internal/common/types_test.go
@@ -129,5 +129,21 @@ func TestJSONConversionsComposeRequest(t *testing.T) {
 			t.Error("Marshaled compose request is not the one expected")
 		}
 	}
+}
 
+func TestImageType_UnmarshalJSON(t *testing.T) {
+	dict := struct {
+		ImageTypes []ImageType `json:"image_types"`
+	}{}
+	input := `{"image_types":["qcow2", "Alibaba"]}`
+	err := json.Unmarshal([]byte(input), &dict)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if dict.ImageTypes[0] != Qcow2Generic {
+		t.Fatal("failed to umarshal image type qcow2; got tag:", dict.ImageTypes[0])
+	}
+	if dict.ImageTypes[1] != Alibaba {
+		t.Fatal("failed to umarshal image type Alibaba; got tag:", dict.ImageTypes[0])
+	}
 }
diff --git a/internal/rcm/api_test.go b/internal/rcm/api_test.go
index 57ac5f5bd..1e0009d00 100644
--- a/internal/rcm/api_test.go
+++ b/internal/rcm/api_test.go
@@ -3,14 +3,15 @@ package rcm_test
 import (
 	"bytes"
 	"encoding/json"
+	"github.com/google/uuid"
 	"net/http"
 	"net/http/httptest"
 	"regexp"
 	"testing"
 
-	"github.com/google/uuid"
 	"github.com/osbuild/osbuild-composer/internal/distro/fedoratest"
 	distro_mock "github.com/osbuild/osbuild-composer/internal/mocks/distro"
+	rpmmd_mock "github.com/osbuild/osbuild-composer/internal/mocks/rpmmd"
 	"github.com/osbuild/osbuild-composer/internal/rcm"
 	"github.com/osbuild/osbuild-composer/internal/store"
 )
@@ -43,14 +44,14 @@ func TestBasicRcmAPI(t *testing.T) {
 		{"GET", "/v1/compose", ``, "", http.StatusMethodNotAllowed, ``},
 		{"POST", "/v1/compose", `{"status":"RUNNING"}`, "application/json", http.StatusBadRequest, ``},
 		{"POST", "/v1/compose", `{"status":"RUNNING"}`, "text/plain", http.StatusBadRequest, ``},
-		{"POST", "/v1/compose", `{"distribution": "fedora-30", "image_types": ["qcow2"], "architectures":["x86_64"], "repositories": [{"url": "aaa"}]}`, "application/json", http.StatusOK, `{"compose_id":".*"}`},
+		{"POST", "/v1/compose", `{"distribution": "fedora-30", "image_types": ["qcow2"], "architectures":["x86_64"], "repositories": []}`, "application/json", http.StatusBadRequest, ""},
 		{"POST", "/v1/compose/111-222-333", `{"status":"RUNNING"}`, "application/json", http.StatusMethodNotAllowed, ``},
 		{"GET", "/v1/compose/7802c476-9cd1-41b7-ba81-43c1906bce73", `{"status":"RUNNING"}`, "application/json", http.StatusBadRequest, `{"error_reason":"Compose UUID does not exist"}`},
 	}
 
 	registry := distro_mock.NewRegistry()
 	distroStruct := fedoratest.New()
-	api := rcm.New(nil, store.New(nil, distroStruct, *registry))
+	api := rcm.New(nil, store.New(nil, distroStruct, *registry), rpmmd_mock.NewRPMMDMock(rpmmd_mock.BaseFixture()))
 
 	for _, c := range cases {
 		resp := internalRequest(api, c.Method, c.Path, c.Body, c.ContentType)
@@ -72,9 +73,9 @@ func TestBasicRcmAPI(t *testing.T) {
 
 func TestSubmitCompose(t *testing.T) {
 	// Test the most basic use case: Submit a new job and get its status.
-	registry := distro_mock.NewRegistry()
 	distroStruct := fedoratest.New()
-	api := rcm.New(nil, store.New(nil, distroStruct, *registry))
+	registry := distro_mock.NewRegistry()
+	api := rcm.New(nil, store.New(nil, distroStruct, *registry), rpmmd_mock.NewRPMMDMock(rpmmd_mock.BaseFixture()))
 
 	var submit_reply struct {
 		UUID uuid.UUID `json:"compose_id"`
@@ -93,7 +94,12 @@ func TestSubmitCompose(t *testing.T) {
 		{
 			"POST",
 			"/v1/compose",
-			`{"distribution": "fedora-30", "image_types": ["qcow2"], "architectures":["x86_64"], "repositories": [{"url": "aaa"}]}`,
+			`{"distribution": "fedora-30", 
+					"image_types": ["qcow2"], 
+					"architectures":["x86_64"], 
+					"repositories": [{
+						"url": "http://download.fedoraproject.org/pub/fedora/linux/releases/30/Everything/x86_64/os/"
+					}]}`,
 			"application/json",
 		},
 	}
@@ -101,7 +107,6 @@ func TestSubmitCompose(t *testing.T) {
 	for n, c := range cases {
 		// Submit job
 		t.Logf("RCM API submit compose, case %d\n", n)
-		//resp := internalRequest(api, "POST", "/v1/compose", `{"distribution": "fedora-30", "image_types": ["test_output"], "architectures":["test_arch"], "repositories": [{"url": "aaa", "checksum": "bbb"}]}`, "application/json")
 		resp := internalRequest(api, c.Method, c.Path, c.Body, c.ContentType)
 		if resp.StatusCode != http.StatusOK {
 			t.Fatal("Failed to call /v1/compose, HTTP status code:", resp.StatusCode)
diff --git a/internal/store/store_test.go b/internal/store/store_test.go
index c36efe743..c9e7da5bf 100644
--- a/internal/store/store_test.go
+++ b/internal/store/store_test.go
@@ -4,31 +4,6 @@ import (
 	"testing"
 )
 
-func TestBumpVersion(t *testing.T) {
-	cases := []struct {
-		Version  string
-		Expected string
-	}{
-		{"", ""},
-		{"0", "0.0.1"},
-		{"0.0", "0.0.1"},
-		{"0.0.0", "0.0.1"},
-		{"2.1.3", "2.1.4"},
-
-		// don't touch invalid version strings
-		{"0.0.0.0", "0.0.0.0"},
-		{"0.a.0", "0.a.0"},
-		{"foo", "foo"},
-	}
-
-	for _, c := range cases {
-		result := bumpVersion(c.Version)
-		if result != c.Expected {
-			t.Errorf("bumpVersion(%#v) is expected to return %#v, but instead returned %#v", c.Version, c.Expected, result)
-		}
-	}
-}
-
 func TestRandomSHA1String(t *testing.T) {
 	hash, err := randomSHA1String()
 	if err != nil {
diff --git a/test/cases/ami_empty_blueprint.json b/test/cases/ami_empty_blueprint.json
index 8ca11f267..e01a445a1 100644
--- a/test/cases/ami_empty_blueprint.json
+++ b/test/cases/ami_empty_blueprint.json
@@ -149,7 +149,6 @@
   },
   "image-info": {
     "boot-environment": {
-      "GRUB2_BOOT_FS_UUID": "76a22bf4-f153-4541-b6c7-0332c0dfaeac",
       "GRUB2_ROOT_FS_UUID": "76a22bf4-f153-4541-b6c7-0332c0dfaeac",
       "kernelopts": "root=UUID=76a22bf4-f153-4541-b6c7-0332c0dfaeac ro no_timer_check console=ttyS0,115200n8 console=tty1 biosdevname=0 net.ifnames=0 console=ttyS0,115200"
     },
diff --git a/test/cases/disk_empty_blueprint.json b/test/cases/disk_empty_blueprint.json
index 6014cca1b..79b3c8c28 100644
--- a/test/cases/disk_empty_blueprint.json
+++ b/test/cases/disk_empty_blueprint.json
@@ -134,7 +134,6 @@
   },
   "image-info": {
     "boot-environment": {
-      "GRUB2_BOOT_FS_UUID": "76a22bf4-f153-4541-b6c7-0332c0dfaeac",
       "GRUB2_ROOT_FS_UUID": "76a22bf4-f153-4541-b6c7-0332c0dfaeac",
       "kernelopts": "root=UUID=76a22bf4-f153-4541-b6c7-0332c0dfaeac ro biosdevname=0 net.ifnames=0"
     },
diff --git a/test/cases/ext4_empty_blueprint.json b/test/cases/ext4_empty_blueprint.json
index a120b987f..a72eaed80 100644
--- a/test/cases/ext4_empty_blueprint.json
+++ b/test/cases/ext4_empty_blueprint.json
@@ -105,7 +105,6 @@
   },
   "image-info": {
     "boot-environment": {
-      "GRUB2_BOOT_FS_UUID": "76a22bf4-f153-4541-b6c7-0332c0dfaeac",
       "GRUB2_ROOT_FS_UUID": "76a22bf4-f153-4541-b6c7-0332c0dfaeac",
       "kernelopts": "root=UUID=76a22bf4-f153-4541-b6c7-0332c0dfaeac ro biosdevname=0 net.ifnames=0"
     },
diff --git a/test/cases/openstack_empty_blueprint.json b/test/cases/openstack_empty_blueprint.json
index c3947bc46..c3f66834d 100644
--- a/test/cases/openstack_empty_blueprint.json
+++ b/test/cases/openstack_empty_blueprint.json
@@ -138,7 +138,6 @@
   },
   "image-info": {
     "boot-environment": {
-      "GRUB2_BOOT_FS_UUID": "76a22bf4-f153-4541-b6c7-0332c0dfaeac",
       "GRUB2_ROOT_FS_UUID": "76a22bf4-f153-4541-b6c7-0332c0dfaeac",
       "kernelopts": "root=UUID=76a22bf4-f153-4541-b6c7-0332c0dfaeac ro biosdevname=0 net.ifnames=0"
     },
diff --git a/test/cases/qcow2_empty_blueprint.json b/test/cases/qcow2_empty_blueprint.json
index 8c27fd5b6..7568da79f 100644
--- a/test/cases/qcow2_empty_blueprint.json
+++ b/test/cases/qcow2_empty_blueprint.json
@@ -139,7 +139,6 @@
   },
   "image-info": {
     "boot-environment": {
-      "GRUB2_BOOT_FS_UUID": "76a22bf4-f153-4541-b6c7-0332c0dfaeac",
       "GRUB2_ROOT_FS_UUID": "76a22bf4-f153-4541-b6c7-0332c0dfaeac",
       "kernelopts": "root=UUID=76a22bf4-f153-4541-b6c7-0332c0dfaeac ro biosdevname=0 net.ifnames=0"
     },
diff --git a/test/cases/vhd_empty_blueprint.json b/test/cases/vhd_empty_blueprint.json
index c0613e13f..1de5f8dd9 100644
--- a/test/cases/vhd_empty_blueprint.json
+++ b/test/cases/vhd_empty_blueprint.json
@@ -153,7 +153,6 @@
   },
   "image-info": {
     "boot-environment": {
-      "GRUB2_BOOT_FS_UUID": "76a22bf4-f153-4541-b6c7-0332c0dfaeac",
       "GRUB2_ROOT_FS_UUID": "76a22bf4-f153-4541-b6c7-0332c0dfaeac",
       "kernelopts": "root=UUID=76a22bf4-f153-4541-b6c7-0332c0dfaeac ro biosdevname=0 rootdelay=300 console=ttyS0 earlyprintk=ttyS0 net.ifnames=0"
     },
diff --git a/test/cases/vmdk_empty_blueprint.json b/test/cases/vmdk_empty_blueprint.json
index eb08689cc..33c79f9b4 100644
--- a/test/cases/vmdk_empty_blueprint.json
+++ b/test/cases/vmdk_empty_blueprint.json
@@ -135,7 +135,6 @@
   },
   "image-info": {
     "boot-environment": {
-      "GRUB2_BOOT_FS_UUID": "76a22bf4-f153-4541-b6c7-0332c0dfaeac",
       "GRUB2_ROOT_FS_UUID": "76a22bf4-f153-4541-b6c7-0332c0dfaeac",
       "kernelopts": "root=UUID=76a22bf4-f153-4541-b6c7-0332c0dfaeac ro biosdevname=0 net.ifnames=0"
     },