Repository: osbuild/osbuild-composer
Pull request #604 (2020-05-12 14:57:31)

[tests] use assert.Equal instead of reflect.DeepEqual in distro_test
The asserts should do the same job and also at the same time make sure that the two compared lists are equal. In the original state, the comparison was performed, but the result was thrown away - and actually the result was false, as the two lists were different due to non-equal order of items.

This change originates from a suggestion by @atodorov in #591.

EDIT: I just found out that I could possibly use `assert.ElementsMatch` instead of `assert.Equal` and leave the items order in the list as it was before. What would be the better option?

larskarlitski: > EDIT: I just found out that I could possibly use `assert.ElementsMatch` instead of `assert.Equal` and leave the items order in the list as it was before. What would be the better option?

Yeah, please do that, because it seems more robust to me.

Patch:
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/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/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/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


Test patch:
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 }}