Repository: osbuild/osbuild-composer
Pull request #46 (2019-10-24 13:02:09)

Weldr tests
Refactor current tests and add the first tests for compose status.


Patch:
diff --git a/internal/blueprint/blueprint.go b/internal/blueprint/blueprint.go
index 2aaa01059..fffe51204 100644
--- a/internal/blueprint/blueprint.go
+++ b/internal/blueprint/blueprint.go
@@ -20,12 +20,13 @@ func (e *InvalidOutputFormatError) Error() string {
 
 // A Blueprint is a high-level description of an image.
 type Blueprint struct {
-	Name        string    `json:"name"`
-	Description string    `json:"description"`
-	Version     string    `json:"version,omitempty"`
-	Packages    []Package `json:"packages"`
-	Modules     []Package `json:"modules"`
-	Groups      []Package `json:"groups"`
+	Name           string          `json:"name"`
+	Description    string          `json:"description"`
+	Version        string          `json:"version,omitempty"`
+	Packages       []Package       `json:"packages"`
+	Modules        []Package       `json:"modules"`
+	Groups         []Package       `json:"groups"`
+	Customizations *Customizations `json:"customizations,omitempty"`
 }
 
 // A Package specifies an RPM package.
diff --git a/internal/blueprint/customizations.go b/internal/blueprint/customizations.go
new file mode 100644
index 000000000..f5f58eab7
--- /dev/null
+++ b/internal/blueprint/customizations.go
@@ -0,0 +1,307 @@
+package blueprint
+
+import (
+	"github.com/osbuild/osbuild-composer/internal/pipeline"
+	"strconv"
+	"strings"
+)
+
+type Customizations struct {
+	Hostname *string                `json:"hostname,omitempty"`
+	Kernel   *KernelCustomization   `json:"kernel,omitempty"`
+	SSHKey   []SSHKeyCustomization  `json:"sshkey,omitempty"`
+	User     []UserCustomization    `json:"user,omitempty"`
+	Group    []GroupCustomization   `json:"group,omitempty"`
+	Timezone *TimezoneCustomization `json:"timezone,omitempty"`
+	Locale   *LocaleCustomization   `json:"locale,omitempty"`
+	Firewall *FirewallCustomization `json:"firewall,omitempty"`
+	Services *ServicesCustomization `json:"services,omitempty"`
+}
+
+type KernelCustomization struct {
+	Append string `json:"append"`
+}
+
+type SSHKeyCustomization struct {
+	User string `json:"user"`
+	Key  string `json:"key"`
+}
+
+type UserCustomization struct {
+	Name        string   `json:"name"`
+	Description *string  `json:"description,omitempty"`
+	Password    *string  `json:"password,omitempty"`
+	Key         *string  `json:"key,omitempty"`
+	Home        *string  `json:"home,omitempty"`
+	Shell       *string  `json:"shell,omitempty"`
+	Groups      []string `json:"groups,omitempty"`
+	UID         *int     `json:"uid,omitempty"`
+	GID         *int     `json:"gid,omitempty"`
+}
+
+type GroupCustomization struct {
+	Name string `json:"name"`
+	GID  *int   `json:"gid,omitempty"`
+}
+
+type TimezoneCustomization struct {
+	Timezone   *string  `json:"timezone,omitempty"`
+	NTPServers []string `json:"ntpservers,omitempty"`
+}
+
+type LocaleCustomization struct {
+	Languages []string `json:"languages,omitempty"`
+	Keyboard  *string  `json:"keyboard,omitempty"`
+}
+
+type FirewallCustomization struct {
+	Ports    []string                       `json:"ports,omitempty"`
+	Services *FirewallServicesCustomization `json:"services,omitempty"`
+}
+
+type FirewallServicesCustomization struct {
+	Enabled  []string `json:"enabled,omitempty"`
+	Disabled []string `json:"disabled,omitempty"`
+}
+
+type ServicesCustomization struct {
+	Enabled  []string `json:"enabled,omitempty"`
+	Disabled []string `json:"disabled,omitempty"`
+}
+
+type CustomizationError struct {
+	message string
+}
+
+func (e *CustomizationError) Error() string {
+	return e.message
+}
+
+func (c *Customizations) customizeAll(p *pipeline.Pipeline) error {
+	c.customizeHostname(p)
+	c.customizeGroup(p)
+	if err := c.customizeUserAndSSHKey(p); err != nil {
+		return err
+	}
+	c.customizeTimezone(p)
+	c.customizeNTPServers(p)
+	c.customizeLanguages(p)
+	c.customizeKeyboard(p)
+	c.customizeFirewall(p)
+	c.customizeServices(p)
+
+	return nil
+}
+
+func (c *Customizations) customizeHostname(p *pipeline.Pipeline) {
+	if c.Hostname == nil {
+		return
+	}
+
+	p.AddStage(
+		pipeline.NewHostnameStage(
+			&pipeline.HostnameStageOptions{Hostname: *c.Hostname},
+		),
+	)
+}
+
+func (c *Customizations) customizeGroup(p *pipeline.Pipeline) {
+	if len(c.Group) == 0 {
+		return
+	}
+
+	// TODO: lorax-composer doesn't create group with the same name as user if both specified
+	groups := make(map[string]pipeline.GroupsStageOptionsGroup)
+	for _, group := range c.Group {
+		groupData := pipeline.GroupsStageOptionsGroup{}
+		if group.GID != nil {
+			gid := strconv.Itoa(*group.GID)
+			groupData.GID = &gid
+		}
+
+		groups[group.Name] = groupData
+	}
+
+	p.AddStage(
+		pipeline.NewGroupsStage(
+			&pipeline.GroupsStageOptions{Groups: groups},
+		),
+	)
+}
+
+func (c *Customizations) assertAllUsersExistForSSHCustomizations() error {
+	for _, sshkey := range c.SSHKey {
+		userFound := false
+		for _, user := range c.User {
+			if user.Name == sshkey.User {
+				userFound = true
+			}
+		}
+
+		if !userFound {
+			return &CustomizationError{"Cannot set SSH key for non-existing user " + sshkey.User}
+		}
+	}
+	return nil
+}
+
+func (c *Customizations) customizeUserAndSSHKey(p *pipeline.Pipeline) error {
+	if len(c.User) == 0 {
+		if len(c.SSHKey) > 0 {
+			return &CustomizationError{"SSH key customization defined but no user customizations are defined"}
+		}
+
+		return nil
+	}
+
+	// return error if ssh key customization without user defined in user customization if found
+	if e := c.assertAllUsersExistForSSHCustomizations(); e != nil {
+		return e
+	}
+
+	users := make(map[string]pipeline.UsersStageOptionsUser)
+	for _, user := range c.User {
+		// TODO: only hashed password are currently supported as an input
+		// plain-text passwords should be also supported due to parity with lorax-composer
+		userData := pipeline.UsersStageOptionsUser{
+			Groups:      user.Groups,
+			Description: user.Description,
+			Home:        user.Home,
+			Shell:       user.Shell,
+			Password:    user.Password,
+			Key:         user.Key,
+		}
+
+		if user.UID != nil {
+			uid := strconv.Itoa(*user.UID)
+			userData.UID = &uid
+		}
+
+		if user.GID != nil {
+			gid := strconv.Itoa(*user.GID)
+			userData.GID = &gid
+		}
+
+		// process sshkey customizations
+		if additionalKeys := findKeysForUser(c.SSHKey, user.Name); len(additionalKeys) > 0 {
+			joinedKeys := strings.Join(additionalKeys, "\n")
+
+			if userData.Key != nil {
+				*userData.Key += "\n" + joinedKeys
+			} else {
+				userData.Key = &joinedKeys
+			}
+		}
+
+		users[user.Name] = userData
+	}
+
+	p.AddStage(
+		pipeline.NewUsersStage(
+			&pipeline.UsersStageOptions{Users: users},
+		),
+	)
+
+	return nil
+}
+
+func (c *Customizations) customizeTimezone(p *pipeline.Pipeline) {
+	if c.Timezone == nil || c.Timezone.Timezone == nil {
+		return
+	}
+
+	// TODO: lorax (anaconda) automatically installs chrony if timeservers are defined
+	// except for the case when chrony is explicitly removed from installed packages (using -chrony)
+	// this is currently not supported, no checks whether chrony is installed are not performed
+
+	p.AddStage(
+		pipeline.NewTimezoneStage(&pipeline.TimezoneStageOptions{
+			Zone: *c.Timezone.Timezone,
+		}),
+	)
+}
+
+func (c *Customizations) customizeNTPServers(p *pipeline.Pipeline) {
+	if c.Timezone == nil || len(c.Timezone.NTPServers) == 0 {
+		return
+	}
+
+	p.AddStage(
+		pipeline.NewChronyStage(&pipeline.ChronyStageOptions{
+			Timeservers: c.Timezone.NTPServers,
+		}),
+	)
+}
+
+func (c *Customizations) customizeLanguages(p *pipeline.Pipeline) {
+	if c.Locale == nil || len(c.Locale.Languages) == 0 {
+		return
+	}
+
+	// TODO: you can specify more languages in customization
+	// The first one is the primary one, we can set in the locale stage, this should currently work
+	// Also, ALL the listed languages are installed using langpack-* packages
+	// This is currently not implemented!
+	// See anaconda src: pyanaconda/payload/dnfpayload.py:772
+
+	p.AddStage(
+		pipeline.NewLocaleStage(&pipeline.LocaleStageOptions{
+			Language: c.Locale.Languages[0],
+		}),
+	)
+}
+
+func (c *Customizations) customizeKeyboard(p *pipeline.Pipeline) {
+	if c.Locale == nil || c.Locale.Keyboard == nil {
+		return
+	}
+
+	p.AddStage(
+		pipeline.NewKeymapStage(&pipeline.KeymapStageOptions{
+			Keymap: *c.Locale.Keyboard,
+		}),
+	)
+}
+
+func (c *Customizations) customizeFirewall(p *pipeline.Pipeline) {
+	if c.Firewall == nil {
+		return
+	}
+
+	var enabledServices, disabledServices []string
+
+	if c.Firewall.Services != nil {
+		enabledServices = c.Firewall.Services.Enabled
+		disabledServices = c.Firewall.Services.Disabled
+	}
+
+	p.AddStage(
+		pipeline.NewFirewallStage(&pipeline.FirewallStageOptions{
+			Ports:            c.Firewall.Ports,
+			EnabledServices:  enabledServices,
+			DisabledServices: disabledServices,
+		}),
+	)
+}
+
+func (c *Customizations) customizeServices(p *pipeline.Pipeline) {
+	if c.Services == nil {
+		return
+	}
+
+	p.AddStage(
+		pipeline.NewSystemdStage(&pipeline.SystemdStageOptions{
+			EnabledServices:  c.Services.Enabled,
+			DisabledServices: c.Services.Disabled,
+		}),
+	)
+}
+
+func findKeysForUser(sshKeyCustomizations []SSHKeyCustomization, user string) (keys []string) {
+	for _, sshKey := range sshKeyCustomizations {
+		if sshKey.User == user {
+			keys = append(keys, sshKey.Key)
+		}
+	}
+	return
+}
diff --git a/internal/blueprint/openstack_output.go b/internal/blueprint/openstack_output.go
index c400ac1a1..ca8e4d106 100644
--- a/internal/blueprint/openstack_output.go
+++ b/internal/blueprint/openstack_output.go
@@ -5,10 +5,25 @@ import "github.com/osbuild/osbuild-composer/internal/pipeline"
 type openstackOutput struct{}
 
 func (t *openstackOutput) translate(b *Blueprint) *pipeline.Pipeline {
-	p := getF30Pipeline()
+	packages := [...]string{
+		"@Core",
+		"chrony",
+		"kernel",
+		"selinux-policy-targeted",
+		"grub2-pc",
+		"spice-vdagent",
+		"qemu-guest-agent",
+		"xen-libs",
+		"langpacks-en",
+		"cloud-init",
+		"libdrm",
+	}
+	p := getCustomF30PackageSet(packages[:], []string{})
+	addF30LocaleStage(p)
 	addF30FSTabStage(p)
 	addF30GRUB2Stage(p)
 	addF30SELinuxStage(p)
+	addF30FixBlsStage(p)
 	addF30QemuAssembler(p, "qcow2", t.getName())
 	return p
 }
diff --git a/internal/blueprint/pipelines/openstack_empty_blueprint.json b/internal/blueprint/pipelines/openstack_empty_blueprint.json
index 6507a1284..7459c2577 100644
--- a/internal/blueprint/pipelines/openstack_empty_blueprint.json
+++ b/internal/blueprint/pipelines/openstack_empty_blueprint.json
@@ -46,16 +46,14 @@
           "spice-vdagent",
           "qemu-guest-agent",
           "xen-libs",
-          "langpacks-en"
+          "langpacks-en",
+          "cloud-init",
+          "libdrm"
         ],
         "releasever": "30",
         "basearch": "x86_64"
       }
     },
-    {
-      "name": "org.osbuild.fix-bls",
-      "options": {}
-    },
     {
       "name": "org.osbuild.locale",
       "options": {
@@ -90,6 +88,10 @@
       "options": {
         "file_contexts": "etc/selinux/targeted/contexts/files/file_contexts"
       }
+    },
+    {
+      "name": "org.osbuild.fix-bls",
+      "options": {}
     }
   ],
   "assembler": {
diff --git a/internal/jobqueue/api.go b/internal/jobqueue/api.go
index bfaa4f1e0..87489b0da 100644
--- a/internal/jobqueue/api.go
+++ b/internal/jobqueue/api.go
@@ -2,13 +2,14 @@ package jobqueue
 
 import (
 	"encoding/json"
-	"github.com/osbuild/osbuild-composer/internal/pipeline"
-	"github.com/osbuild/osbuild-composer/internal/store"
-	"github.com/osbuild/osbuild-composer/internal/target"
 	"log"
 	"net"
 	"net/http"
 
+	"github.com/osbuild/osbuild-composer/internal/pipeline"
+	"github.com/osbuild/osbuild-composer/internal/store"
+	"github.com/osbuild/osbuild-composer/internal/target"
+
 	"github.com/google/uuid"
 	"github.com/julienschmidt/httprouter"
 )
@@ -131,6 +132,8 @@ func (api *API) updateJobHandler(writer http.ResponseWriter, request *http.Reque
 		case *store.NotFoundError:
 			statusResponseError(writer, http.StatusNotFound, err.Error())
 		case *store.NotPendingError:
+			statusResponseError(writer, http.StatusNotFound, err.Error())
+		case *store.NotRunningError:
 			statusResponseError(writer, http.StatusBadRequest, err.Error())
 		case *store.InvalidRequestError:
 			statusResponseError(writer, http.StatusBadRequest, err.Error())
diff --git a/internal/pipeline/chrony_stage.go b/internal/pipeline/chrony_stage.go
new file mode 100644
index 000000000..bcbd334ef
--- /dev/null
+++ b/internal/pipeline/chrony_stage.go
@@ -0,0 +1,14 @@
+package pipeline
+
+type ChronyStageOptions struct {
+	Timeservers []string `json:"timeservers"`
+}
+
+func (ChronyStageOptions) isStageOptions() {}
+
+func NewChronyStage(options *ChronyStageOptions) *Stage {
+	return &Stage{
+		Name:    "org.osbuild.chrony",
+		Options: options,
+	}
+}
diff --git a/internal/pipeline/firewall_stage.go b/internal/pipeline/firewall_stage.go
new file mode 100644
index 000000000..0479f388c
--- /dev/null
+++ b/internal/pipeline/firewall_stage.go
@@ -0,0 +1,16 @@
+package pipeline
+
+type FirewallStageOptions struct {
+	Ports            []string `json:"ports,omitempty"`
+	EnabledServices  []string `json:"enabled_services,omitempty"`
+	DisabledServices []string `json:"disabled_services,omitempty"`
+}
+
+func (FirewallStageOptions) isStageOptions() {}
+
+func NewFirewallStage(options *FirewallStageOptions) *Stage {
+	return &Stage{
+		Name:    "org.osbuild.firewall",
+		Options: options,
+	}
+}
diff --git a/internal/pipeline/groups_stage.go b/internal/pipeline/groups_stage.go
new file mode 100644
index 000000000..55f1659bd
--- /dev/null
+++ b/internal/pipeline/groups_stage.go
@@ -0,0 +1,18 @@
+package pipeline
+
+type GroupsStageOptions struct {
+	Groups map[string]GroupsStageOptionsGroup `json:"groups"`
+}
+
+func (GroupsStageOptions) isStageOptions() {}
+
+type GroupsStageOptionsGroup struct {
+	GID *string `json:"gid,omitempty"`
+}
+
+func NewGroupsStage(options *GroupsStageOptions) *Stage {
+	return &Stage{
+		Name:    "org.osbuild.groups",
+		Options: options,
+	}
+}
diff --git a/internal/pipeline/hostname_stage.go b/internal/pipeline/hostname_stage.go
new file mode 100644
index 000000000..095474b31
--- /dev/null
+++ b/internal/pipeline/hostname_stage.go
@@ -0,0 +1,14 @@
+package pipeline
+
+type HostnameStageOptions struct {
+	Hostname string `json:"hostname"`
+}
+
+func (HostnameStageOptions) isStageOptions() {}
+
+func NewHostnameStage(options *HostnameStageOptions) *Stage {
+	return &Stage{
+		Name:    "org.osbuild.hostname",
+		Options: options,
+	}
+}
diff --git a/internal/pipeline/keymap_stage.go b/internal/pipeline/keymap_stage.go
new file mode 100644
index 000000000..8c5e33c64
--- /dev/null
+++ b/internal/pipeline/keymap_stage.go
@@ -0,0 +1,14 @@
+package pipeline
+
+type KeymapStageOptions struct {
+	Keymap string `json:"keymap"`
+}
+
+func (KeymapStageOptions) isStageOptions() {}
+
+func NewKeymapStage(options *KeymapStageOptions) *Stage {
+	return &Stage{
+		Name:    "org.osbuild.keymap",
+		Options: options,
+	}
+}
diff --git a/internal/pipeline/stage.go b/internal/pipeline/stage.go
index b7be4ad7a..099a5bd4e 100644
--- a/internal/pipeline/stage.go
+++ b/internal/pipeline/stage.go
@@ -48,6 +48,22 @@ func (stage *Stage) UnmarshalJSON(data []byte) error {
 		options = new(LocaleStageOptions)
 	case "org.osbuild.selinux":
 		options = new(SELinuxStageOptions)
+	case "org.osbuild.hostname":
+		options = new(HostnameStageOptions)
+	case "org.osbuild.users":
+		options = new(UsersStageOptions)
+	case "org.osbuild.groups":
+		options = new(GroupsStageOptions)
+	case "org.osbuild.timezone":
+		options = new(TimezoneStageOptions)
+	case "org.osbuild.chrony":
+		options = new(ChronyStageOptions)
+	case "org.osbuild.keymap":
+		options = new(KeymapStageOptions)
+	case "org.osbuild.firewall":
+		options = new(FirewallStageOptions)
+	case "org.osbuild.systemd":
+		options = new(SystemdStageOptions)
 	default:
 		return errors.New("unexpected stage name")
 	}
diff --git a/internal/pipeline/systemd_stage.go b/internal/pipeline/systemd_stage.go
new file mode 100644
index 000000000..5c0d7aa97
--- /dev/null
+++ b/internal/pipeline/systemd_stage.go
@@ -0,0 +1,15 @@
+package pipeline
+
+type SystemdStageOptions struct {
+	EnabledServices  []string `json:"enabled_services,omitempty"`
+	DisabledServices []string `json:"disabled_services,omitempty"`
+}
+
+func (SystemdStageOptions) isStageOptions() {}
+
+func NewSystemdStage(options *SystemdStageOptions) *Stage {
+	return &Stage{
+		Name:    "org.osbuild.systemd",
+		Options: options,
+	}
+}
diff --git a/internal/pipeline/timezone_stage.go b/internal/pipeline/timezone_stage.go
new file mode 100644
index 000000000..d07647bda
--- /dev/null
+++ b/internal/pipeline/timezone_stage.go
@@ -0,0 +1,14 @@
+package pipeline
+
+type TimezoneStageOptions struct {
+	Zone string `json:"zone"`
+}
+
+func (TimezoneStageOptions) isStageOptions() {}
+
+func NewTimezoneStage(options *TimezoneStageOptions) *Stage {
+	return &Stage{
+		Name:    "org.osbuild.users",
+		Options: options,
+	}
+}
diff --git a/internal/pipeline/users_stage.go b/internal/pipeline/users_stage.go
new file mode 100644
index 000000000..f9072e8b0
--- /dev/null
+++ b/internal/pipeline/users_stage.go
@@ -0,0 +1,25 @@
+package pipeline
+
+type UsersStageOptions struct {
+	Users map[string]UsersStageOptionsUser `json:"users"`
+}
+
+func (UsersStageOptions) isStageOptions() {}
+
+type UsersStageOptionsUser struct {
+	UID         *string  `json:"uid,omitempty"`
+	GID         *string  `json:"gid,omitempty"`
+	Groups      []string `json:"groups,omitempty"`
+	Description *string  `json:"description,omitempty"`
+	Home        *string  `json:"home,omitempty"`
+	Shell       *string  `json:"shell,omitempty"`
+	Password    *string  `json:"password,omitempty"`
+	Key         *string  `json:"key,omitempty"`
+}
+
+func NewUsersStage(options *UsersStageOptions) *Stage {
+	return &Stage{
+		Name:    "org.osbuild.users",
+		Options: options,
+	}
+}
diff --git a/internal/store/store.go b/internal/store/store.go
index 94d02c7ea..603fb9764 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -4,9 +4,6 @@ package store
 
 import (
 	"encoding/json"
-	"github.com/osbuild/osbuild-composer/internal/blueprint"
-	"github.com/osbuild/osbuild-composer/internal/pipeline"
-	"github.com/osbuild/osbuild-composer/internal/target"
 	"io/ioutil"
 	"log"
 	"os"
@@ -15,6 +12,10 @@ import (
 	"sync"
 	"time"
 
+	"github.com/osbuild/osbuild-composer/internal/blueprint"
+	"github.com/osbuild/osbuild-composer/internal/pipeline"
+	"github.com/osbuild/osbuild-composer/internal/target"
+
 	"github.com/google/uuid"
 )
 
@@ -72,6 +73,14 @@ func (e *NotPendingError) Error() string {
 	return e.message
 }
 
+type NotRunningError struct {
+	message string
+}
+
+func (e *NotRunningError) Error() string {
+	return e.message
+}
+
 type InvalidRequestError struct {
 	message string
 }
@@ -368,7 +377,18 @@ func (s *Store) PushCompose(composeID uuid.UUID, bp *blueprint.Blueprint, compos
 }
 
 func (s *Store) PopCompose() Job {
-	return <-s.pendingJobs
+	job := <-s.pendingJobs
+	s.change(func() error {
+		compose, exists := s.Composes[job.ComposeID]
+		if !exists || compose.QueueStatus != "WAITING" {
+			panic("Invalid job in queue.")
+		}
+		compose.JobStarted = time.Now()
+		compose.QueueStatus = "RUNNING"
+		s.Composes[job.ComposeID] = compose
+		return nil
+	})
+	return job
 }
 
 func (s *Store) UpdateCompose(composeID uuid.UUID, status string) error {
@@ -377,25 +397,22 @@ func (s *Store) UpdateCompose(composeID uuid.UUID, status string) error {
 		if !exists {
 			return &NotFoundError{"compose does not exist"}
 		}
+		if compose.QueueStatus == "WAITING" {
+			return &NotPendingError{"compose has not been popped"}
+		}
 		switch status {
 		case "RUNNING":
 			switch compose.QueueStatus {
-			case "WAITING":
-				compose.JobStarted = time.Now()
 			case "RUNNING":
 			default:
-				return &NotPendingError{"compose was not pending"}
+				return &NotRunningError{"compose was not running"}
 			}
 		case "FINISHED", "FAILED":
 			switch compose.QueueStatus {
-			case "WAITING":
-				now := time.Now()
-				compose.JobStarted = now
-				compose.JobFinished = now
 			case "RUNNING":
 				compose.JobFinished = time.Now()
 			default:
-				return &NotPendingError{"compose was not pending"}
+				return &NotRunningError{"compose was not running"}
 			}
 			compose.QueueStatus = status
 			s.Composes[composeID] = compose


Test patch:
diff --git a/internal/jobqueue/api_test.go b/internal/jobqueue/api_test.go
index a5a486af5..21cedb28d 100644
--- a/internal/jobqueue/api_test.go
+++ b/internal/jobqueue/api_test.go
@@ -112,12 +112,16 @@ func testUpdateTransition(t *testing.T, from, to string, expectedStatus int) {
 	store := store.New(nil)
 	api := jobqueue.New(nil, store)
 
-	store.PushCompose(id, &blueprint.Blueprint{}, "tar")
-
-	sendHTTP(api, "POST", "/job-queue/v1/jobs", `{}`)
-	if from != "WAITING" {
-		sendHTTP(api, "PATCH", "/job-queue/v1/jobs/ffffffff-ffff-ffff-ffff-ffffffffffff", `{"status":"`+from+`"}`)
+	if from != "VOID" {
+		store.PushCompose(id, &blueprint.Blueprint{}, "tar")
+		if from != "WAITING" {
+			sendHTTP(api, "POST", "/job-queue/v1/jobs", `{}`)
+			if from != "RUNNING" {
+				sendHTTP(api, "PATCH", "/job-queue/v1/jobs/ffffffff-ffff-ffff-ffff-ffffffffffff", `{"status":"`+from+`"}`)
+			}
+		}
 	}
+
 	testRoute(t, api, "PATCH", "/job-queue/v1/jobs/ffffffff-ffff-ffff-ffff-ffffffffffff", `{"status":"`+to+`"}`, expectedStatus, ``)
 }
 
@@ -127,11 +131,15 @@ func TestUpdate(t *testing.T) {
 		To             string
 		ExpectedStatus int
 	}{
-		{"WAITING", "WAITING", http.StatusBadRequest},
-		{"WAITING", "RUNNING", http.StatusOK},
-		{"WAITING", "FINISHED", http.StatusOK},
-		{"WAITING", "FAILED", http.StatusOK},
-		{"RUNNING", "RUNNING", http.StatusOK},
+		{"VOID", "WAITING", http.StatusNotFound},
+		{"VOID", "RUNNING", http.StatusNotFound},
+		{"VOID", "FINISHED", http.StatusNotFound},
+		{"VOID", "FAILED", http.StatusNotFound},
+		{"WAITING", "WAITING", http.StatusNotFound},
+		{"WAITING", "RUNNING", http.StatusNotFound},
+		{"WAITING", "FINISHED", http.StatusNotFound},
+		{"WAITING", "FAILED", http.StatusNotFound},
+		{"RUNNING", "WAITING", http.StatusBadRequest},
 		{"RUNNING", "RUNNING", http.StatusOK},
 		{"RUNNING", "FINISHED", http.StatusOK},
 		{"RUNNING", "FAILED", http.StatusOK},
diff --git a/internal/weldr/api_test.go b/internal/weldr/api_test.go
index 814713aa0..783a736b8 100644
--- a/internal/weldr/api_test.go
+++ b/internal/weldr/api_test.go
@@ -67,16 +67,21 @@ func internalRequest(api *weldr.API, method, path, body string) *http.Response {
 	return resp.Result()
 }
 
-func testRoute(t *testing.T, api *weldr.API, external bool, method, path, body string, expectedStatus int, expectedJSON string) {
-	var resp *http.Response
-
+func sendHTTP(api *weldr.API, external bool, method, path, body string) *http.Response {
 	if len(os.Getenv("OSBUILD_COMPOSER_TEST_EXTERNAL")) > 0 {
 		if !external {
-			t.Skip("This test is for internal testing only")
+			return nil
 		}
-		resp = externalRequest(method, path, body)
+		return externalRequest(method, path, body)
 	} else {
-		resp = internalRequest(api, method, path, body)
+		return internalRequest(api, method, path, body)
+	}
+}
+
+func testRoute(t *testing.T, api *weldr.API, external bool, method, path, body string, expectedStatus int, expectedJSON string) {
+	resp := sendHTTP(api, external, method, path, body)
+	if resp == nil {
+		t.Skip("This test is for internal testing only")
 	}
 
 	if resp.StatusCode != expectedStatus {
@@ -161,45 +166,159 @@ func TestBasic(t *testing.T) {
 	}
 }
 
-func TestBlueprints(t *testing.T) {
-	api := weldr.New(repo, packages, nil, store.New(nil))
+func TestBlueprintsNew(t *testing.T) {
+	var cases = []struct {
+		Method         string
+		Path           string
+		Body           string
+		ExpectedStatus int
+		ExpectedJSON   string
+	}{
+		{"POST", "/api/v0/blueprints/new", `{"name":"test","description":"Test","packages":[{"name":"httpd","version":"2.4.*"}],"version":"0.0.0"}`, http.StatusOK, `{"status":true}`},
+	}
 
-	testRoute(t, api, true, "POST", "/api/v0/blueprints/new",
-		`{"name":"test","description":"Test","packages":[{"name":"httpd","version":"2.4.*"}],"version":"0.0.0"}`,
-		http.StatusOK, `{"status":true}`)
+	for _, c := range cases {
+		api := weldr.New(repo, packages, nil, store.New(nil))
+		testRoute(t, api, true, c.Method, c.Path, c.Body, c.ExpectedStatus, c.ExpectedJSON)
+	}
+}
 
-	testRoute(t, api, true, "GET", "/api/v0/blueprints/info/test", ``,
-		http.StatusOK, `{"blueprints":[{"name":"test","description":"Test","modules":[],"packages":[{"name":"httpd","version":"2.4.*"}],"groups":[],"version":"0.0.0"}],
-		"changes":[{"name":"test","changed":false}], "errors":[]}`)
+func TestBlueprintsWorkspace(t *testing.T) {
+	var cases = []struct {
+		Method         string
+		Path           string
+		Body           string
+		ExpectedStatus int
+		ExpectedJSON   string
+	}{
+		{"POST", "/api/v0/blueprints/workspace", `{"name":"test","description":"Test","packages":[{"name":"systemd","version":"123"}],"version":"0.0.0"}`, http.StatusOK, `{"status":true}`},
+	}
+
+	for _, c := range cases {
+		api := weldr.New(repo, packages, nil, store.New(nil))
+		sendHTTP(api, true, "POST", "/api/v0/blueprints/new", `{"name":"test","description":"Test","packages":[{"name":"httpd","version":"2.4.*"}],"version":"0.0.0"}`)
+		testRoute(t, api, true, c.Method, c.Path, c.Body, c.ExpectedStatus, c.ExpectedJSON)
+	}
+}
+
+func TestBlueprintsInfo(t *testing.T) {
+	var cases = []struct {
+		Method         string
+		Path           string
+		Body           string
+		ExpectedStatus int
+		ExpectedJSON   string
+	}{
+		{"GET", "/api/v0/blueprints/info/test1", ``, http.StatusOK, `{"blueprints":[{"name":"test1","description":"Test","modules":[],"packages":[{"name":"httpd","version":"2.4.*"}],"groups":[],"version":"0.0.0"}],
+		"changes":[{"name":"test1","changed":false}], "errors":[]}`},
+		{"GET", "/api/v0/blueprints/info/test2", ``, http.StatusOK, `{"blueprints":[{"name":"test2","description":"Test","modules":[],"packages":[{"name":"systemd","version":"123"}],"groups":[],"version":"0.0.0"}],
+		"changes":[{"name":"test2","changed":true}], "errors":[]}`},
+	}
+
+	for _, c := range cases {
+		api := weldr.New(repo, packages, nil, store.New(nil))
+		sendHTTP(api, true, "POST", "/api/v0/blueprints/new", `{"name":"test1","description":"Test","packages":[{"name":"httpd","version":"2.4.*"}],"version":"0.0.0"}`)
+		sendHTTP(api, true, "POST", "/api/v0/blueprints/new", `{"name":"test2","description":"Test","packages":[{"name":"httpd","version":"2.4.*"}],"version":"0.0.0"}`)
+		sendHTTP(api, true, "POST", "/api/v0/blueprints/workspace", `{"name":"test2","description":"Test","packages":[{"name":"systemd","version":"123"}],"version":"0.0.0"}`)
+		testRoute(t, api, true, c.Method, c.Path, c.Body, c.ExpectedStatus, c.ExpectedJSON)
+		sendHTTP(api, true, "DELETE", "/api/v0/blueprints/delete/test2", ``)
+		sendHTTP(api, true, "DELETE", "/api/v0/blueprints/delete/test1", ``)
+	}
+}
 
-	testRoute(t, api, true, "POST", "/api/v0/blueprints/workspace",
-		`{"name":"test","description":"Test","packages":[{"name":"systemd","version":"123"}],"version":"0.0.0"}`,
-		http.StatusOK, `{"status":true}`)
+func TestBlueprintsDiff(t *testing.T) {
+	var cases = []struct {
+		Method         string
+		Path           string
+		Body           string
+		ExpectedStatus int
+		ExpectedJSON   string
+	}{
+		{"GET", "/api/v0/blueprints/diff/test/NEWEST/WORKSPACE", ``, http.StatusOK, `{"diff":[{"new":{"Package":{"name":"systemd","version":"123"}},"old":null},{"new":null,"old":{"Package":{"name":"httpd","version":"2.4.*"}}}]}`},
+	}
 
-	testRoute(t, api, true, "GET", "/api/v0/blueprints/info/test", ``,
-		http.StatusOK, `{"blueprints":[{"name":"test","description":"Test","modules":[],"packages":[{"name":"systemd","version":"123"}],"groups":[],"version":"0.0.0"}],
-		"changes":[{"name":"test","changed":true}], "errors":[]}`)
+	for _, c := range cases {
+		api := weldr.New(repo, packages, nil, store.New(nil))
+		sendHTTP(api, true, "POST", "/api/v0/blueprints/new", `{"name":"test","description":"Test","packages":[{"name":"httpd","version":"2.4.*"}],"version":"0.0.0"}`)
+		sendHTTP(api, true, "POST", "/api/v0/blueprints/workspace", `{"name":"test","description":"Test","packages":[{"name":"systemd","version":"123"}],"version":"0.0.0"}`)
+		testRoute(t, api, true, c.Method, c.Path, c.Body, c.ExpectedStatus, c.ExpectedJSON)
+		sendHTTP(api, true, "DELETE", "/api/v0/blueprints/delete/test", ``)
+	}
+}
 
-	testRoute(t, api, true, "GET", "/api/v0/blueprints/diff/test/NEWEST/WORKSPACE", ``,
-		http.StatusOK, `{"diff":[{"new":{"Package":{"name":"systemd","version":"123"}},"old":null},{"new":null,"old":{"Package":{"name":"httpd","version":"2.4.*"}}}]}`)
+func TestBlueprintsDelete(t *testing.T) {
+	var cases = []struct {
+		Method         string
+		Path           string
+		Body           string
+		ExpectedStatus int
+		ExpectedJSON   string
+	}{
+		{"DELETE", "/api/v0/blueprints/delete/test", ``, http.StatusOK, `{"status":true}`},
+	}
 
-	testRoute(t, api, true, "DELETE", "/api/v0/blueprints/delete/test", ``,
-		http.StatusOK, `{"status":true}`)
+	for _, c := range cases {
+		api := weldr.New(repo, packages, nil, store.New(nil))
+		sendHTTP(api, true, "POST", "/api/v0/blueprints/new", `{"name":"test","description":"Test","packages":[{"name":"httpd","version":"2.4.*"}],"version":"0.0.0"}`)
+		testRoute(t, api, true, c.Method, c.Path, c.Body, c.ExpectedStatus, c.ExpectedJSON)
+		sendHTTP(api, true, "DELETE", "/api/v0/blueprints/delete/test", ``)
+	}
 }
 
 func TestCompose(t *testing.T) {
-	api := weldr.New(repo, packages, nil, store.New(nil))
+	var cases = []struct {
+		External       bool
+		Method         string
+		Path           string
+		Body           string
+		ExpectedStatus int
+		ExpectedJSON   string
+	}{
+		{true, "POST", "/api/v0/compose", `{"blueprint_name": "http-server","compose_type": "tar","branch": "master"}`, http.StatusBadRequest, `{"status":false,"errors":[{"id":"UnknownBlueprint","msg":"Unknown blueprint name: http-server"}]}`},
+		{false, "POST", "/api/v0/compose", `{"blueprint_name": "test","compose_type": "tar","branch": "master"}`, http.StatusOK, `*`},
+	}
 
-	testRoute(t, api, true, "POST", "/api/v0/blueprints/new",
-		`{"name":"test","description":"Test","packages":[{"name":"httpd","version":"2.4.*"}],"version":"0.0.0"}`,
-		http.StatusOK, `{"status":true}`)
+	for _, c := range cases {
+		api := weldr.New(repo, packages, nil, store.New(nil))
+		sendHTTP(api, c.External, "POST", "/api/v0/blueprints/new", `{"name":"test","description":"Test","packages":[{"name":"httpd","version":"2.4.*"}],"version":"0.0.0"}`)
+		testRoute(t, api, c.External, c.Method, c.Path, c.Body, c.ExpectedStatus, c.ExpectedJSON)
+		sendHTTP(api, c.External, "DELETE", "/api/v0/blueprints/delete/test", ``)
+	}
+}
 
-	testRoute(t, api, true, "POST", "/api/v0/compose", `{"blueprint_name": "http-server","compose_type": "tar","branch": "master"}`,
-		http.StatusBadRequest, `{"errors":[{"id":"UnknownBlueprint","msg":"Unknown blueprint name: http-server"}],"status":false}`)
+func TestComposeQueue(t *testing.T) {
+	var cases = []struct {
+		Method         string
+		Path           string
+		Body           string
+		ExpectedStatus int
+		ExpectedJSON   string
+	}{
+		{"GET", "/api/v0/compose/queue", ``, http.StatusOK, `*`}, // TODO: we need a way to verify we actually get the expected json here
+	}
 
-	testRoute(t, api, true, "POST", "/api/v0/compose", `{"blueprint_name": "test","compose_type": "tar","branch": "master"}`,
-		http.StatusOK, `*`)
+	if len(os.Getenv("OSBUILD_COMPOSER_TEST_EXTERNAL")) > 0 {
+		t.Skip("This test is for internal testing only")
+	}
 
-	testRoute(t, api, true, "DELETE", "/api/v0/blueprints/delete/test", ``,
-		http.StatusOK, `{"status":true}`)
+	for _, c := range cases {
+		s := store.New(nil)
+		api := weldr.New(repo, packages, nil, s)
+		sendHTTP(api, false, "POST", "/api/v0/blueprints/new", `{"name":"test","description":"Test","packages":[{"name":"httpd","version":"2.4.*"}],"version":"0.0.0"}`)
+		// create job and leave it waiting
+		sendHTTP(api, false, "POST", "/api/v0/compose", `{"blueprint_name": "test","compose_type": "tar","branch": "master"}`)
+		// create job and leave it running
+		sendHTTP(api, false, "POST", "/api/v0/compose", `{"blueprint_name": "test","compose_type": "tar","branch": "master"}`)
+		s.PopCompose()
+		// create job and mark it as finished
+		sendHTTP(api, false, "POST", "/api/v0/compose", `{"blueprint_name": "test","compose_type": "tar","branch": "master"}`)
+		job := s.PopCompose()
+		s.UpdateCompose(job.ComposeID, "FINISHED")
+		// create job and mark it as failed
+		sendHTTP(api, false, "POST", "/api/v0/compose", `{"blueprint_name": "test","compose_type": "tar","branch": "master"}`)
+		job = s.PopCompose()
+		s.UpdateCompose(job.ComposeID, "FAILED")
+		testRoute(t, api, false, c.Method, c.Path, c.Body, c.ExpectedStatus, c.ExpectedJSON)
+		sendHTTP(api, false, "DELETE", "/api/v0/blueprints/delete/test", ``)
+	}
 }