Repository: osbuild/osbuild-composer
Pull request #574 (2020-04-30 14:05:25)

test/dnf-json: fix cleaning-up the cache after the test is finished
t.Parallel() makes defer weird. This commit moves the temporary dir setup
and teardown to the parallel subtest, so the teardown gets called after
the tests are finished.

See the section "Run a group of tests in parallel":
https://blog.golang.org/subtests

larskarlitski: This is probably cleaner anyway, but I'm not understanding the problem. According to the link you posted, a test function will not return until all of its (parallel) subtests complete. It should work the way it was before as well, no?
ondrejbudai: @larskarlitski I rebased and force-pushed a new commit message explaining this rather interesting Go behaviour. A copy of the commit message:

Consider this code:

```go
t.Run("group", func(t *testing.T) {
    setUp()
    t.Run("parallel test1", parallelTest1)
    t.Run("parallel test2", parallelTest1)
    t.Run("sequential test", sequentialTest)
    cleanUp()
})
```

When the group subtest is started, firstly parallelTest1 is run. When
t.Parallel() in the parallelTest1 is hit, its execution is suspended and
parallelTest2 is started. Once again, t.Parallel() is hit and the
parallelTest2 is suspended. Now, sequentialTest is started. It doesn't
contain t.Parallel() so it's run without any interruptions. The group subtest
continues its execution and calls the cleanUp method. After the cleanup is
done, the group subtest returns and it's time for the important part:
The parallelTest1 and 2 are resumed and run in parallel. In other words,
the code after t.Parallel is run when the PARENT subtest returns.

The important (and weird) part is that the parent subtest of the parallel
subtests returns before its parallel children finish. However, just the inner
anonymous function returns. The containing t.Run("group", func(){...})
function does not return until all the parallel subtests are finished.

How to solve this? One solution is to move the cleanup out of the group
subtest:

```go
t.Run("group", func(t *testing.T) {
    setUp()
    t.Run("parallel test1", parallelTest1)
    t.Run("parallel test2", parallelTest1)
    t.Run("sequential test", sequentialTest)

})
cleanUp()
```

How does this work? The group subtest is not marked as parallel, so it just
runs from its beginning to its end (including the parallel subtests). After
it finishes, then the cleanUp is called.

In this commit I chose a different method: I just moved the clean up into each
parallel subtest. I think this is clearer than all that parallel magic I
tried explaining in previous paragraphs.

Patch:
diff --git a/NEWS.md b/NEWS.md
index 7321aafad..3eeb148b0 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -1,5 +1,33 @@
 # OSBuild Composer - Operating System Image Composition Services
 
+## CHANGES WITH 12:
+
+        * In previous versions support for running remote workers was
+         broken. This is now fixed and running remote workers is once
+         again possible. See #568 for more information.
+
+        * The job queue and the store are now two separate Go packages.
+         One of the benefits is that it is now possible to build images
+         without using the store which is too complicated for some usecases.
+         
+        * A blueprint name is now checked against the regex
+         "^[a-zA-Z0-9._-]+$". This is the same limitation as in
+         lorax-composer.
+         
+        * All osbuild calls now use the new --output-directory argument.
+         This change is a must because the old way of retrieving images from
+         the osbuild store will soon be deprecated.
+         
+        * Some routes from the weldr API are now implemented in a more
+         efficient way.
+         
+        * As always, the team worked hard on improving the tests and the CI.
+
+        Contributions from: David Rheinsberg, Jiri Kortus, Lars Karlitski,
+                            Major Hayden, Ondřej Budai
+
+        - Liberec, 2020-05-13
+
 ## CHANGES WITH 11:
 
         * The support for uploading VHD images to Azure is now available.
@@ -32,7 +60,6 @@
 
         - Liberec, 2020-04-29
 
-
 ## CHANGES WITH 10:
 
         * The correct metadata_expire value is now passed to dnf. In the
diff --git a/cmd/osbuild-composer/main.go b/cmd/osbuild-composer/main.go
index 8a7cbeb32..338049fc5 100644
--- a/cmd/osbuild-composer/main.go
+++ b/cmd/osbuild-composer/main.go
@@ -125,7 +125,7 @@ func main() {
 		log.Fatalf("cannot create queue directory: %v", err)
 	}
 
-	jobs, err := fsjobqueue.New(queueDir)
+	jobs, err := fsjobqueue.New(queueDir, []string{"osbuild"})
 	if err != nil {
 		log.Fatalf("cannot create jobqueue: %v", err)
 	}
diff --git a/cmd/osbuild-worker/main.go b/cmd/osbuild-worker/main.go
index ce1c856cf..d80cf65f7 100644
--- a/cmd/osbuild-worker/main.go
+++ b/cmd/osbuild-worker/main.go
@@ -64,14 +64,14 @@ func (e *TargetsError) Error() string {
 }
 
 func RunJob(job *worker.Job, uploadFunc func(uuid.UUID, int, io.Reader) error) (*common.ComposeResult, error) {
-	tmpStore, err := ioutil.TempDir("/var/tmp", "osbuild-store")
+	tmpOutput, err := ioutil.TempDir("/var/tmp", "osbuild-output-*")
 	if err != nil {
-		return nil, fmt.Errorf("error setting up osbuild store: %v", err)
+		return nil, fmt.Errorf("error setting up osbuild output directory: %v", err)
 	}
 	// FIXME: how to handle errors in defer?
-	defer os.RemoveAll(tmpStore)
+	defer os.RemoveAll(tmpOutput)
 
-	result, err := RunOSBuild(job.Manifest, tmpStore, os.Stderr)
+	result, err := RunOSBuild(job.Manifest, tmpOutput, os.Stderr)
 	if err != nil {
 		return nil, err
 	}
@@ -81,7 +81,7 @@ func RunJob(job *worker.Job, uploadFunc func(uuid.UUID, int, io.Reader) error) (
 	for _, t := range job.Targets {
 		switch options := t.Options.(type) {
 		case *target.LocalTargetOptions:
-			f, err := os.Open(path.Join(tmpStore, "refs", result.OutputID, options.Filename))
+			f, err := os.Open(path.Join(tmpOutput, options.Filename))
 			if err != nil {
 				r = append(r, err)
 				continue
@@ -105,7 +105,7 @@ func RunJob(job *worker.Job, uploadFunc func(uuid.UUID, int, io.Reader) error) (
 				options.Key = job.Id.String()
 			}
 
-			_, err = a.Upload(path.Join(tmpStore, "refs", result.OutputID, options.Filename), options.Bucket, options.Key)
+			_, err = a.Upload(path.Join(tmpOutput, options.Filename), options.Bucket, options.Key)
 			if err != nil {
 				r = append(r, err)
 				continue
@@ -132,7 +132,7 @@ func RunJob(job *worker.Job, uploadFunc func(uuid.UUID, int, io.Reader) error) (
 			err := azure.UploadImage(
 				credentials,
 				metadata,
-				path.Join(tmpStore, "refs", result.OutputID, options.Filename),
+				path.Join(tmpOutput, options.Filename),
 				azureMaxUploadGoroutines,
 			)
 
diff --git a/cmd/osbuild-worker/osbuild.go b/cmd/osbuild-worker/osbuild.go
index b8a32da21..2da5dd167 100644
--- a/cmd/osbuild-worker/osbuild.go
+++ b/cmd/osbuild-worker/osbuild.go
@@ -19,10 +19,10 @@ func (e *OSBuildError) Error() string {
 	return e.Message
 }
 
-func RunOSBuild(manifest *osbuild.Manifest, store string, errorWriter io.Writer) (*common.ComposeResult, error) {
+func RunOSBuild(manifest *osbuild.Manifest, outputDirectory string, errorWriter io.Writer) (*common.ComposeResult, error) {
 	cmd := exec.Command(
 		"osbuild",
-		"--store", store,
+		"--output-directory", outputDirectory,
 		"--json", "-",
 	)
 	cmd.Stderr = errorWriter
diff --git a/golang-github-osbuild-composer.spec b/golang-github-osbuild-composer.spec
index 23b7e7cd8..481281d20 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:        11
+Version:        12
 
 %gometa
 
diff --git a/internal/client/compose.go b/internal/client/compose.go
index b2ff9eaa1..0387b3731 100644
--- a/internal/client/compose.go
+++ b/internal/client/compose.go
@@ -6,7 +6,7 @@ import (
 	"encoding/json"
 	"io"
 	"net/http"
-	"strings"
+	"net/url"
 
 	"github.com/osbuild/osbuild-composer/internal/weldr"
 )
@@ -63,20 +63,20 @@ func GetFailedComposesV0(socket *http.Client) ([]weldr.ComposeEntryV0, *APIRespo
 func GetComposeStatusV0(socket *http.Client, uuids, blueprint, status, composeType string) ([]weldr.ComposeEntryV0, *APIResponse, error) {
 	// Build the query string
 	route := "/api/v0/compose/status/" + uuids
-	var filters []string
 
+	params := url.Values{}
 	if len(blueprint) > 0 {
-		filters = append(filters, "blueprint="+blueprint)
+		params.Add("blueprint", blueprint)
 	}
 	if len(status) > 0 {
-		filters = append(filters, "status="+status)
+		params.Add("status", status)
 	}
 	if len(composeType) > 0 {
-		filters = append(filters, "type="+composeType)
+		params.Add("type", composeType)
 	}
 
-	if len(filters) > 0 {
-		route = route + "?" + strings.Join(filters, "&")
+	if len(params) > 0 {
+		route = route + "?" + params.Encode()
 	}
 
 	body, resp, err := GetRaw(socket, "GET", route)
diff --git a/internal/compose/compose.go b/internal/compose/compose.go
index ac56a09fd..a6811534e 100644
--- a/internal/compose/compose.go
+++ b/internal/compose/compose.go
@@ -100,29 +100,3 @@ func (c *Compose) DeepCopy() Compose {
 		ImageBuilds: newImageBuilds,
 	}
 }
-
-// UpdateState changes a state of a single image build inside the Compose
-func (c *Compose) UpdateState(imageBuildId int, newState common.ImageBuildState) error {
-	switch newState {
-	case common.IBWaiting:
-		return &StateTransitionError{"image build cannot be moved into waiting state"}
-	case common.IBRunning:
-		if c.ImageBuilds[imageBuildId].QueueStatus == common.IBWaiting || c.ImageBuilds[imageBuildId].QueueStatus == common.IBRunning {
-			c.ImageBuilds[imageBuildId].QueueStatus = newState
-		} else {
-			return &StateTransitionError{"only waiting image build can be transitioned into running state"}
-		}
-	case common.IBFinished, common.IBFailed:
-		if c.ImageBuilds[imageBuildId].QueueStatus == common.IBRunning {
-			c.ImageBuilds[imageBuildId].QueueStatus = newState
-			for _, t := range c.ImageBuilds[imageBuildId].Targets {
-				t.Status = newState
-			}
-		} else {
-			return &StateTransitionError{"only running image build can be transitioned into finished or failed state"}
-		}
-	default:
-		return &StateTransitionError{"invalid state"}
-	}
-	return nil
-}
diff --git a/internal/jobqueue/fsjobqueue/fsjobqueue.go b/internal/jobqueue/fsjobqueue/fsjobqueue.go
index 76443b8cd..7a407d95d 100644
--- a/internal/jobqueue/fsjobqueue/fsjobqueue.go
+++ b/internal/jobqueue/fsjobqueue/fsjobqueue.go
@@ -3,7 +3,7 @@
 //
 // Jobs are stored in the file system, using the `jsondb` package. However,
 // this package does not use the file system as a database, but keeps some
-// state in memory. This means that access to a given directory myst be
+// state in memory. This means that access to a given directory must be
 // exclusive to only one `fsJobQueue` object at a time. A single `fsJobQueue`
 // can be safely accessed from multiple goroutines, though.
 //
@@ -14,6 +14,7 @@ package fsjobqueue
 import (
 	"context"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"reflect"
 	"sort"
@@ -26,19 +27,20 @@ import (
 )
 
 type fsJobQueue struct {
+	// Protects all fields of this struct. In particular, it ensures
+	// transactions on `db` are atomic. All public functions except
+	// JobStatus hold it while they're running. Dequeue() releases it
+	// briefly while waiting on pending channels.
+	mu sync.Mutex
+
 	db *jsondb.JSONDatabase
 
-	// Maps job types to channels of job ids for that type. Only access
-	// through pendingChannel() to ensure concurrent access is restricted
-	// by the mutex.
-	pending      map[string]chan uuid.UUID
-	pendingMutex sync.Mutex
+	// Maps job types to channels of job ids for that type.
+	pending map[string]chan uuid.UUID
 
 	// Maps job ids to the jobs that depend on it, if any of those
-	// dependants have not yet finished. Only acccess while holding the
-	// associated mutex.
-	dependants      map[uuid.UUID][]uuid.UUID
-	dependantsMutex sync.Mutex
+	// dependants have not yet finished.
+	dependants map[uuid.UUID][]uuid.UUID
 }
 
 // On-disk job struct. Contains all necessary (but non-redundant) information
@@ -51,22 +53,27 @@ type job struct {
 	Dependencies []uuid.UUID     `json:"dependencies"`
 	Result       json.RawMessage `json:"result,omitempty"`
 
-	Status     jobqueue.JobStatus `json:"status"`
-	QueuedAt   time.Time          `json:"queued-at,omitempty"`
-	StartedAt  time.Time          `json:"started-at,omitempty"`
-	FinishedAt time.Time          `json:"finished-at,omitempty"`
+	QueuedAt   time.Time `json:"queued_at,omitempty"`
+	StartedAt  time.Time `json:"started_at,omitempty"`
+	FinishedAt time.Time `json:"finished_at,omitempty"`
+
+	Canceled bool `json:"canceled,omitempty"`
 }
 
 // Create a new fsJobQueue object for `dir`. This object must have exclusive
 // access to `dir`. If `dir` contains jobs created from previous runs, they are
 // loaded and rescheduled to run if necessary.
-func New(dir string) (*fsJobQueue, error) {
+func New(dir string, acceptedJobTypes []string) (*fsJobQueue, error) {
 	q := &fsJobQueue{
 		db:         jsondb.New(dir, 0600),
 		pending:    make(map[string]chan uuid.UUID),
 		dependants: make(map[uuid.UUID][]uuid.UUID),
 	}
 
+	for _, jt := range acceptedJobTypes {
+		q.pending[jt] = make(chan uuid.UUID, 100)
+	}
+
 	// Look for jobs that are still pending and build the dependant map.
 	ids, err := q.db.List()
 	if err != nil {
@@ -81,36 +88,27 @@ func New(dir string) (*fsJobQueue, error) {
 		if err != nil {
 			return nil, err
 		}
-		// We only enqueue jobs that were previously pending.
-		if j.Status != jobqueue.JobPending {
-			continue
-		}
-		// Initialize dependants for this job.
-		q.dependantsMutex.Lock()
-		defer q.dependantsMutex.Unlock()
-		for _, dep := range j.Dependencies {
-			q.dependants[dep] = append(q.dependants[dep], j.Id)
-		}
-		// Enqueue a job if all its dependencies (if there are any)
-		// have finished, but the job itself hasn't run yet.
-		n, err := q.countFinishedJobs(j.Dependencies)
+		err = q.maybeEnqueue(j, true)
 		if err != nil {
 			return nil, err
 		}
-		if n == len(j.Dependencies) {
-			q.pendingChannel(j.Type) <- j.Id
-		}
 	}
 
 	return q, nil
 }
 
 func (q *fsJobQueue) Enqueue(jobType string, args interface{}, dependencies []uuid.UUID) (uuid.UUID, error) {
+	q.mu.Lock()
+	defer q.mu.Unlock()
+
+	if _, exists := q.pending[jobType]; !exists {
+		return uuid.Nil, fmt.Errorf("this queue does not accept job type '%s'", jobType)
+	}
+
 	var j = job{
 		Id:           uuid.New(),
 		Type:         jobType,
 		Dependencies: uniqueUUIDList(dependencies),
-		Status:       jobqueue.JobPending,
 		QueuedAt:     time.Now(),
 	}
 
@@ -120,10 +118,16 @@ func (q *fsJobQueue) Enqueue(jobType string, args interface{}, dependencies []uu
 		return uuid.Nil, fmt.Errorf("error marshaling job arguments: %v", err)
 	}
 
-	// Verify dependencies and check how many of them are already finished.
-	finished, err := q.countFinishedJobs(j.Dependencies)
-	if err != nil {
-		return uuid.Nil, err
+	// Verify dependendencies early, so that the job doesn't get written
+	// when one of them doesn't exist.
+	for _, d := range j.Dependencies {
+		exists, err := q.db.Read(d.String(), nil)
+		if err != nil {
+			return uuid.Nil, err
+		}
+		if !exists {
+			return uuid.Nil, jobqueue.ErrNotExist
+		}
 	}
 
 	// Write the job before updating in-memory state, so that the latter
@@ -133,85 +137,87 @@ func (q *fsJobQueue) Enqueue(jobType string, args interface{}, dependencies []uu
 		return uuid.Nil, fmt.Errorf("cannot write job: %v:", err)
 	}
 
-	// If all dependencies have finished, or there are none, queue the job.
-	// Otherwise, update dependants so that this check is done again when
-	// FinishJob() is called for a dependency.
-	if finished == len(j.Dependencies) {
-		q.pendingChannel(j.Type) <- j.Id
-	} else {
-		q.dependantsMutex.Lock()
-		defer q.dependantsMutex.Unlock()
-		for _, id := range j.Dependencies {
-			q.dependants[id] = append(q.dependants[id], j.Id)
-		}
+	err = q.maybeEnqueue(&j, true)
+	if err != nil {
+		return uuid.Nil, err
 	}
 
 	return j.Id, nil
 }
 
 func (q *fsJobQueue) Dequeue(ctx context.Context, jobTypes []string, args interface{}) (uuid.UUID, error) {
-	// Return early if the conext is already canceled.
+	q.mu.Lock()
+	defer q.mu.Unlock()
+
+	// Return early if the context is already canceled.
 	if err := ctx.Err(); err != nil {
 		return uuid.Nil, err
 	}
 
-	// Use reflect.Select(), because the `select` statement cannot operate
-	// on an unknown amount of channels.
-	cases := []reflect.SelectCase{
-		{
-			Dir:  reflect.SelectRecv,
-			Chan: reflect.ValueOf(ctx.Done()),
-		},
-	}
+	// Filter q.pending by the `jobTypes`. Ignore those job types that this
+	// queue doesn't accept.
+	chans := []chan uuid.UUID{}
 	for _, jt := range jobTypes {
-		cases = append(cases, reflect.SelectCase{
-			Dir:  reflect.SelectRecv,
-			Chan: reflect.ValueOf(q.pendingChannel(jt)),
-		})
+		if c, exists := q.pending[jt]; exists {
+			chans = append(chans, c)
+		}
 	}
 
-	chosen, value, recvOK := reflect.Select(cases)
-	if chosen == 0 && !recvOK {
-		return uuid.Nil, ctx.Err()
-	}
-	// pending channels are never closed
-	if !recvOK {
-		panic("pending channel was closed unexpectedly")
-	}
-	id := value.Interface().(uuid.UUID)
+	// Loop until finding a non-canceled job.
+	var j *job
+	for {
+		// Unlock the mutex while polling channels, so that multiple goroutines
+		// can wait at the same time.
+		q.mu.Unlock()
+		id, err := selectUUIDChannel(ctx, chans)
+		q.mu.Lock()
 
-	j, err := q.readJob(id)
-	if err != nil {
-		return uuid.Nil, err
+		if err != nil {
+			return uuid.Nil, err
+		}
+
+		j, err = q.readJob(id)
+		if err != nil {
+			return uuid.Nil, err
+		}
+
+		if !j.Canceled {
+			break
+		}
 	}
 
-	err = json.Unmarshal(j.Args, args)
+	err := json.Unmarshal(j.Args, args)
 	if err != nil {
 		return uuid.Nil, fmt.Errorf("error unmarshaling arguments for job '%s': %v", j.Id, err)
 	}
 
-	j.Status = jobqueue.JobRunning
 	j.StartedAt = time.Now()
 
-	err = q.db.Write(id.String(), j)
+	err = q.db.Write(j.Id.String(), j)
 	if err != nil {
-		return uuid.Nil, fmt.Errorf("error writing job %s: %v", id, err)
+		return uuid.Nil, fmt.Errorf("error writing job %s: %v", j.Id, err)
 	}
 
 	return j.Id, nil
 }
 
 func (q *fsJobQueue) FinishJob(id uuid.UUID, result interface{}) error {
+	q.mu.Lock()
+	defer q.mu.Unlock()
+
 	j, err := q.readJob(id)
 	if err != nil {
 		return err
 	}
 
-	if j.Status != jobqueue.JobRunning {
+	if j.Canceled {
+		return jobqueue.ErrCanceled
+	}
+
+	if j.StartedAt.IsZero() || !j.FinishedAt.IsZero() {
 		return jobqueue.ErrNotRunning
 	}
 
-	j.Status = jobqueue.JobFinished
 	j.FinishedAt = time.Now()
 
 	j.Result, err = json.Marshal(result)
@@ -225,35 +231,51 @@ func (q *fsJobQueue) FinishJob(id uuid.UUID, result interface{}) error {
 		return fmt.Errorf("error writing job %s: %v", id, err)
 	}
 
-	q.dependantsMutex.Lock()
-	defer q.dependantsMutex.Unlock()
 	for _, depid := range q.dependants[id] {
 		dep, err := q.readJob(depid)
 		if err != nil {
 			return err
 		}
-		n, err := q.countFinishedJobs(dep.Dependencies)
+		err = q.maybeEnqueue(dep, false)
 		if err != nil {
 			return err
 		}
-		if n == len(dep.Dependencies) {
-			q.pendingChannel(dep.Type) <- dep.Id
-		}
 	}
 	delete(q.dependants, id)
 
 	return nil
 }
 
-func (q *fsJobQueue) JobStatus(id uuid.UUID, result interface{}) (status jobqueue.JobStatus, queued, started, finished time.Time, err error) {
-	var j *job
+func (q *fsJobQueue) CancelJob(id uuid.UUID) error {
+	q.mu.Lock()
+	defer q.mu.Unlock()
+
+	j, err := q.readJob(id)
+	if err != nil {
+		return err
+	}
+
+	if !j.FinishedAt.IsZero() {
+		return nil
+	}
+
+	j.Canceled = true
+
+	err = q.db.Write(id.String(), j)
+	if err != nil {
+		return fmt.Errorf("error writing job %s: %v", id, err)
+	}
 
-	j, err = q.readJob(id)
+	return nil
+}
+
+func (q *fsJobQueue) JobStatus(id uuid.UUID, result interface{}) (queued, started, finished time.Time, canceled bool, err error) {
+	j, err := q.readJob(id)
 	if err != nil {
 		return
 	}
 
-	if j.Status == jobqueue.JobFinished {
+	if !j.FinishedAt.IsZero() && !j.Canceled {
 		err = json.Unmarshal(j.Result, result)
 		if err != nil {
 			err = fmt.Errorf("error unmarshaling result for job '%s': %v", id, err)
@@ -261,30 +283,14 @@ func (q *fsJobQueue) JobStatus(id uuid.UUID, result interface{}) (status jobqueu
 		}
 	}
 
-	status = j.Status
 	queued = j.QueuedAt
 	started = j.StartedAt
 	finished = j.FinishedAt
+	canceled = j.Canceled
 
 	return
 }
 
-// Returns the number of finished jobs in `ids`.
-func (q *fsJobQueue) countFinishedJobs(ids []uuid.UUID) (int, error) {
-	n := 0
-	for _, id := range ids {
-		j, err := q.readJob(id)
-		if err != nil {
-			return 0, err
-		}
-		if j.Status == jobqueue.JobFinished {
-			n += 1
-		}
-	}
-
-	return n, nil
-}
-
 // Reads job with `id`. This is a thin wrapper around `q.db.Read`, which
 // returns the job directly, or and error if a job with `id` does not exist.
 func (q *fsJobQueue) readJob(id uuid.UUID) (*job, error) {
@@ -300,19 +306,39 @@ func (q *fsJobQueue) readJob(id uuid.UUID) (*job, error) {
 	return &j, nil
 }
 
-// Safe access to the pending channel for `jobType`. Channels are created on
-// demand.
-func (q *fsJobQueue) pendingChannel(jobType string) chan uuid.UUID {
-	q.pendingMutex.Lock()
-	defer q.pendingMutex.Unlock()
+// Enqueue `job` if it is pending and all its dependencies have finished.
+// Update `q.dependants` if the job was not queued and updateDependants is true
+// (i.e., when this is a new job).
+func (q *fsJobQueue) maybeEnqueue(j *job, updateDependants bool) error {
+	if !j.StartedAt.IsZero() {
+		return nil
+	}
 
-	c, exists := q.pending[jobType]
-	if !exists {
-		c = make(chan uuid.UUID, 100)
-		q.pending[jobType] = c
+	depsFinished := true
+	for _, id := range j.Dependencies {
+		j, err := q.readJob(id)
+		if err != nil {
+			return err
+		}
+		if j.FinishedAt.IsZero() {
+			depsFinished = false
+			break
+		}
+	}
+
+	if depsFinished {
+		c, exists := q.pending[j.Type]
+		if !exists {
+			return fmt.Errorf("this queue doesn't accept job type '%s'", j.Type)
+		}
+		c <- j.Id
+	} else if updateDependants {
+		for _, id := range j.Dependencies {
+			q.dependants[id] = append(q.dependants[id], j.Id)
+		}
 	}
 
-	return c
+	return nil
 }
 
 // Sorts and removes duplicates from `ids`.
@@ -338,3 +364,34 @@ func uniqueUUIDList(ids []uuid.UUID) []uuid.UUID {
 
 	return l
 }
+
+// Select on a list of `chan uuid.UUID`s. Returns an error if one of the
+// channels is closed.
+//
+// Uses reflect.Select(), because the `select` statement cannot operate on an
+// unknown amount of channels.
+func selectUUIDChannel(ctx context.Context, chans []chan uuid.UUID) (uuid.UUID, error) {
+	cases := []reflect.SelectCase{
+		{
+			Dir:  reflect.SelectRecv,
+			Chan: reflect.ValueOf(ctx.Done()),
+		},
+	}
+	for _, c := range chans {
+		cases = append(cases, reflect.SelectCase{
+			Dir:  reflect.SelectRecv,
+			Chan: reflect.ValueOf(c),
+		})
+	}
+
+	chosen, value, recvOK := reflect.Select(cases)
+	if !recvOK {
+		if chosen == 0 {
+			return uuid.Nil, ctx.Err()
+		} else {
+			return uuid.Nil, errors.New("channel was closed unexpectedly")
+		}
+	}
+
+	return value.Interface().(uuid.UUID), nil
+}
diff --git a/internal/jobqueue/jobqueue.go b/internal/jobqueue/jobqueue.go
index a33a32c1a..6dbd96b0e 100644
--- a/internal/jobqueue/jobqueue.go
+++ b/internal/jobqueue/jobqueue.go
@@ -14,7 +14,6 @@ package jobqueue
 
 import (
 	"context"
-	"encoding/json"
 	"errors"
 	"time"
 
@@ -49,57 +48,20 @@ type JobQueue interface {
 	// job type and must be serializable to JSON.
 	FinishJob(id uuid.UUID, result interface{}) error
 
-	// Returns the current status of the job. If the job has already
-	// finished, its result will be returned in `result`. Also returns the
-	// time the job was
-	//    queued   - always valid
-	//    started  - valid when the job is running or has finished
-	//    finished - valid when the job has finished
-	JobStatus(id uuid.UUID, result interface{}) (status JobStatus, queued, started, finished time.Time, err error)
-}
-
-type JobStatus int
+	// Cancel a job. Does nothing if the job has already finished.
+	CancelJob(id uuid.UUID) error
 
-const (
-	JobPending JobStatus = iota
-	JobRunning
-	JobFinished
-)
-
-func (s JobStatus) String() string {
-	switch s {
-	case JobPending:
-		return "pending"
-	case JobRunning:
-		return "running"
-	case JobFinished:
-		return "finished"
-	default:
-		return "<invalid>"
-	}
-}
-
-func (s JobStatus) MarshalJSON() ([]byte, error) {
-	return json.Marshal(s.String())
-}
-
-func (s *JobStatus) UnmarshalJSON(data []byte) error {
-	var str string
-	if err := json.Unmarshal(data, &str); err != nil {
-		return err
-	}
-	switch str {
-	case "pending":
-		*s = JobPending
-	case "running":
-		*s = JobRunning
-	case "finished":
-		*s = JobFinished
-	}
-	return nil
+	// Returns the current status of the job, in the form of three times:
+	// queued, started, and finished. `started` and `finished` might be the
+	// zero time (check with t.IsZero()), when the job is not running or
+	// finished, respectively.
+	//
+	// If the job is finished, its result will be returned in `result`.
+	JobStatus(id uuid.UUID, result interface{}) (queued, started, finished time.Time, canceled bool, err error)
 }
 
 var (
 	ErrNotExist   = errors.New("job does not exist")
 	ErrNotRunning = errors.New("job is not running")
+	ErrCanceled   = errors.New("job ws canceled")
 )
diff --git a/internal/jsondb/db.go b/internal/jsondb/db.go
index 6551f4ed1..10cef85c1 100644
--- a/internal/jsondb/db.go
+++ b/internal/jsondb/db.go
@@ -36,7 +36,8 @@ func New(dir string, perm os.FileMode) *JSONDatabase {
 	return &JSONDatabase{dir, perm}
 }
 
-// Reads the value at `name`. `document` must deserializable from JSON. Returns
+// Reads the value at `name`. `document` must be a type that is deserializable
+// from the JSON document `name`, or nil to not deserialize at all. Returns
 // false if a document with `name` does not exist.
 func (db *JSONDatabase) Read(name string, document interface{}) (bool, error) {
 	f, err := os.Open(path.Join(db.dir, name+".json"))
@@ -48,9 +49,11 @@ func (db *JSONDatabase) Read(name string, document interface{}) (bool, error) {
 	}
 	defer f.Close()
 
-	err = json.NewDecoder(f).Decode(&document)
-	if err != nil {
-		return false, fmt.Errorf("error reading db file %s: %v", name, err)
+	if document != nil {
+		err = json.NewDecoder(f).Decode(&document)
+		if err != nil {
+			return false, fmt.Errorf("error reading db file %s: %v", name, err)
+		}
 	}
 
 	return true, nil
diff --git a/internal/store/store.go b/internal/store/store.go
index a10e1aa79..03f07a538 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -76,22 +76,6 @@ func (e *NotFoundError) Error() string {
 	return e.message
 }
 
-type NotPendingError struct {
-	message string
-}
-
-func (e *NotPendingError) Error() string {
-	return e.message
-}
-
-type InvalidRequestError struct {
-	message string
-}
-
-func (e *InvalidRequestError) Error() string {
-	return e.message
-}
-
 type NoLocalTargetError struct {
 	message string
 }
@@ -554,6 +538,22 @@ func (s *Store) PushTestCompose(composeID uuid.UUID, manifest *osbuild.Manifest,
 		if err != nil {
 			return fmt.Errorf("cannot create output directory for job %v: %#v", composeID, err)
 		}
+
+		f, err := os.Create(s.getImageBuildDirectory(composeID, 0) + "/result.json")
+		if err != nil {
+			return fmt.Errorf("cannot open result.json for job %v: %#v", composeID, err)
+		}
+		err = json.NewEncoder(f).Encode(common.ComposeResult{Success: testSuccess})
+		if err != nil {
+			return fmt.Errorf("cannot write to result.json for job %v: %#v", composeID, err)
+		}
+	}
+
+	var status common.ImageBuildState
+	if testSuccess {
+		status = common.IBFinished
+	} else {
+		status = common.IBFailed
 	}
 
 	// FIXME: handle or comment this possible error
@@ -562,12 +562,13 @@ func (s *Store) PushTestCompose(composeID uuid.UUID, manifest *osbuild.Manifest,
 			Blueprint: bp,
 			ImageBuilds: []compose.ImageBuild{
 				{
-					QueueStatus: common.IBRunning,
+					QueueStatus: status,
 					Manifest:    manifest,
 					ImageType:   imageTypeCommon,
 					Targets:     targets,
 					JobCreated:  time.Now(),
 					JobStarted:  time.Now(),
+					JobFinished: time.Now(),
 					Size:        size,
 				},
 			},
@@ -575,22 +576,6 @@ func (s *Store) PushTestCompose(composeID uuid.UUID, manifest *osbuild.Manifest,
 		return nil
 	})
 
-	var status common.ImageBuildState
-	var result common.ComposeResult
-	if testSuccess {
-		status = common.IBFinished
-		result = common.ComposeResult{Success: true}
-	} else {
-		status = common.IBFailed
-		result = common.ComposeResult{}
-	}
-
-	// Instead of starting the job, immediately set a final status
-	err := s.UpdateImageBuildInCompose(composeID, 0, status, &result)
-	if err != nil {
-		return err
-	}
-
 	return nil
 }
 
@@ -616,49 +601,6 @@ func (s *Store) DeleteCompose(id uuid.UUID) error {
 	})
 }
 
-// UpdateImageBuildInCompose sets the status and optionally also the final image.
-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]
-		if !exists {
-			return &NotFoundError{"compose does not exist"}
-		}
-		// Check that the image build was waiting
-		if currentCompose.ImageBuilds[imageBuildID].QueueStatus == common.IBWaiting {
-			return &NotPendingError{"compose has not been popped"}
-		}
-
-		// write result into file
-		if s.stateDir != nil && result != nil {
-			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)
-			}
-
-			// FIXME: handle error
-			_ = json.NewEncoder(f).Encode(result)
-		}
-
-		// Update the image build state including all target states
-		err := currentCompose.UpdateState(imageBuildID, status)
-		if err != nil {
-			// TODO: log error
-			return &InvalidRequestError{"invalid state transition: " + err.Error()}
-		}
-
-		// 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()
-		}
-
-		s.Composes[composeID] = currentCompose
-
-		return nil
-	})
-}
-
 func (s *Store) AddImageToImageUpload(composeID uuid.UUID, imageBuildID int, reader io.Reader) error {
 	currentCompose, exists := s.Composes[composeID]
 	if !exists {
diff --git a/internal/weldr/api.go b/internal/weldr/api.go
index f080493f4..0ae09569c 100644
--- a/internal/weldr/api.go
+++ b/internal/weldr/api.go
@@ -11,6 +11,7 @@ import (
 	"net"
 	"net/http"
 	"net/url"
+	"regexp"
 	"sort"
 	"strconv"
 	"strings"
@@ -43,6 +44,8 @@ type API struct {
 	router *httprouter.Router
 }
 
+var ValidBlueprintName = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
+
 func New(rpmmd rpmmd.RPMMD, arch distro.Arch, distro distro.Distro, repos []rpmmd.RepoConfig, logger *log.Logger, store *store.Store, workers *worker.Server) *API {
 	api := &API{
 		store:   store,
@@ -232,6 +235,24 @@ type responseError struct {
 	Msg  string `json:"msg"`
 }
 
+// verifyStringsWithRegex checks a slive of strings against a regex of allowed characters
+// it writes the InvalidChars error to the writer and returns false if any of them fail the check
+// It will also return an error if the string is empty
+func verifyStringsWithRegex(writer http.ResponseWriter, strings []string, re *regexp.Regexp) bool {
+	for _, s := range strings {
+		if len(s) > 0 && re.MatchString(s) {
+			continue
+		}
+		errors := responseError{
+			ID:  "InvalidChars",
+			Msg: "Invalid characters in API path",
+		}
+		statusResponseError(writer, http.StatusBadRequest, errors)
+		return false
+	}
+	return true
+}
+
 func statusResponseError(writer http.ResponseWriter, code int, errors ...responseError) {
 	type reply struct {
 		Status bool            `json:"status"`
@@ -807,6 +828,13 @@ func (api *API) blueprintsInfoHandler(writer http.ResponseWriter, request *http.
 		return
 	}
 
+	// Remove the leading / from the first entry (check above ensures it is not just a /
+	names[0] = names[0][1:]
+
+	if !verifyStringsWithRegex(writer, names, ValidBlueprintName) {
+		return
+	}
+
 	query, err := url.ParseQuery(request.URL.RawQuery)
 	if err != nil {
 		errors := responseError{
@@ -821,12 +849,7 @@ func (api *API) blueprintsInfoHandler(writer http.ResponseWriter, request *http.
 	changes := []change{}
 	blueprintErrors := []responseError{}
 
-	for i, name := range names {
-		// remove leading / from first name
-		if i == 0 {
-			name = name[1:]
-		}
-
+	for _, name := range names {
 		blueprint, changed := api.store.GetBlueprint(name)
 		if blueprint == nil {
 			blueprintErrors = append(blueprintErrors, responseError{
@@ -902,13 +925,16 @@ func (api *API) blueprintsDepsolveHandler(writer http.ResponseWriter, request *h
 		return
 	}
 
+	// Remove the leading / from the first entry (check above ensures it is not just a /
+	names[0] = names[0][1:]
+
+	if !verifyStringsWithRegex(writer, names, ValidBlueprintName) {
+		return
+	}
+
 	blueprints := []entry{}
 	blueprintsErrors := []responseError{}
-	for i, name := range names {
-		// remove leading / from first name
-		if i == 0 {
-			name = name[1:]
-		}
+	for _, name := range names {
 		blueprint, _ := api.store.GetBlueprint(name)
 		if blueprint == nil {
 			blueprintsErrors = append(blueprintsErrors, responseError{
@@ -987,13 +1013,16 @@ func (api *API) blueprintsFreezeHandler(writer http.ResponseWriter, request *htt
 		return
 	}
 
+	// Remove the leading / from the first entry (check above ensures it is not just a /
+	names[0] = names[0][1:]
+
+	if !verifyStringsWithRegex(writer, names, ValidBlueprintName) {
+		return
+	}
+
 	blueprints := []blueprintFrozen{}
 	errors := []responseError{}
-	for i, name := range names {
-		// remove leading / from first name
-		if i == 0 {
-			name = name[1:]
-		}
+	for _, name := range names {
 		bp, _ := api.store.GetBlueprint(name)
 		if bp == nil {
 			rerr := responseError{
@@ -1076,8 +1105,19 @@ func (api *API) blueprintsDiffHandler(writer http.ResponseWriter, request *http.
 	}
 
 	name := params.ByName("blueprint")
+	if !verifyStringsWithRegex(writer, []string{name}, ValidBlueprintName) {
+		return
+	}
+
 	fromCommit := params.ByName("from")
+	if !verifyStringsWithRegex(writer, []string{fromCommit}, ValidBlueprintName) {
+		return
+	}
+
 	toCommit := params.ByName("to")
+	if !verifyStringsWithRegex(writer, []string{toCommit}, ValidBlueprintName) {
+		return
+	}
 
 	if len(name) == 0 || len(fromCommit) == 0 || len(toCommit) == 0 {
 		errors := responseError{
@@ -1178,6 +1218,13 @@ func (api *API) blueprintsChangesHandler(writer http.ResponseWriter, request *ht
 		return
 	}
 
+	// Remove the leading / from the first entry (check above ensures it is not just a /
+	names[0] = names[0][1:]
+
+	if !verifyStringsWithRegex(writer, names, ValidBlueprintName) {
+		return
+	}
+
 	offset, limit, err := parseOffsetAndLimit(request.URL.Query())
 	if err != nil {
 		errors := responseError{
@@ -1190,11 +1237,7 @@ func (api *API) blueprintsChangesHandler(writer http.ResponseWriter, request *ht
 
 	allChanges := []change{}
 	errors := []responseError{}
-	for i, name := range names {
-		// remove leading / from first name
-		if i == 0 {
-			name = name[1:]
-		}
+	for _, name := range names {
 		bpChanges := api.store.GetBlueprintChanges(name)
 		// Reverse the changes, newest first
 		reversed := make([]blueprint.Change, 0, len(bpChanges))
@@ -1269,6 +1312,10 @@ func (api *API) blueprintsNewHandler(writer http.ResponseWriter, request *http.R
 		return
 	}
 
+	if !verifyStringsWithRegex(writer, []string{blueprint.Name}, ValidBlueprintName) {
+		return
+	}
+
 	commitMsg := "Recipe " + blueprint.Name + ", version " + blueprint.Version + " saved."
 	err = api.store.PushBlueprint(blueprint, commitMsg)
 	if err != nil {
@@ -1326,6 +1373,10 @@ func (api *API) blueprintsWorkspaceHandler(writer http.ResponseWriter, request *
 		return
 	}
 
+	if !verifyStringsWithRegex(writer, []string{blueprint.Name}, ValidBlueprintName) {
+		return
+	}
+
 	err = api.store.PushBlueprintToWorkspace(blueprint)
 	if err != nil {
 		errors := responseError{
@@ -1345,7 +1396,15 @@ func (api *API) blueprintUndoHandler(writer http.ResponseWriter, request *http.R
 	}
 
 	name := params.ByName("blueprint")
+	if !verifyStringsWithRegex(writer, []string{name}, ValidBlueprintName) {
+		return
+	}
+
 	commit := params.ByName("commit")
+	if !verifyStringsWithRegex(writer, []string{commit}, ValidBlueprintName) {
+		return
+	}
+
 	bpChange, err := api.store.GetBlueprintChange(name, commit)
 	if err != nil {
 		errors := responseError{
@@ -1375,7 +1434,12 @@ func (api *API) blueprintDeleteHandler(writer http.ResponseWriter, request *http
 		return
 	}
 
-	if err := api.store.DeleteBlueprint(params.ByName("blueprint")); err != nil {
+	name := params.ByName("blueprint")
+	if !verifyStringsWithRegex(writer, []string{name}, ValidBlueprintName) {
+		return
+	}
+
+	if err := api.store.DeleteBlueprint(name); err != nil {
 		errors := responseError{
 			ID:  "BlueprintsError",
 			Msg: err.Error(),
@@ -1391,7 +1455,12 @@ func (api *API) blueprintDeleteWorkspaceHandler(writer http.ResponseWriter, requ
 		return
 	}
 
-	if err := api.store.DeleteBlueprintFromWorkspace(params.ByName("blueprint")); err != nil {
+	name := params.ByName("blueprint")
+	if !verifyStringsWithRegex(writer, []string{name}, ValidBlueprintName) {
+		return
+	}
+
+	if err := api.store.DeleteBlueprintFromWorkspace(name); err != nil {
 		errors := responseError{
 			ID:  "BlueprintsError",
 			Msg: err.Error(),
@@ -1408,7 +1477,12 @@ func (api *API) blueprintsTagHandler(writer http.ResponseWriter, request *http.R
 		return
 	}
 
-	err := api.store.TagBlueprint(params.ByName("blueprint"))
+	name := params.ByName("blueprint")
+	if !verifyStringsWithRegex(writer, []string{name}, ValidBlueprintName) {
+		return
+	}
+
+	err := api.store.TagBlueprint(name)
 	if err != nil {
 		errors := responseError{
 			ID:  "BlueprintsError",
@@ -1472,6 +1546,10 @@ func (api *API) composeHandler(writer http.ResponseWriter, request *http.Request
 		return
 	}
 
+	if !verifyStringsWithRegex(writer, []string{cr.BlueprintName}, ValidBlueprintName) {
+		return
+	}
+
 	composeID := uuid.New()
 
 	var targets []*target.Target
@@ -1727,6 +1805,10 @@ func (api *API) composeStatusHandler(writer http.ResponseWriter, request *http.R
 	}
 
 	filterBlueprint := q.Get("blueprint")
+	if len(filterBlueprint) > 0 && !verifyStringsWithRegex(writer, []string{filterBlueprint}, ValidBlueprintName) {
+		return
+	}
+
 	filterStatus := q.Get("status")
 	filterImageType, filterImageTypeExists := common.ImageTypeFromCompatString(q.Get("type"))
 
diff --git a/internal/worker/server.go b/internal/worker/server.go
index b13345c8e..768120c14 100644
--- a/internal/worker/server.go
+++ b/internal/worker/server.go
@@ -79,26 +79,54 @@ func (s *Server) Enqueue(manifest *osbuild.Manifest, targets []*target.Target) (
 }
 
 func (s *Server) JobStatus(id uuid.UUID) (state common.ComposeState, queued, started, finished time.Time, err error) {
+	var canceled bool
 	var result OSBuildJobResult
-	var status jobqueue.JobStatus
 
-	status, queued, started, finished, err = s.jobs.JobStatus(id, &result)
+	queued, started, finished, canceled, err = s.jobs.JobStatus(id, &result)
 	if err != nil {
 		return
 	}
 
-	state = composeStateFromJobStatus(status, result.OSBuildOutput)
+	if canceled {
+		state = common.CFailed
+	} else if !finished.IsZero() {
+		if result.OSBuildOutput.Success {
+			state = common.CFinished
+		} else {
+			state = common.CFailed
+		}
+	} else if !started.IsZero() {
+		state = common.CRunning
+	} else {
+		state = common.CWaiting
+	}
+
 	return
 }
 
 func (s *Server) JobResult(id uuid.UUID) (common.ComposeState, *common.ComposeResult, error) {
+	var canceled bool
 	var result OSBuildJobResult
-	status, _, _, _, err := s.jobs.JobStatus(id, &result)
+
+	_, started, finished, canceled, err := s.jobs.JobStatus(id, &result)
 	if err != nil {
 		return common.CWaiting, nil, err
 	}
 
-	return composeStateFromJobStatus(status, result.OSBuildOutput), result.OSBuildOutput, nil
+	state := common.CWaiting
+	if canceled {
+		state = common.CFailed
+	} else if !finished.IsZero() {
+		if result.OSBuildOutput.Success {
+			state = common.CFinished
+		} else {
+			state = common.CFailed
+		}
+	} else if !started.IsZero() {
+		state = common.CRunning
+	}
+
+	return state, result.OSBuildOutput, nil
 }
 
 // jsonErrorf() is similar to http.Error(), but returns the message in a json
@@ -216,19 +244,3 @@ func (s *Server) addJobImageHandler(writer http.ResponseWriter, request *http.Re
 		jsonErrorf(writer, http.StatusInternalServerError, "%v", err)
 	}
 }
-
-func composeStateFromJobStatus(status jobqueue.JobStatus, output *common.ComposeResult) common.ComposeState {
-	switch status {
-	case jobqueue.JobPending:
-		return common.CWaiting
-	case jobqueue.JobRunning:
-		return common.CRunning
-	case jobqueue.JobFinished:
-		if output.Success {
-			return common.CFinished
-		} else {
-			return common.CFailed
-		}
-	}
-	return common.CWaiting
-}
diff --git a/jenkins/Jenkinsfile b/jenkins/Jenkinsfile
deleted file mode 100644
index c2ea9923a..000000000
--- a/jenkins/Jenkinsfile
+++ /dev/null
@@ -1,84 +0,0 @@
-pipeline {
-    agent none
-
-    options {
-        timestamps()
-        ansiColor('xterm')
-    }
-
-    stages {
-        stage("Functional Testing") {
-            parallel {
-                stage('Fedora 31 base') {
-                    agent {
-                        label "fedora31"
-                    }
-                    environment {
-                        TEST_TYPE = "base"
-                    }
-                    steps {
-                        sh "jenkins/run_tests.sh"
-                    }
-                }
-                stage('Fedora 31 image') {
-                    agent {
-                        label "fedora31"
-                    }
-                    environment {
-                        TEST_TYPE = "image"
-                    }
-                    steps {
-                        sh "jenkins/run_tests.sh"
-                    }
-                }
-                stage('Fedora 32 base') {
-                    agent {
-                        label "fedora32"
-                    }
-                    environment {
-                        TEST_TYPE = "base"
-                    }
-                    steps {
-                        sh "jenkins/run_tests.sh"
-                    }
-                }
-                stage('Fedora 32 image') {
-                    agent {
-                        label "fedora32"
-                    }
-                    environment {
-                        TEST_TYPE = "image"
-                    }
-                    steps {
-                        sh "jenkins/run_tests.sh"
-                    }
-                }
-                //stage('RHEL 8.2 base') {
-                //    agent {
-                //        label "rhel82"
-                //    }
-                //    environment {
-                //        TEST_TYPE = "base"
-                //    }
-                //    steps {
-                //        sh "jenkins/run_tests.sh"
-                //    }
-                //}
-                // Disable RHEL 8.3 image testing for now until we work out the
-                // QEMU issues there.
-                //
-                // stage('RHEL 8.2 image') {
-                //     agent {
-                //         label "rhel82"
-                //     }
-                //     environment {
-                //         TEST_TYPE = "image"
-                //     }
-                //     steps {
-                //         sh "jenkins/run_tests.sh"
-                //     }
-                // }
-            }
-        }
-    }
-}
diff --git a/jenkins/vars.yml b/jenkins/vars.yml
deleted file mode 100644
index 2c225cae5..000000000
--- a/jenkins/vars.yml
+++ /dev/null
@@ -1,41 +0,0 @@
-## Global test variables
-# Tests should use this as their working directory. This part is critical
-# since dnf-json must be in $PATH.
-tests_working_directory: /usr/libexec/osbuild-composer
-
-# The test executables are here.
-tests_path: /usr/libexec/tests/osbuild-composer
-
-# Frequency to check for completed tests.
-polling_interval: 15
-
-## Non-image test variables.
-# List of base tests and their timeouts (in minutes)
-osbuild_composer_base_tests:
-  - name: osbuild-rcm-tests
-    timeout: 5
-  - name: osbuild-weldr-tests
-    timeout: 5
-  - name: osbuild-dnf-json-tests
-    timeout: 15
-  - name: osbuild-tests
-    timeout: 30
-
-## Image test variables.
-# Executable that runs image tests.
-image_test_executable: "{{ tests_path }}/osbuild-image-tests"
-
-# Timeout for image tests (in minutes).
-image_test_timeout: 45
-
-# Location of image test case files.
-image_test_case_path: /usr/share/tests/osbuild-composer/cases
-
-# List of image tests and their timeouts (in minutes).
-osbuild_composer_image_test_cases:
-  - ext4_filesystem-boot.json
-  - partitioned_disk-boot.json
-  - qcow2-boot.json
-  - tar-boot.json
-  - vhd-boot.json
-  - vmdk-boot.json
diff --git a/osbuild-composer.spec b/osbuild-composer.spec
index e6bab229c..53c67d63a 100644
--- a/osbuild-composer.spec
+++ b/osbuild-composer.spec
@@ -4,7 +4,7 @@
 
 %global goipath         github.com/osbuild/osbuild-composer
 
-Version:        11
+Version:        12
 
 %gometa
 


Test patch:
diff --git a/cmd/osbuild-dnf-json-tests/main_test.go b/cmd/osbuild-dnf-json-tests/main_test.go
index 898d233b5..3de99c51d 100644
--- a/cmd/osbuild-dnf-json-tests/main_test.go
+++ b/cmd/osbuild-dnf-json-tests/main_test.go
@@ -45,12 +45,6 @@ func TestFetchChecksum(t *testing.T) {
 // and tries to run depsolve for each architecture. With N architectures available
 // this should run cross-arch dependency solving N-1 times.
 func TestCrossArchDepsolve(t *testing.T) {
-	// Set up temporary directory for rpm/dnf cache
-	dir, err := ioutil.TempDir("/tmp", "rpmmd-test-")
-	require.Nilf(t, err, "Failed to create tmp dir for depsolve test: %v", err)
-	defer os.RemoveAll(dir)
-	rpm := rpmmd.NewRPMMD(dir)
-
 	// Load repositories from the definition we provide in the RPM package
 	repoDir := "/usr/share/osbuild-composer"
 
@@ -61,6 +55,12 @@ func TestCrossArchDepsolve(t *testing.T) {
 			// Run tests in parallel to speed up run times.
 			t.Parallel()
 
+			// Set up temporary directory for rpm/dnf cache
+			dir, err := ioutil.TempDir("/tmp", "rpmmd-test-")
+			require.Nilf(t, err, "Failed to create tmp dir for depsolve test: %v", err)
+			defer os.RemoveAll(dir)
+			rpm := rpmmd.NewRPMMD(dir)
+
 			repos, err := rpmmd.LoadRepositories([]string{repoDir}, distroStruct.Name())
 			require.NoErrorf(t, err, "Failed to LoadRepositories %v", distroStruct.Name())
 
diff --git a/cmd/osbuild-image-tests/constants/constants-travis.go b/cmd/osbuild-image-tests/constants/constants-travis.go
index 48872e450..14601a83d 100644
--- a/cmd/osbuild-image-tests/constants/constants-travis.go
+++ b/cmd/osbuild-image-tests/constants/constants-travis.go
@@ -4,12 +4,12 @@ package constants
 
 import "os/exec"
 
-func GetOsbuildCommand(store string) *exec.Cmd {
+func GetOsbuildCommand(outputDirectory string) *exec.Cmd {
 	cmd := exec.Command(
 		"python3",
 		"-m", "osbuild",
 		"--libdir", ".",
-		"--store", store,
+		"--output-directory", outputDirectory,
 		"--json",
 		"-",
 	)
diff --git a/cmd/osbuild-image-tests/constants/constants.go b/cmd/osbuild-image-tests/constants/constants.go
index 73d986709..560cfc9b5 100644
--- a/cmd/osbuild-image-tests/constants/constants.go
+++ b/cmd/osbuild-image-tests/constants/constants.go
@@ -4,10 +4,10 @@ package constants
 
 import "os/exec"
 
-func GetOsbuildCommand(store string) *exec.Cmd {
+func GetOsbuildCommand(outputDirectory string) *exec.Cmd {
 	return exec.Command(
 		"osbuild",
-		"--store", store,
+		"--output-directory", outputDirectory,
 		"--json",
 		"-",
 	)
diff --git a/cmd/osbuild-image-tests/context-managers.go b/cmd/osbuild-image-tests/context-managers.go
index bf2727de3..39fede7c8 100644
--- a/cmd/osbuild-image-tests/context-managers.go
+++ b/cmd/osbuild-image-tests/context-managers.go
@@ -169,11 +169,10 @@ func withBootedQemuImage(image string, ns netNS, f func() error) error {
 
 // withBootedNspawnImage boots the specified image in the specified namespace
 // using nspawn. The VM is killed immediately after function returns.
-func withBootedNspawnImage(image, name string, ns netNS, f func() error) error {
+func withBootedNspawnImage(image string, ns netNS, f func() error) error {
 	cmd := exec.Command(
 		"systemd-nspawn",
 		"--boot", "--register=no",
-		"-M", name,
 		"--image", image,
 		"--network-namespace-path", ns.Path(),
 	)
@@ -195,11 +194,10 @@ func withBootedNspawnImage(image, name string, ns netNS, f func() error) error {
 
 // withBootedNspawnImage boots the specified directory in the specified namespace
 // using nspawn. The VM is killed immediately after function returns.
-func withBootedNspawnDirectory(dir, name string, ns netNS, f func() error) error {
+func withBootedNspawnDirectory(dir string, ns netNS, f func() error) error {
 	cmd := exec.Command(
 		"systemd-nspawn",
 		"--boot", "--register=no",
-		"-M", name,
 		"--directory", dir,
 		"--network-namespace-path", ns.Path(),
 	)
diff --git a/cmd/osbuild-image-tests/main_test.go b/cmd/osbuild-image-tests/main_test.go
index f8f88c46f..1228dbdbd 100644
--- a/cmd/osbuild-image-tests/main_test.go
+++ b/cmd/osbuild-image-tests/main_test.go
@@ -42,8 +42,8 @@ type testcaseStruct struct {
 // mutex to enforce only one osbuild instance run at a time, see below
 var osbuildMutex sync.Mutex
 
-// runOsbuild runs osbuild with the specified manifest and store.
-func runOsbuild(manifest []byte, store string) (string, error) {
+// runOsbuild runs osbuild with the specified manifest and output-directory.
+func runOsbuild(manifest []byte, outputDirectory string) error {
 	// Osbuild crashes when multiple instances are run at a time.
 	// This mutex enforces that there's always just one osbuild instance.
 	// This should be removed once osbuild is fixed.
@@ -51,7 +51,7 @@ func runOsbuild(manifest []byte, store string) (string, error) {
 	osbuildMutex.Lock()
 	defer osbuildMutex.Unlock()
 
-	cmd := constants.GetOsbuildCommand(store)
+	cmd := constants.GetOsbuildCommand(outputDirectory)
 
 	cmd.Stderr = os.Stderr
 	cmd.Stdin = bytes.NewReader(manifest)
@@ -64,21 +64,12 @@ func runOsbuild(manifest []byte, store string) (string, error) {
 		if _, ok := err.(*exec.ExitError); ok {
 			var formattedOutput bytes.Buffer
 			_ = json.Indent(&formattedOutput, outBuffer.Bytes(), "", "  ")
-			return "", fmt.Errorf("running osbuild failed: %s", formattedOutput.String())
+			return fmt.Errorf("running osbuild failed: %s", formattedOutput.String())
 		}
-		return "", fmt.Errorf("running osbuild failed from an unexpected reason: %#v", err)
+		return fmt.Errorf("running osbuild failed from an unexpected reason: %#v", err)
 	}
 
-	var result struct {
-		OutputID string `json:"output_id"`
-	}
-
-	err = json.NewDecoder(&outBuffer).Decode(&result)
-	if err != nil {
-		return "", fmt.Errorf("cannot decode osbuild output: %#v", err)
-	}
-
-	return result.OutputID, nil
+	return nil
 }
 
 // testImageInfo runs image-info on image specified by imageImage and
@@ -200,9 +191,9 @@ func testBootUsingQemu(t *testing.T, imagePath string) {
 	require.NoError(t, err)
 }
 
-func testBootUsingNspawnImage(t *testing.T, imagePath string, outputID string) {
+func testBootUsingNspawnImage(t *testing.T, imagePath string) {
 	err := withNetworkNamespace(func(ns netNS) error {
-		return withBootedNspawnImage(imagePath, outputID, ns, func() error {
+		return withBootedNspawnImage(imagePath, ns, func() error {
 			testSSH(t, "localhost", constants.TestPaths.PrivateKey, &ns)
 			return nil
 		})
@@ -210,10 +201,10 @@ func testBootUsingNspawnImage(t *testing.T, imagePath string, outputID string) {
 	require.NoError(t, err)
 }
 
-func testBootUsingNspawnDirectory(t *testing.T, imagePath string, outputID string) {
+func testBootUsingNspawnDirectory(t *testing.T, imagePath string) {
 	err := withNetworkNamespace(func(ns netNS) error {
 		return withExtractedTarArchive(imagePath, func(dir string) error {
-			return withBootedNspawnDirectory(dir, outputID, ns, func() error {
+			return withBootedNspawnDirectory(dir, ns, func() error {
 				testSSH(t, "localhost", constants.TestPaths.PrivateKey, &ns)
 				return nil
 			})
@@ -305,16 +296,16 @@ func testBootUsingAzure(t *testing.T, imagePath string) {
 // The test passes if the function is able to connect to the image via ssh
 // in defined number of attempts and systemd-is-running returns running
 // or degraded status.
-func testBoot(t *testing.T, imagePath string, bootType string, outputID string) {
+func testBoot(t *testing.T, imagePath string, bootType string) {
 	switch bootType {
 	case "qemu":
 		testBootUsingQemu(t, imagePath)
 
 	case "nspawn":
-		testBootUsingNspawnImage(t, imagePath, outputID)
+		testBootUsingNspawnImage(t, imagePath)
 
 	case "nspawn-extract":
-		testBootUsingNspawnDirectory(t, imagePath, outputID)
+		testBootUsingNspawnDirectory(t, imagePath)
 
 	case "aws":
 		testBootUsingAWS(t, imagePath)
@@ -344,7 +335,7 @@ func kvmAvailable() bool {
 
 // testImage performs a series of tests specified in the testcase
 // on an image
-func testImage(t *testing.T, testcase testcaseStruct, imagePath, outputID string) {
+func testImage(t *testing.T, testcase testcaseStruct, imagePath string) {
 	if testcase.ImageInfo != nil {
 		t.Run("image info", func(t *testing.T) {
 			testImageInfo(t, imagePath, testcase.ImageInfo)
@@ -357,7 +348,7 @@ func testImage(t *testing.T, testcase testcaseStruct, imagePath, outputID string
 			return
 		}
 		t.Run("boot", func(t *testing.T) {
-			testBoot(t, imagePath, testcase.Boot.Type, outputID)
+			testBoot(t, imagePath, testcase.Boot.Type)
 		})
 	}
 }
@@ -365,22 +356,22 @@ func testImage(t *testing.T, testcase testcaseStruct, imagePath, outputID string
 // runTestcase builds the pipeline specified in the testcase and then it
 // tests the result
 func runTestcase(t *testing.T, testcase testcaseStruct) {
-	store, err := ioutil.TempDir("/var/tmp", "osbuild-image-tests-")
-	require.NoErrorf(t, err, "cannot create temporary store: %#v", err)
+	outputDirectory, err := ioutil.TempDir("/var/tmp", "osbuild-image-tests-*")
+	require.NoErrorf(t, err, "cannot create temporary output directory: %#v", err)
 
 	defer func() {
-		err := os.RemoveAll(store)
+		err := os.RemoveAll(outputDirectory)
 		if err != nil {
-			log.Printf("cannot remove temporary store: %#v\n", err)
+			log.Printf("cannot remove temporary output directory: %#v\n", err)
 		}
 	}()
 
-	outputID, err := runOsbuild(testcase.Manifest, store)
+	err = runOsbuild(testcase.Manifest, outputDirectory)
 	require.NoError(t, err)
 
-	imagePath := fmt.Sprintf("%s/refs/%s/%s", store, outputID, testcase.ComposeRequest.Filename)
+	imagePath := fmt.Sprintf("%s/%s", outputDirectory, testcase.ComposeRequest.Filename)
 
-	testImage(t, testcase, imagePath, outputID)
+	testImage(t, testcase, imagePath)
 }
 
 // getAllCases returns paths to all testcases in the testcase directory
diff --git a/internal/client/blueprints_test.go b/internal/client/blueprints_test.go
index 2f932bd86..fe8b993e7 100644
--- a/internal/client/blueprints_test.go
+++ b/internal/client/blueprints_test.go
@@ -63,6 +63,42 @@ func TestPostEmptyTOMLBlueprintV0(t *testing.T) {
 	require.False(t, resp.Status, "did not return an error")
 }
 
+// POST a TOML blueprint with an empty name
+func TestPostEmptyNameTOMLBlueprintV0(t *testing.T) {
+	// Use a blueprint with an empty Name
+	bp := `
+		name=""
+		version="0.0.1"
+		[package]
+		name="bash"
+		version="*"
+		`
+	resp, err := PostTOMLBlueprintV0(testState.socket, bp)
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, resp, "did not return an error")
+	require.False(t, resp.Status, "wrong Status (true)")
+	require.Equal(t, "InvalidChars", resp.Errors[0].ID)
+	require.Contains(t, resp.Errors[0].Msg, "Invalid characters in API path")
+}
+
+// POST a TOML blueprint with an invalid name chars
+func TestPostInvalidNameTOMLBlueprintV0(t *testing.T) {
+	// Use a blueprint with invalid Name
+	bp := `
+		name="I ｗ𝒊ll 𝟉ο𝘁 𝛠ａ𝔰ꜱ 𝘁𝒉𝝸𝚜"
+		version="0.0.1"
+		[package]
+		name="bash"
+		version="*"
+		`
+	resp, err := PostTOMLBlueprintV0(testState.socket, bp)
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, resp, "did not return an error")
+	require.False(t, resp.Status, "wrong Status (true)")
+	require.Equal(t, "InvalidChars", resp.Errors[0].ID)
+	require.Contains(t, resp.Errors[0].Msg, "Invalid characters in API path")
+}
+
 // POST a new JSON blueprint
 func TestPostJSONBlueprintV0(t *testing.T) {
 	bp := `{
@@ -100,6 +136,40 @@ func TestPostEmptyJSONBlueprintV0(t *testing.T) {
 	require.False(t, resp.Status, "did not return an error")
 }
 
+// POST a JSON blueprint with an empty name
+func TestPostEmptyNameJSONBlueprintV0(t *testing.T) {
+	// Use a blueprint with an empty Name
+	bp := `{
+		"name": "",
+		"version": "0.0.1",
+		"modules": [{"name": "util-linux", "version": "*"}]
+	}`
+
+	resp, err := PostJSONBlueprintV0(testState.socket, bp)
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, resp, "did not return an error")
+	require.False(t, resp.Status, "wrong Status (true)")
+	require.Equal(t, "InvalidChars", resp.Errors[0].ID)
+	require.Contains(t, resp.Errors[0].Msg, "Invalid characters in API path")
+}
+
+// POST a JSON blueprint with invalid name chars
+func TestPostInvalidNameJSONBlueprintV0(t *testing.T) {
+	// Use a blueprint with an empty Name
+	bp := `{
+		"name": "I ｗ𝒊ll 𝟉ο𝘁 𝛠ａ𝔰ꜱ 𝘁𝒉𝝸𝚜",
+		"version": "0.0.1",
+		"modules": [{"name": "util-linux", "version": "*"}]
+	}`
+
+	resp, err := PostJSONBlueprintV0(testState.socket, bp)
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, resp, "did not return an error")
+	require.False(t, resp.Status, "wrong Status (true)")
+	require.Equal(t, "InvalidChars", resp.Errors[0].ID)
+	require.Contains(t, resp.Errors[0].Msg, "Invalid characters in API path")
+}
+
 // POST a blueprint to the workspace as TOML
 func TestPostTOMLWorkspaceV0(t *testing.T) {
 	bp := `
@@ -145,6 +215,42 @@ func TestPostEmptyTOMLWorkspaceV0(t *testing.T) {
 	require.False(t, resp.Status, "did not return an error")
 }
 
+// POST a TOML blueprint with an empty name to the workspace
+func TestPostEmptyNameTOMLWorkspaceV0(t *testing.T) {
+	// Use a blueprint with an empty Name
+	bp := `
+		name=""
+		version="0.0.1"
+		[package]
+		name="bash"
+		version="*"
+		`
+	resp, err := PostTOMLWorkspaceV0(testState.socket, bp)
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, resp, "did not return an error")
+	require.False(t, resp.Status, "wrong Status (true)")
+	require.Equal(t, "InvalidChars", resp.Errors[0].ID)
+	require.Contains(t, resp.Errors[0].Msg, "Invalid characters in API path")
+}
+
+// POST a TOML blueprint with an invalid name chars to the workspace
+func TestPostInvalidNameTOMLWorkspaceV0(t *testing.T) {
+	// Use a blueprint with invalid Name
+	bp := `
+		name="I ｗ𝒊ll 𝟉ο𝘁 𝛠ａ𝔰ꜱ 𝘁𝒉𝝸𝚜"
+		version="0.0.1"
+		[package]
+		name="bash"
+		version="*"
+		`
+	resp, err := PostTOMLWorkspaceV0(testState.socket, bp)
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, resp, "did not return an error")
+	require.False(t, resp.Status, "wrong Status (true)")
+	require.Equal(t, "InvalidChars", resp.Errors[0].ID)
+	require.Contains(t, resp.Errors[0].Msg, "Invalid characters in API path")
+}
+
 // POST a blueprint to the workspace as JSON
 func TestPostJSONWorkspaceV0(t *testing.T) {
 	bp := `{
@@ -182,6 +288,40 @@ func TestPostEmptyJSONWorkspaceV0(t *testing.T) {
 	require.False(t, resp.Status, "did not return an error")
 }
 
+// POST a JSON blueprint with an empty name to the workspace
+func TestPostEmptyNameJSONWorkspaceV0(t *testing.T) {
+	// Use a blueprint with an empty Name
+	bp := `{
+		"name": "",
+		"version": "0.0.1",
+		"modules": [{"name": "util-linux", "version": "*"}]
+	}`
+
+	resp, err := PostJSONWorkspaceV0(testState.socket, bp)
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, resp, "did not return an error")
+	require.False(t, resp.Status, "wrong Status (true)")
+	require.Equal(t, "InvalidChars", resp.Errors[0].ID)
+	require.Contains(t, resp.Errors[0].Msg, "Invalid characters in API path")
+}
+
+// POST a JSON blueprint with invalid name chars to the workspace
+func TestPostInvalidNameJSONWorkspaceV0(t *testing.T) {
+	// Use a blueprint with an empty Name
+	bp := `{
+		"name": "I ｗ𝒊ll 𝟉ο𝘁 𝛠ａ𝔰ꜱ 𝘁𝒉𝝸𝚜",
+		"version": "0.0.1",
+		"modules": [{"name": "util-linux", "version": "*"}]
+	}`
+
+	resp, err := PostJSONWorkspaceV0(testState.socket, bp)
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, resp, "did not return an error")
+	require.False(t, resp.Status, "wrong Status (true)")
+	require.Equal(t, "InvalidChars", resp.Errors[0].ID)
+	require.Contains(t, resp.Errors[0].Msg, "Invalid characters in API path")
+}
+
 // delete a blueprint
 func TestDeleteBlueprintV0(t *testing.T) {
 	// POST a blueprint to delete
@@ -211,6 +351,16 @@ func TestDeleteNonBlueprint0(t *testing.T) {
 	require.False(t, resp.Status, "did not return an error")
 }
 
+// delete a blueprint with invalid name characters
+func TestDeleteInvalidBlueprintV0(t *testing.T) {
+	resp, err := DeleteBlueprintV0(testState.socket, "I ｗ𝒊ll 𝟉ο𝘁 𝛠ａ𝔰ꜱ 𝘁𝒉𝝸𝚜")
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, resp, "did not return an error")
+	require.False(t, resp.Status, "wrong Status (true)")
+	require.Equal(t, "InvalidChars", resp.Errors[0].ID)
+	require.Contains(t, resp.Errors[0].Msg, "Invalid characters in API path")
+}
+
 // delete a new blueprint from the workspace
 func TestDeleteNewWorkspaceV0(t *testing.T) {
 	// POST a blueprint to delete
@@ -291,6 +441,23 @@ func TestDeleteChangesWorkspaceV0(t *testing.T) {
 	require.Equal(t, "deleteBlueprintChangesWSV0", info.Blueprints[0].Description, "original blueprint not returned")
 }
 
+// delete a non-existent blueprint workspace
+func TestDeleteNonWorkspace0(t *testing.T) {
+	resp, err := DeleteWorkspaceV0(testState.socket, "test-delete-non-blueprint-ws-v0")
+	require.NoError(t, err, "failed with a client error")
+	require.False(t, resp.Status, "did not return an error")
+}
+
+// delete a blueprint with invalid name characters
+func TestDeleteInvalidWorkspaceV0(t *testing.T) {
+	resp, err := DeleteWorkspaceV0(testState.socket, "I ｗ𝒊ll 𝟉ο𝘁 𝛠ａ𝔰ꜱ 𝘁𝒉𝝸𝚜")
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, resp, "did not return an error")
+	require.False(t, resp.Status, "wrong Status (true)")
+	require.Equal(t, "InvalidChars", resp.Errors[0].ID)
+	require.Contains(t, resp.Errors[0].Msg, "Invalid characters in API path")
+}
+
 // list blueprints
 func TestListBlueprintsV0(t *testing.T) {
 	// Post a couple of blueprints
@@ -361,6 +528,16 @@ func TestGetNonTOMLBlueprintV0(t *testing.T) {
 	require.False(t, api.Status, "wrong Status (true)")
 }
 
+// get blueprint with invalid name characters
+func TestGetInvalidTOMLBlueprintV0(t *testing.T) {
+	_, api, err := GetBlueprintInfoTOMLV0(testState.socket, "I ｗ𝒊ll 𝟉ο𝘁 𝛠ａ𝔰ꜱ 𝘁𝒉𝝸𝚜")
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, api, "did not return an error")
+	require.False(t, api.Status, "wrong Status (true)")
+	require.Equal(t, "InvalidChars", api.Errors[0].ID)
+	require.Contains(t, api.Errors[0].Msg, "Invalid characters in API path")
+}
+
 // get blueprint contents as JSON
 func TestGetJSONBlueprintV0(t *testing.T) {
 	bp := `{
@@ -490,6 +667,16 @@ func TestBlueprintNonChangesV0(t *testing.T) {
 	require.Greater(t, len(resp.Errors), 0, "failed with no error: %#v", resp)
 }
 
+// get changes with invalid name characters
+func TestInvalidBlueprintChangesV0(t *testing.T) {
+	_, api, err := GetBlueprintsChangesV0(testState.socket, []string{"I ｗ𝒊ll 𝟉ο𝘁 𝛠ａ𝔰ꜱ 𝘁𝒉𝝸𝚜"})
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, api, "did not return an error")
+	require.False(t, api.Status, "wrong Status (true)")
+	require.Equal(t, "InvalidChars", api.Errors[0].ID)
+	require.Contains(t, api.Errors[0].Msg, "Invalid characters in API path")
+}
+
 // Undo blueprint changes
 func TestUndoBlueprintV0(t *testing.T) {
 	bps := []string{`{
@@ -589,6 +776,26 @@ func TestUndoNonBlueprintV0(t *testing.T) {
 	require.False(t, resp.Status, "did not return an error")
 }
 
+// undo a blueprint with invalid name characters
+func TestUndoInvalidBlueprintV0(t *testing.T) {
+	resp, err := UndoBlueprintChangeV0(testState.socket, "I ｗ𝒊ll 𝟉ο𝘁 𝛠ａ𝔰ꜱ 𝘁𝒉𝝸𝚜", "FFFF")
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, resp, "did not return an error")
+	require.False(t, resp.Status, "wrong Status (true)")
+	require.Equal(t, "InvalidChars", resp.Errors[0].ID)
+	require.Contains(t, resp.Errors[0].Msg, "Invalid characters in API path")
+}
+
+// undo a blueprint with invalid commit characters
+func TestUndoInvalidBlueprintCommitV0(t *testing.T) {
+	resp, err := UndoBlueprintChangeV0(testState.socket, "test-undo-non-blueprint-v0", "I ｗ𝒊ll 𝟉ο𝘁 𝛠ａ𝔰ꜱ 𝘁𝒉𝝸𝚜")
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, resp, "did not return an error")
+	require.False(t, resp.Status, "wrong Status (true)")
+	require.Equal(t, "InvalidChars", resp.Errors[0].ID)
+	require.Contains(t, resp.Errors[0].Msg, "Invalid characters in API path")
+}
+
 // Tag a blueprint with a new revision
 // The blueprint revision tag cannot be reset, it always increments by one, and cannot be deleted.
 // So to test tagging we tag two blueprint changes and make sure the second is first +1
@@ -660,6 +867,16 @@ func TestNonBlueprintTagV0(t *testing.T) {
 	require.False(t, tagResp.Status, "did not return an error")
 }
 
+// tag a blueprint with invalid name characters
+func TestTagInvalidBlueprintV0(t *testing.T) {
+	resp, err := TagBlueprintV0(testState.socket, "I ｗ𝒊ll 𝟉ο𝘁 𝛠ａ𝔰ꜱ 𝘁𝒉𝝸𝚜")
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, resp, "did not return an error")
+	require.False(t, resp.Status, "wrong Status (true)")
+	require.Equal(t, "InvalidChars", resp.Errors[0].ID)
+	require.Contains(t, resp.Errors[0].Msg, "Invalid characters in API path")
+}
+
 // depsolve a blueprint with packages and modules
 func TestBlueprintDepsolveV0(t *testing.T) {
 	bp := `{
@@ -695,6 +912,16 @@ func TestNonBlueprintDepsolveV0(t *testing.T) {
 	require.Greater(t, len(resp.Errors), 0, "failed with no error: %#v", resp)
 }
 
+// depsolve a blueprint with invalid name characters
+func TestDepsolveInvalidBlueprintV0(t *testing.T) {
+	_, api, err := DepsolveBlueprintV0(testState.socket, "I ｗ𝒊ll 𝟉ο𝘁 𝛠ａ𝔰ꜱ 𝘁𝒉𝝸𝚜")
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, api, "did not return an error")
+	require.False(t, api.Status, "wrong Status (true)")
+	require.Equal(t, "InvalidChars", api.Errors[0].ID)
+	require.Contains(t, api.Errors[0].Msg, "Invalid characters in API path")
+}
+
 // freeze a blueprint
 func TestBlueprintFreezeV0(t *testing.T) {
 	if testState.unitTest {
@@ -758,4 +985,14 @@ func TestNonBlueprintFreezeV0(t *testing.T) {
 	require.Greater(t, len(resp.Errors), 0, "failed with no error: %#v", resp)
 }
 
+// freeze a blueprint with invalid name characters
+func TestFreezeInvalidBlueprintV0(t *testing.T) {
+	_, api, err := FreezeBlueprintV0(testState.socket, "I ｗ𝒊ll 𝟉ο𝘁 𝛠ａ𝔰ꜱ 𝘁𝒉𝝸𝚜")
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, api, "did not return an error")
+	require.False(t, api.Status, "wrong Status (true)")
+	require.Equal(t, "InvalidChars", api.Errors[0].ID)
+	require.Contains(t, api.Errors[0].Msg, "Invalid characters in API path")
+}
+
 // TODO diff of blueprint changes
diff --git a/internal/client/compose_test.go b/internal/client/compose_test.go
index de73cd040..69a8ef605 100644
--- a/internal/client/compose_test.go
+++ b/internal/client/compose_test.go
@@ -89,6 +89,36 @@ func TestComposeInvalidBlueprintV0(t *testing.T) {
 	require.Contains(t, resp.Errors[0].Msg, "test-invalid-bp-compose-v0")
 }
 
+// Test compose for empty blueprint fails
+func TestComposeEmptyBlueprintV0(t *testing.T) {
+	compose := `{
+		"blueprint_name": "",
+		"compose_type": "qcow2",
+		"branch": "master"
+	}`
+	resp, err := PostComposeV0(testState.socket, compose)
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, resp)
+	require.False(t, resp.Status, "POST did not fail")
+	require.Equal(t, len(resp.Errors), 1)
+	require.Contains(t, resp.Errors[0].Msg, "Invalid characters in API path")
+}
+
+// Test compose for blueprint with invalid characters fails
+func TestComposeInvalidCharsBlueprintV0(t *testing.T) {
+	compose := `{
+		"blueprint_name": "I ｗ𝒊ll 𝟉ο𝘁 𝛠ａ𝔰ꜱ 𝘁𝒉𝝸𝚜",
+		"compose_type": "qcow2",
+		"branch": "master"
+	}`
+	resp, err := PostComposeV0(testState.socket, compose)
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, resp)
+	require.False(t, resp.Status, "POST did not fail")
+	require.Equal(t, len(resp.Errors), 1)
+	require.Contains(t, resp.Errors[0].Msg, "Invalid characters in API path")
+}
+
 // Test compose cancel for unknown uuid fails
 // Is cancel implemented at all?
 
@@ -162,6 +192,24 @@ func TestComposeInvalidStatusV0(t *testing.T) {
 	require.Equal(t, 0, len(status))
 }
 
+// Test status filter for unknown blueprint
+func TestComposeUnknownBlueprintStatusV0(t *testing.T) {
+	status, resp, err := GetComposeStatusV0(testState.socket, "*", "unknown-blueprint-test", "", "")
+	require.NoError(t, err, "failed with a client error")
+	require.Nil(t, resp)
+	require.Equal(t, 0, len(status))
+}
+
+// Test status filter for blueprint with invalid characters
+func TestComposeInvalidBlueprintStatusV0(t *testing.T) {
+	status, resp, err := GetComposeStatusV0(testState.socket, "*", "I ｗ𝒊ll 𝟉ο𝘁 𝛠ａ𝔰ꜱ 𝘁𝒉𝝸𝚜", "", "")
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, resp)
+	require.Equal(t, "InvalidChars", resp.Errors[0].ID)
+	require.Contains(t, resp.Errors[0].Msg, "Invalid characters in API path")
+	require.Equal(t, 0, len(status))
+}
+
 // Helper for searching compose results for a UUID
 func UUIDInComposeResults(buildID uuid.UUID, results []weldr.ComposeEntryV0) bool {
 	for idx := range results {
diff --git a/internal/distro/fedora30/distro_test.go b/internal/distro/fedora30/distro_test.go
index 25b5a210c..039594936 100644
--- a/internal/distro/fedora30/distro_test.go
+++ b/internal/distro/fedora30/distro_test.go
@@ -1,7 +1,6 @@
 package fedora30_test
 
 import (
-	"reflect"
 	"testing"
 
 	"github.com/osbuild/osbuild-composer/internal/distro/distro_test_common"
@@ -136,7 +135,7 @@ func TestImageType_BuildPackages(t *testing.T) {
 				t.Errorf("d.GetArch(%v) returned err = %v; expected nil", archLabel, err)
 				continue
 			}
-			reflect.DeepEqual(itStruct.BuildPackages(), buildPackages[archLabel])
+			assert.ElementsMatch(t, buildPackages[archLabel], itStruct.BuildPackages())
 		}
 	}
 }
diff --git a/internal/distro/fedora31/distro_test.go b/internal/distro/fedora31/distro_test.go
index 068f1881a..d286785d5 100644
--- a/internal/distro/fedora31/distro_test.go
+++ b/internal/distro/fedora31/distro_test.go
@@ -1,7 +1,6 @@
 package fedora31_test
 
 import (
-	"reflect"
 	"testing"
 
 	"github.com/osbuild/osbuild-composer/internal/distro/distro_test_common"
@@ -136,7 +135,7 @@ func TestImageType_BuildPackages(t *testing.T) {
 				t.Errorf("d.GetArch(%v) returned err = %v; expected nil", archLabel, err)
 				continue
 			}
-			reflect.DeepEqual(itStruct.BuildPackages(), buildPackages[archLabel])
+			assert.ElementsMatch(t, buildPackages[archLabel], itStruct.BuildPackages())
 		}
 	}
 }
diff --git a/internal/distro/fedora32/distro_test.go b/internal/distro/fedora32/distro_test.go
index 53ecef084..6908af30e 100644
--- a/internal/distro/fedora32/distro_test.go
+++ b/internal/distro/fedora32/distro_test.go
@@ -1,7 +1,6 @@
 package fedora32_test
 
 import (
-	"reflect"
 	"testing"
 
 	"github.com/osbuild/osbuild-composer/internal/distro/distro_test_common"
@@ -136,7 +135,7 @@ func TestImageType_BuildPackages(t *testing.T) {
 				t.Errorf("d.GetArch(%v) returned err = %v; expected nil", archLabel, err)
 				continue
 			}
-			reflect.DeepEqual(itStruct.BuildPackages(), buildPackages[archLabel])
+			assert.ElementsMatch(t, buildPackages[archLabel], itStruct.BuildPackages())
 		}
 	}
 }
diff --git a/internal/distro/rhel82/distro_test.go b/internal/distro/rhel82/distro_test.go
index 83cbf1d97..351a74346 100644
--- a/internal/distro/rhel82/distro_test.go
+++ b/internal/distro/rhel82/distro_test.go
@@ -3,7 +3,9 @@ package rhel82_test
 import (
 	"testing"
 
+	"github.com/osbuild/osbuild-composer/internal/distro/distro_test_common"
 	"github.com/osbuild/osbuild-composer/internal/distro/rhel82"
+	"github.com/stretchr/testify/assert"
 )
 
 func TestFilenameFromType(t *testing.T) {
@@ -93,3 +95,321 @@ func TestFilenameFromType(t *testing.T) {
 		})
 	}
 }
+
+func TestImageType_BuildPackages(t *testing.T) {
+	x8664BuildPackages := []string{
+		"dnf",
+		"dosfstools",
+		"e2fsprogs",
+		"grub2-pc",
+		"policycoreutils",
+		"qemu-img",
+		"systemd",
+		"tar",
+		"xz",
+	}
+	aarch64BuildPackages := []string{
+		"dnf",
+		"dosfstools",
+		"e2fsprogs",
+		"policycoreutils",
+		"qemu-img",
+		"systemd",
+		"tar",
+		"xz",
+	}
+	buildPackages := map[string][]string{
+		"x86_64":  x8664BuildPackages,
+		"aarch64": aarch64BuildPackages,
+	}
+	d := rhel82.New()
+	for _, archLabel := range d.ListArches() {
+		archStruct, err := d.GetArch(archLabel)
+		if assert.NoErrorf(t, err, "d.GetArch(%v) returned err = %v; expected nil", archLabel, err) {
+			continue
+		}
+		for _, itLabel := range archStruct.ListImageTypes() {
+			itStruct, err := archStruct.GetImageType(itLabel)
+			if assert.NoErrorf(t, err, "d.GetArch(%v) returned err = %v; expected nil", archLabel, err) {
+				continue
+			}
+			assert.ElementsMatch(t, buildPackages[archLabel], itStruct.BuildPackages())
+		}
+	}
+}
+
+func TestImageType_Name(t *testing.T) {
+	distro := rhel82.New()
+	imgMap := []struct {
+		arch     string
+		imgNames []string
+	}{
+		{
+			arch: "x86_64",
+			imgNames: []string{
+				"ami",
+				"ext4-filesystem",
+				"partitioned-disk",
+				"qcow2",
+				"openstack",
+				"tar",
+				"vhd",
+				"vmdk",
+			},
+		},
+		{
+			arch: "aarch64",
+			imgNames: []string{
+				"ami",
+				"ext4-filesystem",
+				"partitioned-disk",
+				"qcow2",
+				"openstack",
+				"tar",
+			},
+		},
+	}
+	for _, mapping := range imgMap {
+		arch, err := distro.GetArch(mapping.arch)
+		if assert.NoError(t, err) {
+			for _, imgName := range mapping.imgNames {
+				imgType, err := arch.GetImageType(imgName)
+				if assert.NoError(t, err) {
+					assert.Equalf(t, imgName, imgType.Name(), "arch: %s", mapping.arch)
+				}
+			}
+		}
+	}
+}
+
+func TestImageType_Size(t *testing.T) {
+	const gigaByte = 1024 * 1024 * 1024
+	sizeMap := []struct {
+		name       string
+		inputSize  uint64
+		outputSize uint64
+	}{
+		{
+			name:       "ami",
+			inputSize:  6*gigaByte + 1,
+			outputSize: 6*gigaByte + 1,
+		},
+		{
+			name:       "ami",
+			inputSize:  0,
+			outputSize: 6 * gigaByte,
+		},
+		{
+			name:       "vhd",
+			inputSize:  10 * gigaByte,
+			outputSize: 10 * gigaByte,
+		},
+		{
+			name:       "vhd",
+			inputSize:  10*gigaByte - 1,
+			outputSize: 10 * gigaByte,
+		},
+	}
+
+	distro := rhel82.New()
+	arch, err := distro.GetArch("x86_64")
+	if assert.NoError(t, err) {
+		for _, mapping := range sizeMap {
+			imgType, err := arch.GetImageType(mapping.name)
+			if assert.NoError(t, err) {
+				size := imgType.Size(mapping.inputSize)
+				assert.Equalf(t, mapping.outputSize, size, "Image type: %s, input size: %d, expected: %d, got: %d",
+					mapping.name, mapping.inputSize, mapping.outputSize, size)
+			}
+		}
+	}
+}
+
+func TestImageType_BasePackages(t *testing.T) {
+	pkgMaps := []struct {
+		name               string
+		basePackages       []string
+		bootloaderPackages []string
+		excludedPackages   []string
+		bootable           bool
+	}{
+		{
+			name: "ami",
+			basePackages: []string{
+				"checkpolicy",
+				"chrony",
+				"cloud-init",
+				"cloud-init",
+				"cloud-utils-growpart",
+				"@core",
+				"dhcp-client",
+				"gdisk",
+				"insights-client",
+				"kernel",
+				"langpacks-en",
+				"net-tools",
+				"NetworkManager",
+				"redhat-release",
+				"redhat-release-eula",
+				"rng-tools",
+				"rsync",
+				"selinux-policy-targeted",
+				"tar",
+				"yum-utils",
+			},
+			bootloaderPackages: []string{
+				"dracut-config-generic",
+				"grub2-pc",
+			},
+			excludedPackages: []string{
+				"aic94xx-firmware",
+				"alsa-firmware",
+				"alsa-lib",
+				"alsa-tools-firmware",
+				"biosdevname",
+				"dracut-config-rescue",
+				"firewalld",
+				"iprutils",
+				"ivtv-firmware",
+				"iwl1000-firmware",
+				"iwl100-firmware",
+				"iwl105-firmware",
+				"iwl135-firmware",
+				"iwl2000-firmware",
+				"iwl2030-firmware",
+				"iwl3160-firmware",
+				"iwl3945-firmware",
+				"iwl4965-firmware",
+				"iwl5000-firmware",
+				"iwl5150-firmware",
+				"iwl6000-firmware",
+				"iwl6000g2a-firmware",
+				"iwl6000g2b-firmware",
+				"iwl6050-firmware",
+				"iwl7260-firmware",
+				"libertas-sd8686-firmware",
+				"libertas-sd8787-firmware",
+				"libertas-usb8388-firmware",
+				"plymouth",
+
+				// TODO this cannot be removed, because the kernel (?)
+				// depends on it. The ec2 kickstart force-removes it.
+				// "linux-firmware",
+
+				// TODO setfiles failes because of usr/sbin/timedatex. Exlude until
+				// https://errata.devel.redhat.com/advisory/47339 lands
+				"timedatex",
+			},
+			bootable: true,
+		},
+		{
+			name: "ext4-filesystem",
+			basePackages: []string{
+				"policycoreutils",
+				"selinux-policy-targeted",
+				"kernel",
+				"firewalld",
+				"chrony",
+				"langpacks-en",
+			},
+			excludedPackages: []string{
+				"dracut-config-rescue",
+
+				// TODO setfiles failes because of usr/sbin/timedatex. Exlude until
+				// https://errata.devel.redhat.com/advisory/47339 lands
+				"timedatex",
+			},
+			bootable: true,
+		},
+		{
+			name: "openstack",
+			basePackages: []string{
+				// Defaults
+				"@Core",
+				"langpacks-en",
+
+				// From the lorax kickstart
+				"kernel",
+				"selinux-policy-targeted",
+				"cloud-init",
+				"qemu-guest-agent",
+				"spice-vdagent",
+			},
+			bootloaderPackages: []string{
+				"dracut-config-generic",
+				"grub2-pc",
+			},
+			excludedPackages: []string{
+				"dracut-config-rescue",
+			},
+			bootable: true,
+		},
+	}
+	distro := rhel82.New()
+	arch, err := distro.GetArch("x86_64")
+	assert.NoError(t, err)
+
+	for _, pkgMap := range pkgMaps {
+		imgType, err := arch.GetImageType(pkgMap.name)
+		assert.NoError(t, err)
+		basePackages, excludedPackages := imgType.BasePackages()
+		assert.Equalf(
+			t,
+			append(pkgMap.basePackages, pkgMap.bootloaderPackages...),
+			basePackages,
+			"image type: %s",
+			pkgMap.name,
+		)
+		assert.Equalf(t, pkgMap.excludedPackages, excludedPackages, "image type: %s", pkgMap.name)
+	}
+}
+
+func TestDistro_Manifest(t *testing.T) {
+	distro_test_common.TestDistro_Manifest(t, "../../../test/cases/", "rhel_8.2*", rhel82.New())
+}
+
+func TestRhel82_ListArches(t *testing.T) {
+	distro := rhel82.New()
+	arches := distro.ListArches()
+	assert.Equal(t, []string{"aarch64", "x86_64"}, arches)
+}
+
+func TestRhel82_GetArch(t *testing.T) {
+	distro := rhel82.New()
+	arches := []struct {
+		name          string
+		errorExpected bool
+	}{
+		{
+			name: "x86_64",
+		},
+		{
+			name: "aarch64",
+		},
+		{
+			name:          "foo-arch",
+			errorExpected: true,
+		},
+	}
+
+	for _, a := range arches {
+		actualArch, err := distro.GetArch(a.name)
+		if !a.errorExpected {
+			assert.Equal(t, a.name, actualArch.Name())
+			assert.NoError(t, err)
+		} else {
+			assert.Nil(t, actualArch)
+			assert.Error(t, err)
+		}
+	}
+}
+
+func TestRhel82_Name(t *testing.T) {
+	distro := rhel82.New()
+	assert.Equal(t, "rhel-8.2", distro.Name())
+}
+
+func TestRhel82_ModulePlatformID(t *testing.T) {
+	distro := rhel82.New()
+	assert.Equal(t, "platform:el8", distro.ModulePlatformID())
+}
diff --git a/internal/distro/rhel83/distro_test.go b/internal/distro/rhel83/distro_test.go
index 64fb2f9c4..a2c849821 100644
--- a/internal/distro/rhel83/distro_test.go
+++ b/internal/distro/rhel83/distro_test.go
@@ -4,6 +4,7 @@ import (
 	"testing"
 
 	"github.com/osbuild/osbuild-composer/internal/distro/rhel83"
+	"github.com/stretchr/testify/assert"
 )
 
 func TestFilenameFromType(t *testing.T) {
@@ -93,3 +94,322 @@ func TestFilenameFromType(t *testing.T) {
 		})
 	}
 }
+
+func TestImageType_BuildPackages(t *testing.T) {
+	x8664BuildPackages := []string{
+		"dnf",
+		"dosfstools",
+		"e2fsprogs",
+		"grub2-pc",
+		"policycoreutils",
+		"qemu-img",
+		"systemd",
+		"tar",
+		"xz",
+	}
+	aarch64BuildPackages := []string{
+		"dnf",
+		"dosfstools",
+		"e2fsprogs",
+		"policycoreutils",
+		"qemu-img",
+		"systemd",
+		"tar",
+		"xz",
+	}
+	buildPackages := map[string][]string{
+		"x86_64":  x8664BuildPackages,
+		"aarch64": aarch64BuildPackages,
+	}
+	d := rhel83.New()
+	for _, archLabel := range d.ListArches() {
+		archStruct, err := d.GetArch(archLabel)
+		if assert.NoErrorf(t, err, "d.GetArch(%v) returned err = %v; expected nil", archLabel, err) {
+			continue
+		}
+		for _, itLabel := range archStruct.ListImageTypes() {
+			itStruct, err := archStruct.GetImageType(itLabel)
+			if assert.NoErrorf(t, err, "d.GetArch(%v) returned err = %v; expected nil", archLabel, err) {
+				continue
+			}
+			assert.ElementsMatch(t, buildPackages[archLabel], itStruct.BuildPackages())
+		}
+	}
+}
+
+func TestImageType_Name(t *testing.T) {
+	distro := rhel83.New()
+	imgMap := []struct {
+		arch     string
+		imgNames []string
+	}{
+		{
+			arch: "x86_64",
+			imgNames: []string{
+				"ami",
+				"ext4-filesystem",
+				"partitioned-disk",
+				"qcow2",
+				"openstack",
+				"tar",
+				"vhd",
+				"vmdk",
+			},
+		},
+		{
+			arch: "aarch64",
+			imgNames: []string{
+				"ami",
+				"ext4-filesystem",
+				"partitioned-disk",
+				"qcow2",
+				"openstack",
+				"tar",
+			},
+		},
+	}
+	for _, mapping := range imgMap {
+		arch, err := distro.GetArch(mapping.arch)
+		if assert.NoError(t, err) {
+			for _, imgName := range mapping.imgNames {
+				imgType, err := arch.GetImageType(imgName)
+				if assert.NoError(t, err) {
+					assert.Equalf(t, imgName, imgType.Name(), "arch: %s", mapping.arch)
+				}
+			}
+		}
+	}
+}
+
+func TestImageType_Size(t *testing.T) {
+	const gigaByte = 1024 * 1024 * 1024
+	sizeMap := []struct {
+		name       string
+		inputSize  uint64
+		outputSize uint64
+	}{
+		{
+			name:       "ami",
+			inputSize:  6*gigaByte + 1,
+			outputSize: 6*gigaByte + 1,
+		},
+		{
+			name:       "ami",
+			inputSize:  0,
+			outputSize: 6 * gigaByte,
+		},
+		{
+			name:       "vhd",
+			inputSize:  10 * gigaByte,
+			outputSize: 10 * gigaByte,
+		},
+		{
+			name:       "vhd",
+			inputSize:  10*gigaByte - 1,
+			outputSize: 10 * gigaByte,
+		},
+	}
+
+	distro := rhel83.New()
+	arch, err := distro.GetArch("x86_64")
+	if assert.NoError(t, err) {
+		for _, mapping := range sizeMap {
+			imgType, err := arch.GetImageType(mapping.name)
+			if assert.NoError(t, err) {
+				size := imgType.Size(mapping.inputSize)
+				assert.Equalf(t, mapping.outputSize, size, "Image type: %s, input size: %d, expected: %d, got: %d",
+					mapping.name, mapping.inputSize, mapping.outputSize, size)
+			}
+		}
+	}
+}
+
+func TestImageType_BasePackages(t *testing.T) {
+	pkgMaps := []struct {
+		name               string
+		basePackages       []string
+		bootloaderPackages []string
+		excludedPackages   []string
+		bootable           bool
+	}{
+		{
+			name: "ami",
+			basePackages: []string{
+				"checkpolicy",
+				"chrony",
+				"cloud-init",
+				"cloud-init",
+				"cloud-utils-growpart",
+				"@core",
+				"dhcp-client",
+				"gdisk",
+				"insights-client",
+				"kernel",
+				"langpacks-en",
+				"net-tools",
+				"NetworkManager",
+				"redhat-release",
+				"redhat-release-eula",
+				"rng-tools",
+				"rsync",
+				"selinux-policy-targeted",
+				"tar",
+				"yum-utils",
+			},
+			bootloaderPackages: []string{
+				"dracut-config-generic",
+				"grub2-pc",
+			},
+			excludedPackages: []string{
+				"aic94xx-firmware",
+				"alsa-firmware",
+				"alsa-lib",
+				"alsa-tools-firmware",
+				"biosdevname",
+				"dracut-config-rescue",
+				"firewalld",
+				"iprutils",
+				"ivtv-firmware",
+				"iwl1000-firmware",
+				"iwl100-firmware",
+				"iwl105-firmware",
+				"iwl135-firmware",
+				"iwl2000-firmware",
+				"iwl2030-firmware",
+				"iwl3160-firmware",
+				"iwl3945-firmware",
+				"iwl4965-firmware",
+				"iwl5000-firmware",
+				"iwl5150-firmware",
+				"iwl6000-firmware",
+				"iwl6000g2a-firmware",
+				"iwl6000g2b-firmware",
+				"iwl6050-firmware",
+				"iwl7260-firmware",
+				"libertas-sd8686-firmware",
+				"libertas-sd8787-firmware",
+				"libertas-usb8388-firmware",
+				"plymouth",
+
+				// TODO this cannot be removed, because the kernel (?)
+				// depends on it. The ec2 kickstart force-removes it.
+				// "linux-firmware",
+
+				// TODO setfiles failes because of usr/sbin/timedatex. Exlude until
+				// https://errata.devel.redhat.com/advisory/47339 lands
+				"timedatex",
+			},
+			bootable: true,
+		},
+		{
+			name: "ext4-filesystem",
+			basePackages: []string{
+				"policycoreutils",
+				"selinux-policy-targeted",
+				"kernel",
+				"firewalld",
+				"chrony",
+				"langpacks-en",
+			},
+			excludedPackages: []string{
+				"dracut-config-rescue",
+
+				// TODO setfiles failes because of usr/sbin/timedatex. Exlude until
+				// https://errata.devel.redhat.com/advisory/47339 lands
+				"timedatex",
+			},
+			bootable: true,
+		},
+		{
+			name: "openstack",
+			basePackages: []string{
+				// Defaults
+				"@Core",
+				"langpacks-en",
+
+				// From the lorax kickstart
+				"kernel",
+				"selinux-policy-targeted",
+				"cloud-init",
+				"qemu-guest-agent",
+				"spice-vdagent",
+			},
+			bootloaderPackages: []string{
+				"dracut-config-generic",
+				"grub2-pc",
+			},
+			excludedPackages: []string{
+				"dracut-config-rescue",
+			},
+			bootable: true,
+		},
+	}
+	distro := rhel83.New()
+	arch, err := distro.GetArch("x86_64")
+	assert.NoError(t, err)
+
+	for _, pkgMap := range pkgMaps {
+		imgType, err := arch.GetImageType(pkgMap.name)
+		assert.NoError(t, err)
+		basePackages, excludedPackages := imgType.BasePackages()
+		assert.Equalf(
+			t,
+			append(pkgMap.basePackages, pkgMap.bootloaderPackages...),
+			basePackages,
+			"image type: %s",
+			pkgMap.name,
+		)
+		assert.Equalf(t, pkgMap.excludedPackages, excludedPackages, "image type: %s", pkgMap.name)
+	}
+}
+
+func TestDistro_Manifest(t *testing.T) {
+	// disabled as there is no usable manifest file available ATM
+	// distro_test_common.TestDistro_Manifest(t, "../../../test/cases/", "rhel_8.3*", rhel83.New())
+}
+
+func TestRhel83_ListArches(t *testing.T) {
+	distro := rhel83.New()
+	arches := distro.ListArches()
+	assert.Equal(t, []string{"aarch64", "x86_64"}, arches)
+}
+
+func TestRhel83_GetArch(t *testing.T) {
+	distro := rhel83.New()
+	arches := []struct {
+		name          string
+		errorExpected bool
+	}{
+		{
+			name: "x86_64",
+		},
+		{
+			name: "aarch64",
+		},
+		{
+			name:          "foo-arch",
+			errorExpected: true,
+		},
+	}
+
+	for _, a := range arches {
+		actualArch, err := distro.GetArch(a.name)
+		if !a.errorExpected {
+			assert.Equal(t, a.name, actualArch.Name())
+			assert.NoError(t, err)
+		} else {
+			assert.Nil(t, actualArch)
+			assert.Error(t, err)
+		}
+	}
+}
+
+func TestRhel83_Name(t *testing.T) {
+	distro := rhel83.New()
+	assert.Equal(t, "rhel-8.3", distro.Name())
+}
+
+func TestRhel83_ModulePlatformID(t *testing.T) {
+	distro := rhel83.New()
+	assert.Equal(t, "platform:el8", distro.ModulePlatformID())
+}
diff --git a/internal/jobqueue/fsjobqueue/fsjobqueue_test.go b/internal/jobqueue/fsjobqueue/fsjobqueue_test.go
index 0482557a7..108da8677 100644
--- a/internal/jobqueue/fsjobqueue/fsjobqueue_test.go
+++ b/internal/jobqueue/fsjobqueue/fsjobqueue_test.go
@@ -6,6 +6,7 @@ import (
 	"io/ioutil"
 	"os"
 	"testing"
+	"time"
 
 	"github.com/google/uuid"
 	"github.com/stretchr/testify/require"
@@ -22,11 +23,11 @@ func cleanupTempDir(t *testing.T, dir string) {
 	require.NoError(t, err)
 }
 
-func newTemporaryQueue(t *testing.T) (jobqueue.JobQueue, string) {
+func newTemporaryQueue(t *testing.T, jobTypes []string) (jobqueue.JobQueue, string) {
 	dir, err := ioutil.TempDir("", "jobqueue-test-")
 	require.NoError(t, err)
 
-	q, err := fsjobqueue.New(dir)
+	q, err := fsjobqueue.New(dir, jobTypes)
 	require.NoError(t, err)
 	require.NotNil(t, q)
 
@@ -34,6 +35,7 @@ func newTemporaryQueue(t *testing.T) (jobqueue.JobQueue, string) {
 }
 
 func pushTestJob(t *testing.T, q jobqueue.JobQueue, jobType string, args interface{}, dependencies []uuid.UUID) uuid.UUID {
+	t.Helper()
 	id, err := q.Enqueue(jobType, args, dependencies)
 	require.NoError(t, err)
 	require.NotEmpty(t, id)
@@ -52,13 +54,13 @@ func finishNextTestJob(t *testing.T, q jobqueue.JobQueue, jobType string, result
 }
 
 func TestNonExistant(t *testing.T) {
-	q, err := fsjobqueue.New("/non-existant-directory")
+	q, err := fsjobqueue.New("/non-existant-directory", []string{})
 	require.Error(t, err)
 	require.Nil(t, q)
 }
 
 func TestErrors(t *testing.T) {
-	q, dir := newTemporaryQueue(t)
+	q, dir := newTemporaryQueue(t, []string{"test"})
 	defer cleanupTempDir(t, dir)
 
 	// not serializable to JSON
@@ -78,7 +80,7 @@ func TestArgs(t *testing.T) {
 		S string
 	}
 
-	q, dir := newTemporaryQueue(t)
+	q, dir := newTemporaryQueue(t, []string{"fish", "octopus"})
 	defer cleanupTempDir(t, dir)
 
 	oneargs := argument{7, "🐠"}
@@ -100,7 +102,7 @@ func TestArgs(t *testing.T) {
 }
 
 func TestJobTypes(t *testing.T) {
-	q, dir := newTemporaryQueue(t)
+	q, dir := newTemporaryQueue(t, []string{"octopus", "clownfish"})
 	defer cleanupTempDir(t, dir)
 
 	one := pushTestJob(t, q, "octopus", nil, nil)
@@ -117,7 +119,7 @@ func TestJobTypes(t *testing.T) {
 }
 
 func TestDependencies(t *testing.T) {
-	q, dir := newTemporaryQueue(t)
+	q, dir := newTemporaryQueue(t, []string{"test"})
 	defer cleanupTempDir(t, dir)
 
 	t.Run("done-before-pushing-dependant", func(t *testing.T) {
@@ -130,15 +132,21 @@ func TestDependencies(t *testing.T) {
 		require.ElementsMatch(t, []uuid.UUID{one, two}, r)
 
 		j := pushTestJob(t, q, "test", nil, []uuid.UUID{one, two})
-		status, _, _, _, err := q.JobStatus(j, nil)
+		queued, started, finished, canceled, err := q.JobStatus(j, nil)
 		require.NoError(t, err)
-		require.Equal(t, jobqueue.JobPending, status)
+		require.True(t, !queued.IsZero())
+		require.True(t, started.IsZero())
+		require.True(t, finished.IsZero())
+		require.False(t, canceled)
 
 		require.Equal(t, j, finishNextTestJob(t, q, "test", testResult{}))
 
-		status, _, _, _, err = q.JobStatus(j, &testResult{})
+		queued, started, finished, canceled, err = q.JobStatus(j, &testResult{})
 		require.NoError(t, err)
-		require.Equal(t, jobqueue.JobFinished, status)
+		require.True(t, !queued.IsZero())
+		require.True(t, !started.IsZero())
+		require.True(t, !finished.IsZero())
+		require.False(t, canceled)
 	})
 
 	t.Run("done-after-pushing-dependant", func(t *testing.T) {
@@ -146,9 +154,12 @@ func TestDependencies(t *testing.T) {
 		two := pushTestJob(t, q, "test", nil, nil)
 
 		j := pushTestJob(t, q, "test", nil, []uuid.UUID{one, two})
-		status, _, _, _, err := q.JobStatus(j, nil)
+		queued, started, finished, canceled, err := q.JobStatus(j, nil)
 		require.NoError(t, err)
-		require.Equal(t, jobqueue.JobPending, status)
+		require.True(t, !queued.IsZero())
+		require.True(t, started.IsZero())
+		require.True(t, finished.IsZero())
+		require.False(t, canceled)
 
 		r := []uuid.UUID{}
 		r = append(r, finishNextTestJob(t, q, "test", testResult{}))
@@ -157,8 +168,90 @@ func TestDependencies(t *testing.T) {
 
 		require.Equal(t, j, finishNextTestJob(t, q, "test", testResult{}))
 
-		status, _, _, _, err = q.JobStatus(j, &testResult{})
+		queued, started, finished, canceled, err = q.JobStatus(j, &testResult{})
 		require.NoError(t, err)
-		require.Equal(t, jobqueue.JobFinished, status)
+		require.True(t, !queued.IsZero())
+		require.True(t, !started.IsZero())
+		require.True(t, !finished.IsZero())
+		require.False(t, canceled)
 	})
 }
+
+// Test that a job queue allows parallel access to multiple workers, mainly to
+// verify the quirky unlocking in Dequeue().
+func TestMultipleWorkers(t *testing.T) {
+	q, dir := newTemporaryQueue(t, []string{"octopus", "clownfish"})
+	defer cleanupTempDir(t, dir)
+
+	done := make(chan struct{})
+	go func() {
+		defer close(done)
+		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+		defer cancel()
+		id, err := q.Dequeue(ctx, []string{"octopus"}, &json.RawMessage{})
+		require.NoError(t, err)
+		require.NotEmpty(t, id)
+	}()
+
+	// Increase the likelihood that the above goroutine was scheduled and
+	// is waiting in Dequeue().
+	time.Sleep(10 * time.Millisecond)
+
+	// This call to Dequeue() should not block on the one in the goroutine.
+	id := pushTestJob(t, q, "clownfish", nil, nil)
+	r, err := q.Dequeue(context.Background(), []string{"clownfish"}, &json.RawMessage{})
+	require.NoError(t, err)
+	require.Equal(t, id, r)
+
+	// Now wake up the Dequeue() in the goroutine and wait for it to finish.
+	_ = pushTestJob(t, q, "octopus", nil, nil)
+	<-done
+}
+
+func TestCancel(t *testing.T) {
+	q, dir := newTemporaryQueue(t, []string{"octopus", "clownfish"})
+	defer cleanupTempDir(t, dir)
+
+	// Cancel a non-existing job
+	err := q.CancelJob(uuid.New())
+	require.Error(t, err)
+
+	// Cancel a pending job
+	id := pushTestJob(t, q, "clownfish", nil, nil)
+	require.NotEmpty(t, id)
+	err = q.CancelJob(id)
+	require.NoError(t, err)
+	_, _, _, canceled, err := q.JobStatus(id, &testResult{})
+	require.NoError(t, err)
+	require.True(t, canceled)
+	err = q.FinishJob(id, &testResult{})
+	require.Error(t, err)
+
+	// Cancel a running job, which should not dequeue the canceled job from above
+	id = pushTestJob(t, q, "clownfish", nil, nil)
+	require.NotEmpty(t, id)
+	r, err := q.Dequeue(context.Background(), []string{"clownfish"}, &json.RawMessage{})
+	require.NoError(t, err)
+	require.Equal(t, id, r)
+	err = q.CancelJob(id)
+	require.NoError(t, err)
+	_, _, _, canceled, err = q.JobStatus(id, &testResult{})
+	require.NoError(t, err)
+	require.True(t, canceled)
+	err = q.FinishJob(id, &testResult{})
+	require.Error(t, err)
+
+	// Cancel a finished job, which is a no-op
+	id = pushTestJob(t, q, "clownfish", nil, nil)
+	require.NotEmpty(t, id)
+	r, err = q.Dequeue(context.Background(), []string{"clownfish"}, &json.RawMessage{})
+	require.NoError(t, err)
+	require.Equal(t, id, r)
+	err = q.FinishJob(id, &testResult{})
+	require.NoError(t, err)
+	err = q.CancelJob(id)
+	require.NoError(t, err)
+	_, _, _, canceled, err = q.JobStatus(id, &testResult{})
+	require.NoError(t, err)
+	require.False(t, canceled)
+}
diff --git a/internal/jobqueue/testjobqueue/testjobqueue.go b/internal/jobqueue/testjobqueue/testjobqueue.go
index a47afe9a1..2c0d5e3f1 100644
--- a/internal/jobqueue/testjobqueue/testjobqueue.go
+++ b/internal/jobqueue/testjobqueue/testjobqueue.go
@@ -30,7 +30,10 @@ type job struct {
 	Args         json.RawMessage
 	Dependencies []uuid.UUID
 	Result       json.RawMessage
-	Status       jobqueue.JobStatus
+	QueuedAt     time.Time
+	StartedAt    time.Time
+	FinishedAt   time.Time
+	Canceled     bool
 }
 
 func New() *testJobQueue {
@@ -45,7 +48,7 @@ func (q *testJobQueue) Enqueue(jobType string, args interface{}, dependencies []
 		Id:           uuid.New(),
 		Type:         jobType,
 		Dependencies: uniqueUUIDList(dependencies),
-		Status:       jobqueue.JobPending,
+		QueuedAt:     time.Now(),
 	}
 
 	var err error
@@ -92,7 +95,7 @@ func (q *testJobQueue) Dequeue(ctx context.Context, jobTypes []string, args inte
 			return uuid.Nil, err
 		}
 
-		j.Status = jobqueue.JobRunning
+		j.StartedAt = time.Now()
 		return j.Id, nil
 	}
 
@@ -105,7 +108,7 @@ func (q *testJobQueue) FinishJob(id uuid.UUID, result interface{}) error {
 		return jobqueue.ErrNotExist
 	}
 
-	if j.Status != jobqueue.JobRunning {
+	if j.StartedAt.IsZero() || !j.FinishedAt.IsZero() {
 		return jobqueue.ErrNotRunning
 	}
 
@@ -115,7 +118,7 @@ func (q *testJobQueue) FinishJob(id uuid.UUID, result interface{}) error {
 		return fmt.Errorf("error marshaling result: %v", err)
 	}
 
-	j.Status = jobqueue.JobFinished
+	j.FinishedAt = time.Now()
 
 	for _, depid := range q.dependants[id] {
 		dep := q.jobs[depid]
@@ -132,25 +135,35 @@ func (q *testJobQueue) FinishJob(id uuid.UUID, result interface{}) error {
 	return nil
 }
 
-func (q *testJobQueue) JobStatus(id uuid.UUID, result interface{}) (status jobqueue.JobStatus, queued, started, finished time.Time, err error) {
-	var j *job
+func (q *testJobQueue) CancelJob(id uuid.UUID) error {
+	j, exists := q.jobs[id]
+	if !exists {
+		return jobqueue.ErrNotExist
+	}
+
+	j.Canceled = true
+
+	return nil
+}
 
+func (q *testJobQueue) JobStatus(id uuid.UUID, result interface{}) (queued, started, finished time.Time, canceled bool, err error) {
 	j, exists := q.jobs[id]
 	if !exists {
 		err = jobqueue.ErrNotExist
 		return
 	}
 
-	if j.Status == jobqueue.JobFinished {
+	if !j.FinishedAt.IsZero() {
 		err = json.Unmarshal(j.Result, result)
 		if err != nil {
 			return
 		}
 	}
 
-	queued = time.Time{}
-	started = time.Time{}
-	finished = time.Time{}
+	queued = j.QueuedAt
+	started = j.StartedAt
+	finished = j.FinishedAt
+	canceled = j.Canceled
 
 	return
 }
diff --git a/internal/jsondb/db_test.go b/internal/jsondb/db_test.go
index 70dbbd0bc..c18b45949 100644
--- a/internal/jsondb/db_test.go
+++ b/internal/jsondb/db_test.go
@@ -72,6 +72,39 @@ func TestCorrupt(t *testing.T) {
 	require.Error(t, err)
 }
 
+func TestRead(t *testing.T) {
+	dir, err := ioutil.TempDir("", "jsondb-test-")
+	require.NoError(t, err)
+	defer cleanupTempDir(t, dir)
+
+	err = ioutil.WriteFile(path.Join(dir, "one.json"), []byte("true"), 0755)
+	require.NoError(t, err)
+
+	db := jsondb.New(dir, 0755)
+
+	var b bool
+	exists, err := db.Read("one", &b)
+	require.NoError(t, err)
+	require.True(t, exists)
+	require.True(t, b)
+
+	// nil means don't deserialize
+	exists, err = db.Read("one", nil)
+	require.NoError(t, err)
+	require.True(t, exists)
+
+	b = false
+	exists, err = db.Read("two", &b)
+	require.NoError(t, err)
+	require.False(t, exists)
+	require.False(t, b)
+
+	// nil means don't deserialize
+	exists, err = db.Read("two", nil)
+	require.NoError(t, err)
+	require.False(t, exists)
+}
+
 func TestMultiple(t *testing.T) {
 	dir, err := ioutil.TempDir("", "jsondb-test-")
 	require.NoError(t, err)
diff --git a/internal/weldr/api_test.go b/internal/weldr/api_test.go
index b33bc1db9..9c56c0684 100644
--- a/internal/weldr/api_test.go
+++ b/internal/weldr/api_test.go
@@ -79,6 +79,7 @@ func TestBlueprintsNew(t *testing.T) {
 		{"POST", "/api/v0/blueprints/new", `{"name":"test","description":"Test","packages":[],"version":""}`, http.StatusOK, `{"status":true}`},
 		{"POST", "/api/v0/blueprints/new", `{"name":"test","description":"Test","packages":[{"name":"httpd","version":"2.4.*"}],"version":"0.0.0"}`, http.StatusOK, `{"status":true}`},
 		{"POST", "/api/v0/blueprints/new", `{"name":"test","description":"Test","packages:}`, http.StatusBadRequest, `{"status":false,"errors":[{"id":"BlueprintsError","msg":"400 Bad Request: The browser (or proxy) sent a request that this server could not understand: unexpected EOF"}]}`},
+		{"POST", "/api/v0/blueprints/new", `{"name":"","description":"Test","packages":[{"name":"httpd","version":"2.4.*"}],"version":"0.0.0"}`, http.StatusBadRequest, `{"status":false,"errors":[{"id":"InvalidChars","msg":"Invalid characters in API path"}]}`},
 		{"POST", "/api/v0/blueprints/new", ``, http.StatusBadRequest, `{"status":false,"errors":[{"id":"BlueprintsError","msg":"Missing blueprint"}]}`},
 	}
 
diff --git a/jenkins/run_tests.sh b/jenkins/run_tests.sh
deleted file mode 100755
index 730710208..000000000
--- a/jenkins/run_tests.sh
+++ /dev/null
@@ -1,49 +0,0 @@
-#!/bin/bash
-set -euxo pipefail
-
-# Create temporary directories for Ansible.
-sudo mkdir -vp /opt/ansible_{local,remote}
-sudo chmod -R 777 /opt/ansible_{local,remote}
-
-# Restart systemd to work around some Fedora issues in cloud images.
-sudo systemctl restart systemd-journald
-
-# Get the current journald cursor.
-export JOURNALD_CURSOR=$(sudo journalctl --quiet -n 1 --show-cursor | tail -n 1 | grep -oP 's\=.*$')
-
-# Add a function to preserve the system journal if something goes wrong.
-preserve_journal() {
-  sudo journalctl --after-cursor=${JOURNALD_CURSOR} > systemd-journald.log
-  exit 1
-}
-trap "preserve_journal" ERR
-
-# Ensure Ansible is installed.
-if ! rpm -q ansible; then
-  sudo dnf -y install ansible
-fi
-
-# Write a simple hosts file for Ansible.
-echo -e "[test_instances]\nlocalhost ansible_connection=local" > hosts.ini
-
-# Set Ansible's config file location.
-export ANSIBLE_CONFIG=ansible-osbuild/ansible.cfg
-
-# Deploy the software.
-git clone https://github.com/osbuild/ansible-osbuild.git ansible-osbuild
-ansible-playbook \
-  -i hosts.ini \
-  -e osbuild_composer_repo=${WORKSPACE} \
-  -e osbuild_composer_version=$(git rev-parse HEAD) \
-  ansible-osbuild/playbook.yml
-
-# Run the tests.
-ansible-playbook \
-  -e workspace=${WORKSPACE} \
-  -e journald_cursor="${JOURNALD_CURSOR}" \
-  -e test_type=${TEST_TYPE:-base} \
-  -i hosts.ini \
-  jenkins/test.yml
-
-# Collect the systemd journal anyway if we made it all the way to the end.
-sudo journalctl --after-cursor=${JOURNALD_CURSOR} > systemd-journald.log
\ No newline at end of file
diff --git a/jenkins/test.yml b/jenkins/test.yml
deleted file mode 100644
index e0359943b..000000000
--- a/jenkins/test.yml
+++ /dev/null
@@ -1,40 +0,0 @@
----
-
-- hosts: localhost
-  become: yes
-  vars:
-    passed_tests: []
-    failed_tests: []
-  vars_files:
-    - vars.yml
-  tasks:
-
-    - name: Run osbuild-composer base tests
-      include_tasks: test_runner_base.yml
-      loop: "{{ osbuild_composer_base_tests }}"
-      loop_control:
-        loop_var: test
-      when:
-        - test_type == 'base'
-
-    - name: Run osbuild-composer image tests
-      include_tasks: test_runner_image.yml
-      when:
-        - test_type == 'image'
-
-    - name: Show failed and passed tests
-      debug:
-        msg: |
-          Passed tests: {{ 'None' if not passed_tests else '' }}
-          {% for test_name in passed_tests %}
-            - {{ test_name }}
-          {% endfor %}
-          Failed tests: {{ 'None' if not failed_tests else '' }}
-          {% for test_name in failed_tests %}
-            - {{ test_name }}
-          {% endfor %}
-
-    - name: Fail the test run if a test failed
-      fail:
-        msg: One or more tests failed.
-      when: failed_tests
\ No newline at end of file
diff --git a/jenkins/test_runner_base.yml b/jenkins/test_runner_base.yml
deleted file mode 100644
index 73ca336c3..000000000
--- a/jenkins/test_runner_base.yml
+++ /dev/null
@@ -1,34 +0,0 @@
----
-
-- block:
-
-    - name: "Run {{ test.name }} test"
-      command: "{{ tests_path }}/{{ test.name }} -test.v -test.timeout {{ test.timeout * 60 }}s"
-      args:
-        chdir: "{{ tests_working_directory }}"
-      register: test_cmd
-
-    - name: "Mark {{ test.name }} as passed"
-      set_fact:
-        passed_tests: "{{ passed_tests + [test.name] }}"
-
-  rescue:
-
-    - name: "Mark {{ test.name }} as failed"
-      set_fact:
-        failed_tests: "{{ failed_tests + [test.name] }}"
-
-  always:
-
-    - name: "Write log for {{ test.name }}"
-      copy:
-        dest: "{{ workspace }}/{{ test.name }}.log"
-        content: |
-          Logs from {{ test.name }}
-          ----------------------------------------------------------------------
-          stderr:
-          {{ test_cmd.stderr }}
-          ----------------------------------------------------------------------
-          stdout:
-          {{ test_cmd.stdout }}
-
diff --git a/jenkins/test_runner_image.yml b/jenkins/test_runner_image.yml
deleted file mode 100644
index a22582307..000000000
--- a/jenkins/test_runner_image.yml
+++ /dev/null
@@ -1,62 +0,0 @@
----
-
-- name: Setup test case prefix based on distro and release
-  set_fact:
-    test_case_prefix: >-
-      {%- if ansible_distribution == "Fedora" -%}
-      fedora_{{ ansible_distribution_version }}-{{ ansible_machine }}
-      {%- else -%}
-      rhel_{{ ansible_distribution_version }}-{{ ansible_machine }}
-      {%- endif -%}
-
-- name: Show which tests will be run
-  debug:
-    msg: |
-      Running the following image test cases:
-      {% for test_case in osbuild_composer_image_test_cases %}
-        - {{ test_case | splitext | first }}
-      {% endfor %}
-
-- block:
-
-    # NOTE(mhayden): The fancy jinja2 here ensures that the last test case
-    # in the list does not have a backslash added. That would make the shell
-    # upset and nobody likes it when that happens.
-    - name: "Run image tests"
-      command: |
-        {{ image_test_executable }} -test.v -test.timeout {{image_test_timeout * 60}}s \
-          {% for test_case in osbuild_composer_image_test_cases %}
-            {% if loop.last %}
-          {{ image_test_case_path }}/{{ test_case_prefix }}-{{ test_case }}
-            {% else %}
-          {{ image_test_case_path }}/{{ test_case_prefix }}-{{ test_case }} \
-            {% endif %}
-          {% endfor %}
-      args:
-        chdir: "{{ tests_working_directory }}"
-      register: test_cmd
-
-    - name: "Mark image tests as passed"
-      set_fact:
-        passed_tests: "{{ passed_tests + ['image_tests'] }}"
-
-  rescue:
-
-    - name: "Mark image tests as failed"
-      set_fact:
-        failed_tests: "{{ failed_tests + ['image_tests'] }}"
-
-  always:
-
-    - name: "Write log for image tests"
-      copy:
-        dest: "{{ workspace }}/image_test.log"
-        content: |
-          Logs from image test
-          ----------------------------------------------------------------------
-          stderr:
-          {{ test_cmd.stderr }}
-          ----------------------------------------------------------------------
-          stdout:
-          {{ test_cmd.stdout }}
-
diff --git a/schutzbot/run_tests.sh b/schutzbot/run_tests.sh
index 2a78253a3..1d13229aa 100755
--- a/schutzbot/run_tests.sh
+++ b/schutzbot/run_tests.sh
@@ -5,13 +5,6 @@ set -euxo pipefail
 sudo mkdir -vp /opt/ansible_{local,remote}
 sudo chmod -R 777 /opt/ansible_{local,remote}
 
-# Remove Fedora modular repositories to speed up dnf-json.
-sudo rm -rfv /etc/yum.repos.d/fedora*modular*
-
-# Ensure /tmp is mounted on tmpfs.
-sudo systemctl enable tmp.mount || \
-  sudo systemctl unmask tmp.mount && sudo systemctl start tmp.mount
-
 # Restart systemd to work around some Fedora issues in cloud images.
 sudo systemctl restart systemd-journald
 
@@ -25,23 +18,21 @@ preserve_journal() {
 }
 trap "preserve_journal" ERR
 
-# Ensure Ansible is installed.
-if ! rpm -q ansible; then
-  sudo dnf -y install ansible
-fi
-
 # Write a simple hosts file for Ansible.
 echo -e "[test_instances]\nlocalhost ansible_connection=local" > hosts.ini
 
 # Set Ansible's config file location.
 export ANSIBLE_CONFIG=ansible-osbuild/ansible.cfg
 
-# Deploy the software.
+# Get the SHA of osbuild-composer which Jenkins checked out for us.
+OSBUILD_COMPOSER_VERSION=$(git rev-parse HEAD)
+
+# Deploy osbuild-composer and osbuild using RPMs built in a mock chroot.
 git clone https://github.com/osbuild/ansible-osbuild.git ansible-osbuild
 ansible-playbook \
   -i hosts.ini \
-  -e osbuild_composer_repo=${WORKSPACE} \
-  -e osbuild_composer_version=$(git rev-parse HEAD) \
+  -e osbuild_composer_version=${OSBUILD_COMPOSER_VERSION} \
+  -e install_source=mock \
   ansible-osbuild/playbook.yml
 
 # Run the tests.
@@ -50,7 +41,7 @@ ansible-playbook \
   -e journald_cursor="${JOURNALD_CURSOR}" \
   -e test_type=${TEST_TYPE:-base} \
   -i hosts.ini \
-  jenkins/test.yml
+  schutzbot/test.yml
 
 # Collect the systemd journal anyway if we made it all the way to the end.
 sudo journalctl --after-cursor=${JOURNALD_CURSOR} > systemd-journald.log
\ No newline at end of file
diff --git a/schutzbot/test.yml b/schutzbot/test.yml
index e0359943b..a1c7ee98c 100644
--- a/schutzbot/test.yml
+++ b/schutzbot/test.yml
@@ -19,6 +19,9 @@
 
     - name: Run osbuild-composer image tests
       include_tasks: test_runner_image.yml
+      loop: "{{ osbuild_composer_image_test_cases }}"
+      loop_control:
+        loop_var: test_case
       when:
         - test_type == 'image'
 
diff --git a/schutzbot/test_runner_image.yml b/schutzbot/test_runner_image.yml
index a22582307..876b5c3d8 100644
--- a/schutzbot/test_runner_image.yml
+++ b/schutzbot/test_runner_image.yml
@@ -9,50 +9,34 @@
       rhel_{{ ansible_distribution_version }}-{{ ansible_machine }}
       {%- endif -%}
 
-- name: Show which tests will be run
-  debug:
-    msg: |
-      Running the following image test cases:
-      {% for test_case in osbuild_composer_image_test_cases %}
-        - {{ test_case | splitext | first }}
-      {% endfor %}
-
 - block:
 
-    # NOTE(mhayden): The fancy jinja2 here ensures that the last test case
-    # in the list does not have a backslash added. That would make the shell
-    # upset and nobody likes it when that happens.
-    - name: "Run image tests"
+    - name: "Run image test case: {{ test_case_prefix }}-{{ test_case }}"
       command: |
-        {{ image_test_executable }} -test.v -test.timeout {{image_test_timeout * 60}}s \
-          {% for test_case in osbuild_composer_image_test_cases %}
-            {% if loop.last %}
+        {{ image_test_executable }} -test.v \
+          -test.timeout {{image_test_timeout * 60}}s \
           {{ image_test_case_path }}/{{ test_case_prefix }}-{{ test_case }}
-            {% else %}
-          {{ image_test_case_path }}/{{ test_case_prefix }}-{{ test_case }} \
-            {% endif %}
-          {% endfor %}
       args:
         chdir: "{{ tests_working_directory }}"
       register: test_cmd
 
-    - name: "Mark image tests as passed"
+    - name: "Mark test case as passed: {{ test_case_prefix }}-{{ test_case }}"
       set_fact:
-        passed_tests: "{{ passed_tests + ['image_tests'] }}"
+        passed_tests: "{{ passed_tests + [ test_case_prefix + '-' + test_case ] }}"
 
   rescue:
 
-    - name: "Mark image tests as failed"
+    - name: "Mark test case as failed: {{ test_case_prefix }}-{{ test_case }}"
       set_fact:
-        failed_tests: "{{ failed_tests + ['image_tests'] }}"
+        failed_tests: "{{ failed_tests + [ test_case_prefix + '-' + test_case ] }}"
 
   always:
 
-    - name: "Write log for image tests"
+    - name: "Write log for test case: {{ test_case_prefix }}-{{ test_case }}"
       copy:
-        dest: "{{ workspace }}/image_test.log"
+        dest: "{{ workspace }}/{{ test_case_prefix }}-{{ test_case }}.log"
         content: |
-          Logs from image test
+          Logs from test case: {{ test_case_prefix }}-{{ test_case }}
           ----------------------------------------------------------------------
           stderr:
           {{ test_cmd.stderr }}
diff --git a/tools/test-case-generators/generate-test-case b/tools/test-case-generators/generate-test-case
index ff9647b75..a7e83b3e7 100755
--- a/tools/test-case-generators/generate-test-case
+++ b/tools/test-case-generators/generate-test-case
@@ -5,6 +5,7 @@ import subprocess
 import json
 import os
 import sys
+import tempfile
 
 '''
 This script generates a json test case. It accepts a test_case_request as input through standard input.
@@ -35,10 +36,14 @@ def get_subprocess_stdout(*args, **kwargs):
     return sp.stdout
 
 
-def run_osbuild(manifest, store):
-    osbuild_cmd = ["osbuild", "--store", store, "--json", "-"]
-    result = json.loads(get_subprocess_stdout(osbuild_cmd, encoding="utf-8", input=json.dumps(manifest)))
-    return result.get("output_id")
+def run_osbuild(manifest, store, output):
+    subprocess.run(["osbuild",
+                    "--store", store,
+                    "--output-directory", output,
+                    "--json", "-"],
+                   check=True,
+                   encoding="utf-8",
+                   input=json.dumps(manifest))
 
 
 def main(test_case, store):
@@ -52,9 +57,10 @@ def main(test_case, store):
     test_case["rpmmd"] = json.loads(get_subprocess_stdout(pipeline_command, input=compose_request, encoding="utf-8"))
 
     if boot_type != "nspawn-extract":
-        output_id = run_osbuild(test_case["manifest"], store)
-        image_file = os.path.join(store, "refs", output_id, test_case["compose-request"]["filename"])
-        test_case["image-info"] = json.loads(get_subprocess_stdout(["tools/image-info", image_file], encoding="utf-8"))
+        with tempfile.TemporaryDirectory(dir=store, prefix="test-case-output-") as output:
+            run_osbuild(test_case["manifest"], store, output)
+            image_file = os.path.join(output, test_case["compose-request"]["filename"])
+            test_case["image-info"] = json.loads(get_subprocess_stdout(["tools/image-info", image_file], encoding="utf-8"))
 
     return test_case