Repository: osbuild/osbuild-composer
Pull request #138 (2019-12-03 14:08:05)

store: add image struct into compose struct
As a part of f4991cb1 ComposeEntry struct was removed from store package.
This change made sense because this struct is connected more with API than
with store - store uses its own Compose struct. In addition, converters
between Compose and ComposeEntry were added. Unfortunately, ComposeEntry
contains ImageSize which was not stored in Compose but retrieved from store
using GetImage method. This made those converters dependent on the store,
which was messy.

To solve this issue this commit adds image struct into Compose struct.
The content of image struct is generated on the worker side - when the worker
sets the compose status to FINISHED, it also sends Image struct with detailed
information about the result.


Patch:
diff --git a/cmd/osbuild-worker/main.go b/cmd/osbuild-worker/main.go
index 0824cf9ec..459fcd6c8 100644
--- a/cmd/osbuild-worker/main.go
+++ b/cmd/osbuild-worker/main.go
@@ -11,6 +11,7 @@ import (
 
 	"github.com/osbuild/osbuild-composer/internal/distro"
 	"github.com/osbuild/osbuild-composer/internal/jobqueue"
+	"github.com/osbuild/osbuild-composer/internal/store"
 )
 
 type ComposerClient struct {
@@ -53,9 +54,9 @@ func (c *ComposerClient) AddJob() (*jobqueue.Job, error) {
 	return job, nil
 }
 
-func (c *ComposerClient) UpdateJob(job *jobqueue.Job, status string) error {
+func (c *ComposerClient) UpdateJob(job *jobqueue.Job, status string, image *store.Image) error {
 	var b bytes.Buffer
-	json.NewEncoder(&b).Encode(&jobqueue.JobStatus{status})
+	json.NewEncoder(&b).Encode(&jobqueue.JobStatus{status, image})
 	req, err := http.NewRequest("PATCH", "http://localhost/job-queue/v1/jobs/"+job.ID.String(), &b)
 	if err != nil {
 		return err
@@ -82,23 +83,23 @@ func handleJob(client *ComposerClient, distro distro.Distro) {
 		panic(err)
 	}
 
-	client.UpdateJob(job, "RUNNING")
+	client.UpdateJob(job, "RUNNING", nil)
 
 	fmt.Printf("Running job %s\n", job.ID.String())
-	err, errs := job.Run(distro)
+	image, err, errs := job.Run(distro)
 	if err != nil {
-		client.UpdateJob(job, "FAILED")
+		client.UpdateJob(job, "FAILED", nil)
 		return
 	}
 
 	for _, err := range errs {
 		if err != nil {
-			client.UpdateJob(job, "FAILED")
+			client.UpdateJob(job, "FAILED", nil)
 			return
 		}
 	}
 
-	client.UpdateJob(job, "FINISHED")
+	client.UpdateJob(job, "FINISHED", image)
 }
 
 func main() {
diff --git a/internal/blueprint/blueprint.go b/internal/blueprint/blueprint.go
index 1aecccbdd..2b3b39054 100644
--- a/internal/blueprint/blueprint.go
+++ b/internal/blueprint/blueprint.go
@@ -3,32 +3,35 @@ package blueprint
 
 // 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         []Group         `json:"groups"`
-	Customizations *Customizations `json:"customizations,omitempty"`
+	Name        string    `json:"name" toml:"name"`
+	Description string    `json:"description" toml:"description"`
+	Version     string    `json:"version,omitempty" toml:"version,omitempty"`
+	Packages    []Package `json:"packages" toml:"packages"`
+
+	// skip "omitempty" for json, because cockpit-composer chokes when the keys are missing
+	Modules []Package `json:"modules" toml:"modules,omitempty"`
+	Groups  []Group   `json:"groups" toml:"groups,omitempty"`
+
+	Customizations *Customizations `json:"customizations,omitempty" toml:"customizations,omitempty"`
 }
 
 type Change struct {
-	Commit    string    `json:"commit"`
-	Message   string    `json:"message"`
-	Revision  *string   `json:"revision"`
-	Timestamp string    `json:"timestamp"`
-	Blueprint Blueprint `json:"-"`
+	Commit    string    `json:"commit" toml:"commit"`
+	Message   string    `json:"message" toml:"message"`
+	Revision  *string   `json:"revision" toml:"revision"`
+	Timestamp string    `json:"timestamp" toml:"timestamp"`
+	Blueprint Blueprint `json:"-" toml:"-"`
 }
 
 // A Package specifies an RPM package.
 type Package struct {
-	Name    string `json:"name"`
-	Version string `json:"version,omitempty"`
+	Name    string `json:"name" toml:"name"`
+	Version string `json:"version,omitempty" toml:"version,omitempty"`
 }
 
 // A group specifies an package group.
 type Group struct {
-	Name string `json:"name"`
+	Name string `json:"name" toml:"name"`
 }
 
 // packages, modules, and groups all resolve to rpm packages right now. This
diff --git a/internal/blueprint/customizations.go b/internal/blueprint/customizations.go
index 5d20d49be..d5ccedd4b 100644
--- a/internal/blueprint/customizations.go
+++ b/internal/blueprint/customizations.go
@@ -1,66 +1,66 @@
 package blueprint
 
 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"`
+	Hostname *string                `json:"hostname,omitempty" toml:"hostname,omitempty"`
+	Kernel   *KernelCustomization   `json:"kernel,omitempty" toml:"kernel,omitempty"`
+	SSHKey   []SSHKeyCustomization  `json:"sshkey,omitempty" toml:"sshkey,omitempty"`
+	User     []UserCustomization    `json:"user,omitempty" toml:"user,omitempty"`
+	Group    []GroupCustomization   `json:"group,omitempty" toml:"group,omitempty"`
+	Timezone *TimezoneCustomization `json:"timezone,omitempty" toml:"timezone,omitempty"`
+	Locale   *LocaleCustomization   `json:"locale,omitempty" toml:"locale,omitempty"`
+	Firewall *FirewallCustomization `json:"firewall,omitempty" toml:"firewall,omitempty"`
+	Services *ServicesCustomization `json:"services,omitempty" toml:"services,omitempty"`
 }
 
 type KernelCustomization struct {
-	Append string `json:"append"`
+	Append string `json:"append" toml:"append"`
 }
 
 type SSHKeyCustomization struct {
-	User string `json:"user"`
-	Key  string `json:"key"`
+	User string `json:"user" toml:"user"`
+	Key  string `json:"key" toml:"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"`
+	Name        string   `json:"name" toml:"name"`
+	Description *string  `json:"description,omitempty" toml:"description,omitempty"`
+	Password    *string  `json:"password,omitempty" toml:"password,omitempty"`
+	Key         *string  `json:"key,omitempty" toml:"key,omitempty"`
+	Home        *string  `json:"home,omitempty" toml:"home,omitempty"`
+	Shell       *string  `json:"shell,omitempty" toml:"shell,omitempty"`
+	Groups      []string `json:"groups,omitempty" toml:"groups,omitempty"`
+	UID         *int     `json:"uid,omitempty" toml:"uid,omitempty"`
+	GID         *int     `json:"gid,omitempty" toml:"gid,omitempty"`
 }
 
 type GroupCustomization struct {
-	Name string `json:"name"`
-	GID  *int   `json:"gid,omitempty"`
+	Name string `json:"name" toml:"name"`
+	GID  *int   `json:"gid,omitempty" toml:"gid,omitempty"`
 }
 
 type TimezoneCustomization struct {
-	Timezone   *string  `json:"timezone,omitempty"`
-	NTPServers []string `json:"ntpservers,omitempty"`
+	Timezone   *string  `json:"timezone,omitempty" toml:"timezone,omitempty"`
+	NTPServers []string `json:"ntpservers,omitempty" toml:"ntpservers,omitempty"`
 }
 
 type LocaleCustomization struct {
-	Languages []string `json:"languages,omitempty"`
-	Keyboard  *string  `json:"keyboard,omitempty"`
+	Languages []string `json:"languages,omitempty" toml:"languages,omitempty"`
+	Keyboard  *string  `json:"keyboard,omitempty" toml:"keyboard,omitempty"`
 }
 
 type FirewallCustomization struct {
-	Ports    []string                       `json:"ports,omitempty"`
-	Services *FirewallServicesCustomization `json:"services,omitempty"`
+	Ports    []string                       `json:"ports,omitempty" toml:"ports,omitempty"`
+	Services *FirewallServicesCustomization `json:"services,omitempty" toml:"services,omitempty"`
 }
 
 type FirewallServicesCustomization struct {
-	Enabled  []string `json:"enabled,omitempty"`
-	Disabled []string `json:"disabled,omitempty"`
+	Enabled  []string `json:"enabled,omitempty" toml:"enabled,omitempty"`
+	Disabled []string `json:"disabled,omitempty" toml:"disabled,omitempty"`
 }
 
 type ServicesCustomization struct {
-	Enabled  []string `json:"enabled,omitempty"`
-	Disabled []string `json:"disabled,omitempty"`
+	Enabled  []string `json:"enabled,omitempty" toml:"enabled,omitempty"`
+	Disabled []string `json:"disabled,omitempty" toml:"disabled,omitempty"`
 }
 
 type CustomizationError struct {
diff --git a/internal/jobqueue/api.go b/internal/jobqueue/api.go
index 60c3d1aea..f00256fb6 100644
--- a/internal/jobqueue/api.go
+++ b/internal/jobqueue/api.go
@@ -93,12 +93,10 @@ func (api *API) addJobHandler(writer http.ResponseWriter, request *http.Request,
 	nextJob := api.store.PopCompose()
 
 	writer.WriteHeader(http.StatusCreated)
-	json.NewEncoder(writer).Encode(replyBody{nextJob.ComposeID, nextJob.Pipeline, nextJob.Targets})
+	json.NewEncoder(writer).Encode(replyBody{nextJob.ComposeID, nextJob.Pipeline, nextJob.Targets, nextJob.OutputType})
 }
 
 func (api *API) updateJobHandler(writer http.ResponseWriter, request *http.Request, params httprouter.Params) {
-	type requestBody JobStatus
-
 	contentType := request.Header["Content-Type"]
 	if len(contentType) != 1 || contentType[0] != "application/json" {
 		statusResponseError(writer, http.StatusUnsupportedMediaType)
@@ -111,14 +109,14 @@ func (api *API) updateJobHandler(writer http.ResponseWriter, request *http.Reque
 		return
 	}
 
-	var body requestBody
+	var body JobStatus
 	err = json.NewDecoder(request.Body).Decode(&body)
 	if err != nil {
 		statusResponseError(writer, http.StatusBadRequest, "invalid status: "+err.Error())
 		return
 	}
 
-	err = api.store.UpdateCompose(id, body.Status)
+	err = api.store.UpdateCompose(id, body.Status, body.Image)
 	if err != nil {
 		switch err.(type) {
 		case *store.NotFoundError:
diff --git a/internal/jobqueue/job.go b/internal/jobqueue/job.go
index 7d6c47729..fd948adb4 100644
--- a/internal/jobqueue/job.go
+++ b/internal/jobqueue/job.go
@@ -8,36 +8,40 @@ import (
 	"os/exec"
 
 	"github.com/google/uuid"
+
 	"github.com/osbuild/osbuild-composer/internal/distro"
 	"github.com/osbuild/osbuild-composer/internal/pipeline"
+	"github.com/osbuild/osbuild-composer/internal/store"
 	"github.com/osbuild/osbuild-composer/internal/target"
 	"github.com/osbuild/osbuild-composer/internal/upload/awsupload"
 )
 
 type Job struct {
-	ID       uuid.UUID          `json:"id"`
-	Pipeline *pipeline.Pipeline `json:"pipeline"`
-	Targets  []*target.Target   `json:"targets"`
+	ID         uuid.UUID          `json:"id"`
+	Pipeline   *pipeline.Pipeline `json:"pipeline"`
+	Targets    []*target.Target   `json:"targets"`
+	OutputType string             `json:"output_type"`
 }
 
 type JobStatus struct {
-	Status string `json:"status"`
+	Status string       `json:"status"`
+	Image  *store.Image `json:"image"`
 }
 
-func (job *Job) Run(d distro.Distro) (error, []error) {
+func (job *Job) Run(d distro.Distro) (*store.Image, error, []error) {
 	build := pipeline.Build{
 		Runner: d.Runner(),
 	}
 
 	buildFile, err := ioutil.TempFile("", "osbuild-worker-build-env-*")
 	if err != nil {
-		return err, nil
+		return nil, err, nil
 	}
 	defer os.Remove(buildFile.Name())
 
 	err = json.NewEncoder(buildFile).Encode(build)
 	if err != nil {
-		return err, nil
+		return nil, err, nil
 	}
 
 	cmd := exec.Command(
@@ -50,22 +54,22 @@ func (job *Job) Run(d distro.Distro) (error, []error) {
 
 	stdin, err := cmd.StdinPipe()
 	if err != nil {
-		return err, nil
+		return nil, err, nil
 	}
 
 	stdout, err := cmd.StdoutPipe()
 	if err != nil {
-		return err, nil
+		return nil, err, nil
 	}
 
 	err = cmd.Start()
 	if err != nil {
-		return err, nil
+		return nil, err, nil
 	}
 
 	err = json.NewEncoder(stdin).Encode(job.Pipeline)
 	if err != nil {
-		return err, nil
+		return nil, err, nil
 	}
 	stdin.Close()
 
@@ -75,14 +79,21 @@ func (job *Job) Run(d distro.Distro) (error, []error) {
 	}
 	err = json.NewDecoder(stdout).Decode(&result)
 	if err != nil {
-		return err, nil
+		return nil, err, nil
 	}
 
 	err = cmd.Wait()
 	if err != nil {
-		return err, nil
+		return nil, err, nil
+	}
+
+	filename, mimeType, err := d.FilenameFromType(job.OutputType)
+	if err != nil {
+		return nil, err, nil
 	}
 
+	var image store.Image
+
 	var r []error
 
 	for _, t := range job.Targets {
@@ -102,6 +113,25 @@ func (job *Job) Run(d distro.Distro) (error, []error) {
 				r = append(r, err)
 				continue
 			}
+
+			imagePath := options.Location + "/" + filename
+			file, err := os.Open(imagePath)
+			if err != nil {
+				r = append(r, err)
+				continue
+			}
+
+			fileStat, err := file.Stat()
+			if err != nil {
+				return nil, err, nil
+			}
+
+			image = store.Image{
+				Path: imagePath,
+				Mime: mimeType,
+				Size: fileStat.Size(),
+			}
+
 		case *target.AWSTargetOptions:
 			a, err := awsupload.New(options.Region, options.AccessKeyID, options.SecretAccessKey)
 			if err != nil {
@@ -132,5 +162,5 @@ func (job *Job) Run(d distro.Distro) (error, []error) {
 		r = append(r, nil)
 	}
 
-	return nil, r
+	return &image, nil, r
 }
diff --git a/internal/store/store.go b/internal/store/store.go
index 8fc57dc71..459e1d24e 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -6,11 +6,14 @@ import (
 	"crypto/sha1"
 	"encoding/hex"
 	"encoding/json"
+	"fmt"
 	"io/ioutil"
 	"log"
 	"os"
 	"path/filepath"
 	"sort"
+	"strconv"
+	"strings"
 	"sync"
 	"time"
 
@@ -48,19 +51,20 @@ type Compose struct {
 	JobCreated  time.Time            `json:"job_created"`
 	JobStarted  time.Time            `json:"job_started"`
 	JobFinished time.Time            `json:"job_finished"`
+	Image       *Image               `json:"image"`
 }
 
 // A Job contains the information about a compose a worker needs to process it.
 type Job struct {
-	ComposeID uuid.UUID
-	Pipeline  *pipeline.Pipeline
-	Targets   []*target.Target
+	ComposeID  uuid.UUID
+	Pipeline   *pipeline.Pipeline
+	Targets    []*target.Target
+	OutputType string
 }
 
 // An Image represents the image resulting from a compose.
 type Image struct {
-	File *os.File
-	Name string
+	Path string
 	Mime string
 	Size int64
 }
@@ -220,6 +224,14 @@ func (s *Store) ListBlueprints() []string {
 	return names
 }
 
+func (s *Store) GetCompose(id uuid.UUID) (Compose, bool) {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+
+	compose, exists := s.Composes[id]
+	return compose, exists
+}
+
 func (s *Store) GetAllComposes() map[uuid.UUID]Compose {
 	s.mu.RLock()
 	defer s.mu.RUnlock()
@@ -330,6 +342,21 @@ func (s *Store) GetBlueprintChanges(name string) []blueprint.Change {
 	return changes
 }
 
+func bumpVersion(str string) string {
+	v := [3]uint64{}
+	fields := strings.SplitN(str, ".", 3)
+	for i := 0; i < len(fields); i++ {
+		if n, err := strconv.ParseUint(fields[i], 10, 64); err == nil {
+			v[i] = n
+		} else {
+			// don't touch strings with invalid versions
+			return str
+		}
+	}
+
+	return fmt.Sprintf("%d.%d.%d", v[0], v[1], v[2] + 1)
+}
+
 func (s *Store) PushBlueprint(bp blueprint.Blueprint, commitMsg string) {
 	s.change(func() error {
 		hash := sha1.New()
@@ -352,6 +379,12 @@ func (s *Store) PushBlueprint(bp blueprint.Blueprint, commitMsg string) {
 			s.BlueprintsChanges[bp.Name] = make(map[string]blueprint.Change)
 		}
 		s.BlueprintsChanges[bp.Name][commit] = change
+
+		if old, ok := s.Blueprints[bp.Name]; ok {
+			if bp.Version == "" || bp.Version == old.Version {
+				bp.Version = bumpVersion(old.Version)
+			}
+		}
 		s.Blueprints[bp.Name] = bp
 		return nil
 	})
@@ -407,9 +440,10 @@ func (s *Store) PushCompose(composeID uuid.UUID, bp *blueprint.Blueprint, compos
 		return nil
 	})
 	s.pendingJobs <- Job{
-		ComposeID: composeID,
-		Pipeline:  pipeline,
-		Targets:   targets,
+		ComposeID:  composeID,
+		Pipeline:   pipeline,
+		Targets:    targets,
+		OutputType: composeType,
 	}
 
 	return nil
@@ -433,7 +467,7 @@ func (s *Store) PopCompose() Job {
 	return job
 }
 
-func (s *Store) UpdateCompose(composeID uuid.UUID, status string) error {
+func (s *Store) UpdateCompose(composeID uuid.UUID, status string, image *Image) error {
 	return s.change(func() error {
 		compose, exists := s.Composes[composeID]
 		if !exists {
@@ -460,6 +494,11 @@ func (s *Store) UpdateCompose(composeID uuid.UUID, status string) error {
 			for _, t := range compose.Targets {
 				t.Status = status
 			}
+
+			if status == "FINISHED" {
+				compose.Image = image
+			}
+
 			s.Composes[composeID] = compose
 		default:
 			return &InvalidRequestError{"invalid state transition"}
@@ -468,44 +507,6 @@ func (s *Store) UpdateCompose(composeID uuid.UUID, status string) error {
 	})
 }
 
-func (s *Store) GetImage(composeID uuid.UUID) (*Image, error) {
-	s.mu.RLock()
-	defer s.mu.RUnlock()
-
-	if compose, exists := s.Composes[composeID]; exists {
-		if compose.QueueStatus != "FINISHED" {
-			return nil, &InvalidRequestError{"compose was not finished"}
-		}
-		name, mime, err := s.distro.FilenameFromType(compose.OutputType)
-		if err != nil {
-			panic("invalid output type")
-		}
-		for _, t := range compose.Targets {
-			switch options := t.Options.(type) {
-			case *target.LocalTargetOptions:
-				file, err := os.Open(options.Location + "/" + name)
-				if err == nil {
-					fileStat, err := file.Stat()
-					if err != nil {
-						return nil, &NotFoundError{"image info could not be found"}
-					}
-					size := fileStat.Size()
-
-					return &Image{
-						File: file,
-						Name: name,
-						Mime: mime,
-						Size: size,
-					}, nil
-				}
-			}
-		}
-		return nil, &NotFoundError{"image could not be found"}
-	}
-
-	return nil, &NotFoundError{"compose could not be found"}
-}
-
 func (s *Store) PushSource(source SourceConfig) {
 	s.change(func() error {
 		s.Sources[source.Name] = source
diff --git a/internal/weldr/api.go b/internal/weldr/api.go
index aa307e712..6486a1b86 100644
--- a/internal/weldr/api.go
+++ b/internal/weldr/api.go
@@ -9,6 +9,8 @@ import (
 	"net"
 	"net/http"
 	"net/url"
+	"os"
+	"path/filepath"
 	"sort"
 	"strconv"
 	"strings"
@@ -733,7 +735,9 @@ func (api *API) blueprintsInfoHandler(writer http.ResponseWriter, request *http.
 			statusResponseError(writer, http.StatusBadRequest, errors)
 			return
 		}
-		toml.NewEncoder(writer).Encode(blueprints[0])
+		encoder := toml.NewEncoder(writer)
+		encoder.Indent = ""
+		encoder.Encode(blueprints[0])
 	} else {
 		errors := responseError{
 			ID:  "InvalidChars",
@@ -1368,21 +1372,51 @@ func (api *API) composeImageHandler(writer http.ResponseWriter, request *http.Re
 		return
 	}
 
-	image, err := api.store.GetImage(uuid)
+	compose, exists := api.store.GetCompose(uuid)
+	if !exists {
+		errors := responseError{
+			ID:  "BuildMissingFile",
+			Msg: fmt.Sprintf("Compose %s doesn't exist", uuidString),
+		}
+		statusResponseError(writer, http.StatusBadRequest, errors)
+		return
+	}
+
+	if compose.QueueStatus != "FINISHED" {
+		errors := responseError{
+			ID:  "BuildInWrongState",
+			Msg: fmt.Sprintf("Build %s is in wrong state: %s", uuidString, compose.QueueStatus),
+		}
+		statusResponseError(writer, http.StatusBadRequest, errors)
+		return
+	}
+
+	if compose.Image == nil {
+		errors := responseError{
+			ID:  "BuildMissingFile",
+			Msg: fmt.Sprintf("Compose %s doesn't have an image assigned", uuidString),
+		}
+		statusResponseError(writer, http.StatusBadRequest, errors)
+		return
+	}
+
+	imageName := filepath.Base(compose.Image.Path)
+
+	file, err := os.Open(compose.Image.Path)
 	if err != nil {
 		errors := responseError{
 			ID:  "BuildMissingFile",
-			Msg: fmt.Sprintf("Build %s is missing image file %s", uuidString, image.Name),
+			Msg: fmt.Sprintf("Build %s is missing file %s!", uuidString, imageName),
 		}
 		statusResponseError(writer, http.StatusBadRequest, errors)
 		return
 	}
 
-	writer.Header().Set("Content-Disposition", "attachment; filename="+uuid.String()+"-"+image.Name)
-	writer.Header().Set("Content-Type", image.Mime)
-	writer.Header().Set("Content-Length", fmt.Sprintf("%d", image.Size))
+	writer.Header().Set("Content-Disposition", "attachment; filename="+uuid.String()+"-"+imageName)
+	writer.Header().Set("Content-Type", compose.Image.Mime)
+	writer.Header().Set("Content-Length", fmt.Sprintf("%d", compose.Image.Size))
 
-	io.Copy(writer, image.File)
+	io.Copy(writer, file)
 }
 
 func (api *API) composeFinishedHandler(writer http.ResponseWriter, request *http.Request, params httprouter.Params) {
diff --git a/internal/weldr/compose.go b/internal/weldr/compose.go
index 80c2ccdd8..705188f34 100644
--- a/internal/weldr/compose.go
+++ b/internal/weldr/compose.go
@@ -3,6 +3,7 @@ package weldr
 import (
 	"github.com/google/uuid"
 	"github.com/osbuild/osbuild-composer/internal/store"
+	"log"
 	"sort"
 )
 
@@ -41,13 +42,13 @@ func composeToComposeEntry(id uuid.UUID, compose store.Compose, includeUploads b
 		composeEntry.JobStarted = float64(compose.JobStarted.UnixNano()) / 1000000000
 
 	case "FINISHED":
-		//image, err := s.GetImage(id)
-		//imageSize := int64(0)
-		//if err == nil {
-		//	imageSize = image.Size
-		//}
-		// TODO: this is currently broken!
-		composeEntry.ImageSize = int64(0)
+		if compose.Image != nil {
+			composeEntry.ImageSize = compose.Image.Size
+		} else {
+			log.Printf("finished compose with id %s has nil image\n", id.String())
+			composeEntry.ImageSize = 0
+		}
+
 		composeEntry.JobCreated = float64(compose.JobCreated.UnixNano()) / 1000000000
 		composeEntry.JobStarted = float64(compose.JobStarted.UnixNano()) / 1000000000
 		composeEntry.JobFinished = float64(compose.JobFinished.UnixNano()) / 1000000000


Test patch:
diff --git a/internal/jobqueue/api_test.go b/internal/jobqueue/api_test.go
index 34bdf1daf..86217ff92 100644
--- a/internal/jobqueue/api_test.go
+++ b/internal/jobqueue/api_test.go
@@ -49,7 +49,7 @@ func TestCreate(t *testing.T) {
 	}
 
 	test.TestRoute(t, api, false, "POST", "/job-queue/v1/jobs", `{}`, http.StatusCreated,
-		`{"id":"ffffffff-ffff-ffff-ffff-ffffffffffff","pipeline":{"build":{"pipeline":{"stages":[{"name":"org.osbuild.dnf","options":{"repos":[{"metalink":"https://mirrors.fedoraproject.org/metalink?repo=fedora-$releasever\u0026arch=$basearch","gpgkey":"-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFturGcBEACv0xBo91V2n0uEC2vh69ywCiSyvUgN/AQH8EZpCVtM7NyjKgKm\nbbY4G3R0M3ir1xXmvUDvK0493/qOiFrjkplvzXFTGpPTi0ypqGgxc5d0ohRA1M75\nL+0AIlXoOgHQ358/c4uO8X0JAA1NYxCkAW1KSJgFJ3RjukrfqSHWthS1d4o8fhHy\nKJKEnirE5hHqB50dafXrBfgZdaOs3C6ppRIePFe2o4vUEapMTCHFw0woQR8Ah4/R\nn7Z9G9Ln+0Cinmy0nbIDiZJ+pgLAXCOWBfDUzcOjDGKvcpoZharA07c0q1/5ojzO\n4F0Fh4g/BUmtrASwHfcIbjHyCSr1j/3Iz883iy07gJY5Yhiuaqmp0o0f9fgHkG53\n2xCU1owmACqaIBNQMukvXRDtB2GJMuKa/asTZDP6R5re+iXs7+s9ohcRRAKGyAyc\nYKIQKcaA+6M8T7/G+TPHZX6HJWqJJiYB+EC2ERblpvq9TPlLguEWcmvjbVc31nyq\nSDoO3ncFWKFmVsbQPTbP+pKUmlLfJwtb5XqxNR5GEXSwVv4I7IqBmJz1MmRafnBZ\ng0FJUtH668GnldO20XbnSVBr820F5SISMXVwCXDXEvGwwiB8Lt8PvqzXnGIFDAu3\nDlQI5sxSqpPVWSyw08ppKT2Tpmy8adiBotLfaCFl2VTHwOae48X2dMPBvQARAQAB\ntDFGZWRvcmEgKDMwKSA8ZmVkb3JhLTMwLXByaW1hcnlAZmVkb3JhcHJvamVjdC5v\ncmc+iQI4BBMBAgAiBQJbbqxnAhsPBgsJCAcDAgYVCAIJCgsEFgIDAQIeAQIXgAAK\nCRDvPBEfz8ZZudTnD/9170LL3nyTVUCFmBjT9wZ4gYnpwtKVPa/pKnxbbS+Bmmac\ng9TrT9pZbqOHrNJLiZ3Zx1Hp+8uxr3Lo6kbYwImLhkOEDrf4aP17HfQ6VYFbQZI8\nf79OFxWJ7si9+3gfzeh9UYFEqOQfzIjLWFyfnas0OnV/P+RMQ1Zr+vPRqO7AR2va\nN9wg+Xl7157dhXPCGYnGMNSoxCbpRs0JNlzvJMuAea5nTTznRaJZtK/xKsqLn51D\nK07k9MHVFXakOH8QtMCUglbwfTfIpO5YRq5imxlWbqsYWVQy1WGJFyW6hWC0+RcJ\nOx5zGtOfi4/dN+xJ+ibnbyvy/il7Qm+vyFhCYqIPyS5m2UVJUuao3eApE38k78/o\n8aQOTnFQZ+U1Sw+6woFTxjqRQBXlQm2+7Bt3bqGATg4sXXWPbmwdL87Ic+mxn/ml\nSMfQux/5k6iAu1kQhwkO2YJn9eII6HIPkW+2m5N1JsUyJQe4cbtZE5Yh3TRA0dm7\n+zoBRfCXkOW4krchbgww/ptVmzMMP7GINJdROrJnsGl5FVeid9qHzV7aZycWSma7\nCxBYB1J8HCbty5NjtD6XMYRrMLxXugvX6Q4NPPH+2NKjzX4SIDejS6JjgrP3KA3O\npMuo7ZHMfveBngv8yP+ZD/1sS6l+dfExvdaJdOdgFCnp4p3gPbw5+Lv70HrMjA==\n=BfZ/\n-----END PGP PUBLIC KEY BLOCK-----\n","checksum":"sha256:9f596e18f585bee30ac41c11fb11a83ed6b11d5b341c1cb56ca4015d7717cb97"}],"packages":["dnf","e2fsprogs","policycoreutils","qemu-img","systemd","grub2-pc","tar"],"releasever":"30","basearch":"x86_64"}}]},"runner":"org.osbuild.fedora30"},"stages":[{"name":"org.osbuild.dnf","options":{"repos":[{"metalink":"https://mirrors.fedoraproject.org/metalink?repo=fedora-$releasever\u0026arch=$basearch","gpgkey":"-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFturGcBEACv0xBo91V2n0uEC2vh69ywCiSyvUgN/AQH8EZpCVtM7NyjKgKm\nbbY4G3R0M3ir1xXmvUDvK0493/qOiFrjkplvzXFTGpPTi0ypqGgxc5d0ohRA1M75\nL+0AIlXoOgHQ358/c4uO8X0JAA1NYxCkAW1KSJgFJ3RjukrfqSHWthS1d4o8fhHy\nKJKEnirE5hHqB50dafXrBfgZdaOs3C6ppRIePFe2o4vUEapMTCHFw0woQR8Ah4/R\nn7Z9G9Ln+0Cinmy0nbIDiZJ+pgLAXCOWBfDUzcOjDGKvcpoZharA07c0q1/5ojzO\n4F0Fh4g/BUmtrASwHfcIbjHyCSr1j/3Iz883iy07gJY5Yhiuaqmp0o0f9fgHkG53\n2xCU1owmACqaIBNQMukvXRDtB2GJMuKa/asTZDP6R5re+iXs7+s9ohcRRAKGyAyc\nYKIQKcaA+6M8T7/G+TPHZX6HJWqJJiYB+EC2ERblpvq9TPlLguEWcmvjbVc31nyq\nSDoO3ncFWKFmVsbQPTbP+pKUmlLfJwtb5XqxNR5GEXSwVv4I7IqBmJz1MmRafnBZ\ng0FJUtH668GnldO20XbnSVBr820F5SISMXVwCXDXEvGwwiB8Lt8PvqzXnGIFDAu3\nDlQI5sxSqpPVWSyw08ppKT2Tpmy8adiBotLfaCFl2VTHwOae48X2dMPBvQARAQAB\ntDFGZWRvcmEgKDMwKSA8ZmVkb3JhLTMwLXByaW1hcnlAZmVkb3JhcHJvamVjdC5v\ncmc+iQI4BBMBAgAiBQJbbqxnAhsPBgsJCAcDAgYVCAIJCgsEFgIDAQIeAQIXgAAK\nCRDvPBEfz8ZZudTnD/9170LL3nyTVUCFmBjT9wZ4gYnpwtKVPa/pKnxbbS+Bmmac\ng9TrT9pZbqOHrNJLiZ3Zx1Hp+8uxr3Lo6kbYwImLhkOEDrf4aP17HfQ6VYFbQZI8\nf79OFxWJ7si9+3gfzeh9UYFEqOQfzIjLWFyfnas0OnV/P+RMQ1Zr+vPRqO7AR2va\nN9wg+Xl7157dhXPCGYnGMNSoxCbpRs0JNlzvJMuAea5nTTznRaJZtK/xKsqLn51D\nK07k9MHVFXakOH8QtMCUglbwfTfIpO5YRq5imxlWbqsYWVQy1WGJFyW6hWC0+RcJ\nOx5zGtOfi4/dN+xJ+ibnbyvy/il7Qm+vyFhCYqIPyS5m2UVJUuao3eApE38k78/o\n8aQOTnFQZ+U1Sw+6woFTxjqRQBXlQm2+7Bt3bqGATg4sXXWPbmwdL87Ic+mxn/ml\nSMfQux/5k6iAu1kQhwkO2YJn9eII6HIPkW+2m5N1JsUyJQe4cbtZE5Yh3TRA0dm7\n+zoBRfCXkOW4krchbgww/ptVmzMMP7GINJdROrJnsGl5FVeid9qHzV7aZycWSma7\nCxBYB1J8HCbty5NjtD6XMYRrMLxXugvX6Q4NPPH+2NKjzX4SIDejS6JjgrP3KA3O\npMuo7ZHMfveBngv8yP+ZD/1sS6l+dfExvdaJdOdgFCnp4p3gPbw5+Lv70HrMjA==\n=BfZ/\n-----END PGP PUBLIC KEY BLOCK-----\n","checksum":"sha256:9f596e18f585bee30ac41c11fb11a83ed6b11d5b341c1cb56ca4015d7717cb97"}],"packages":["policycoreutils","selinux-policy-targeted","kernel","firewalld","chrony","langpacks-en"],"exclude_packages":["dracut-config-rescue"],"releasever":"30","basearch":"x86_64"}},{"name":"org.osbuild.fix-bls","options":{}},{"name":"org.osbuild.locale","options":{"language":"en_US"}},{"name":"org.osbuild.grub2","options":{"root_fs_uuid":"76a22bf4-f153-4541-b6c7-0332c0dfaeac","boot_fs_uuid":"00000000-0000-0000-0000-000000000000","kernel_opts":"ro biosdevname=0 net.ifnames=0"}},{"name":"org.osbuild.selinux","options":{"file_contexts":"etc/selinux/targeted/contexts/files/file_contexts"}}],"assembler":{"name":"org.osbuild.tar","options":{"filename":"root.tar.xz"}}},"targets":[{"image_name":"","name":"org.osbuild.local","options":{"location":"/var/lib/osbuild-composer/outputs/ffffffff-ffff-ffff-ffff-ffffffffffff"},"status":"RUNNING"}]}`, "created", "uuid")
+		`{"id":"ffffffff-ffff-ffff-ffff-ffffffffffff","output_type":"tar","pipeline":{"build":{"pipeline":{"stages":[{"name":"org.osbuild.dnf","options":{"repos":[{"metalink":"https://mirrors.fedoraproject.org/metalink?repo=fedora-$releasever\u0026arch=$basearch","gpgkey":"-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFturGcBEACv0xBo91V2n0uEC2vh69ywCiSyvUgN/AQH8EZpCVtM7NyjKgKm\nbbY4G3R0M3ir1xXmvUDvK0493/qOiFrjkplvzXFTGpPTi0ypqGgxc5d0ohRA1M75\nL+0AIlXoOgHQ358/c4uO8X0JAA1NYxCkAW1KSJgFJ3RjukrfqSHWthS1d4o8fhHy\nKJKEnirE5hHqB50dafXrBfgZdaOs3C6ppRIePFe2o4vUEapMTCHFw0woQR8Ah4/R\nn7Z9G9Ln+0Cinmy0nbIDiZJ+pgLAXCOWBfDUzcOjDGKvcpoZharA07c0q1/5ojzO\n4F0Fh4g/BUmtrASwHfcIbjHyCSr1j/3Iz883iy07gJY5Yhiuaqmp0o0f9fgHkG53\n2xCU1owmACqaIBNQMukvXRDtB2GJMuKa/asTZDP6R5re+iXs7+s9ohcRRAKGyAyc\nYKIQKcaA+6M8T7/G+TPHZX6HJWqJJiYB+EC2ERblpvq9TPlLguEWcmvjbVc31nyq\nSDoO3ncFWKFmVsbQPTbP+pKUmlLfJwtb5XqxNR5GEXSwVv4I7IqBmJz1MmRafnBZ\ng0FJUtH668GnldO20XbnSVBr820F5SISMXVwCXDXEvGwwiB8Lt8PvqzXnGIFDAu3\nDlQI5sxSqpPVWSyw08ppKT2Tpmy8adiBotLfaCFl2VTHwOae48X2dMPBvQARAQAB\ntDFGZWRvcmEgKDMwKSA8ZmVkb3JhLTMwLXByaW1hcnlAZmVkb3JhcHJvamVjdC5v\ncmc+iQI4BBMBAgAiBQJbbqxnAhsPBgsJCAcDAgYVCAIJCgsEFgIDAQIeAQIXgAAK\nCRDvPBEfz8ZZudTnD/9170LL3nyTVUCFmBjT9wZ4gYnpwtKVPa/pKnxbbS+Bmmac\ng9TrT9pZbqOHrNJLiZ3Zx1Hp+8uxr3Lo6kbYwImLhkOEDrf4aP17HfQ6VYFbQZI8\nf79OFxWJ7si9+3gfzeh9UYFEqOQfzIjLWFyfnas0OnV/P+RMQ1Zr+vPRqO7AR2va\nN9wg+Xl7157dhXPCGYnGMNSoxCbpRs0JNlzvJMuAea5nTTznRaJZtK/xKsqLn51D\nK07k9MHVFXakOH8QtMCUglbwfTfIpO5YRq5imxlWbqsYWVQy1WGJFyW6hWC0+RcJ\nOx5zGtOfi4/dN+xJ+ibnbyvy/il7Qm+vyFhCYqIPyS5m2UVJUuao3eApE38k78/o\n8aQOTnFQZ+U1Sw+6woFTxjqRQBXlQm2+7Bt3bqGATg4sXXWPbmwdL87Ic+mxn/ml\nSMfQux/5k6iAu1kQhwkO2YJn9eII6HIPkW+2m5N1JsUyJQe4cbtZE5Yh3TRA0dm7\n+zoBRfCXkOW4krchbgww/ptVmzMMP7GINJdROrJnsGl5FVeid9qHzV7aZycWSma7\nCxBYB1J8HCbty5NjtD6XMYRrMLxXugvX6Q4NPPH+2NKjzX4SIDejS6JjgrP3KA3O\npMuo7ZHMfveBngv8yP+ZD/1sS6l+dfExvdaJdOdgFCnp4p3gPbw5+Lv70HrMjA==\n=BfZ/\n-----END PGP PUBLIC KEY BLOCK-----\n","checksum":"sha256:9f596e18f585bee30ac41c11fb11a83ed6b11d5b341c1cb56ca4015d7717cb97"}],"packages":["dnf","e2fsprogs","policycoreutils","qemu-img","systemd","grub2-pc","tar"],"releasever":"30","basearch":"x86_64"}}]},"runner":"org.osbuild.fedora30"},"stages":[{"name":"org.osbuild.dnf","options":{"repos":[{"metalink":"https://mirrors.fedoraproject.org/metalink?repo=fedora-$releasever\u0026arch=$basearch","gpgkey":"-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFturGcBEACv0xBo91V2n0uEC2vh69ywCiSyvUgN/AQH8EZpCVtM7NyjKgKm\nbbY4G3R0M3ir1xXmvUDvK0493/qOiFrjkplvzXFTGpPTi0ypqGgxc5d0ohRA1M75\nL+0AIlXoOgHQ358/c4uO8X0JAA1NYxCkAW1KSJgFJ3RjukrfqSHWthS1d4o8fhHy\nKJKEnirE5hHqB50dafXrBfgZdaOs3C6ppRIePFe2o4vUEapMTCHFw0woQR8Ah4/R\nn7Z9G9Ln+0Cinmy0nbIDiZJ+pgLAXCOWBfDUzcOjDGKvcpoZharA07c0q1/5ojzO\n4F0Fh4g/BUmtrASwHfcIbjHyCSr1j/3Iz883iy07gJY5Yhiuaqmp0o0f9fgHkG53\n2xCU1owmACqaIBNQMukvXRDtB2GJMuKa/asTZDP6R5re+iXs7+s9ohcRRAKGyAyc\nYKIQKcaA+6M8T7/G+TPHZX6HJWqJJiYB+EC2ERblpvq9TPlLguEWcmvjbVc31nyq\nSDoO3ncFWKFmVsbQPTbP+pKUmlLfJwtb5XqxNR5GEXSwVv4I7IqBmJz1MmRafnBZ\ng0FJUtH668GnldO20XbnSVBr820F5SISMXVwCXDXEvGwwiB8Lt8PvqzXnGIFDAu3\nDlQI5sxSqpPVWSyw08ppKT2Tpmy8adiBotLfaCFl2VTHwOae48X2dMPBvQARAQAB\ntDFGZWRvcmEgKDMwKSA8ZmVkb3JhLTMwLXByaW1hcnlAZmVkb3JhcHJvamVjdC5v\ncmc+iQI4BBMBAgAiBQJbbqxnAhsPBgsJCAcDAgYVCAIJCgsEFgIDAQIeAQIXgAAK\nCRDvPBEfz8ZZudTnD/9170LL3nyTVUCFmBjT9wZ4gYnpwtKVPa/pKnxbbS+Bmmac\ng9TrT9pZbqOHrNJLiZ3Zx1Hp+8uxr3Lo6kbYwImLhkOEDrf4aP17HfQ6VYFbQZI8\nf79OFxWJ7si9+3gfzeh9UYFEqOQfzIjLWFyfnas0OnV/P+RMQ1Zr+vPRqO7AR2va\nN9wg+Xl7157dhXPCGYnGMNSoxCbpRs0JNlzvJMuAea5nTTznRaJZtK/xKsqLn51D\nK07k9MHVFXakOH8QtMCUglbwfTfIpO5YRq5imxlWbqsYWVQy1WGJFyW6hWC0+RcJ\nOx5zGtOfi4/dN+xJ+ibnbyvy/il7Qm+vyFhCYqIPyS5m2UVJUuao3eApE38k78/o\n8aQOTnFQZ+U1Sw+6woFTxjqRQBXlQm2+7Bt3bqGATg4sXXWPbmwdL87Ic+mxn/ml\nSMfQux/5k6iAu1kQhwkO2YJn9eII6HIPkW+2m5N1JsUyJQe4cbtZE5Yh3TRA0dm7\n+zoBRfCXkOW4krchbgww/ptVmzMMP7GINJdROrJnsGl5FVeid9qHzV7aZycWSma7\nCxBYB1J8HCbty5NjtD6XMYRrMLxXugvX6Q4NPPH+2NKjzX4SIDejS6JjgrP3KA3O\npMuo7ZHMfveBngv8yP+ZD/1sS6l+dfExvdaJdOdgFCnp4p3gPbw5+Lv70HrMjA==\n=BfZ/\n-----END PGP PUBLIC KEY BLOCK-----\n","checksum":"sha256:9f596e18f585bee30ac41c11fb11a83ed6b11d5b341c1cb56ca4015d7717cb97"}],"packages":["policycoreutils","selinux-policy-targeted","kernel","firewalld","chrony","langpacks-en"],"exclude_packages":["dracut-config-rescue"],"releasever":"30","basearch":"x86_64"}},{"name":"org.osbuild.fix-bls","options":{}},{"name":"org.osbuild.locale","options":{"language":"en_US"}},{"name":"org.osbuild.grub2","options":{"root_fs_uuid":"76a22bf4-f153-4541-b6c7-0332c0dfaeac","boot_fs_uuid":"00000000-0000-0000-0000-000000000000","kernel_opts":"ro biosdevname=0 net.ifnames=0"}},{"name":"org.osbuild.selinux","options":{"file_contexts":"etc/selinux/targeted/contexts/files/file_contexts"}}],"assembler":{"name":"org.osbuild.tar","options":{"filename":"root.tar.xz"}}},"targets":[{"image_name":"","name":"org.osbuild.local","options":{"location":"/var/lib/osbuild-composer/outputs/ffffffff-ffff-ffff-ffff-ffffffffffff"},"status":"RUNNING"}]}`, "created", "uuid")
 }
 
 func testUpdateTransition(t *testing.T, from, to string, expectedStatus int) {
diff --git a/internal/store/store_test.go b/internal/store/store_test.go
new file mode 100644
index 000000000..be7b3a4b3
--- /dev/null
+++ b/internal/store/store_test.go
@@ -0,0 +1,30 @@
+package store
+
+import (
+	"testing"
+)
+
+func TestBumpVersion(t *testing.T) {
+	cases := []struct {
+		Version  string
+		Expected string
+	}{
+		{"", ""},
+		{"0", "0.0.1"},
+		{"0.0", "0.0.1"},
+		{"0.0.0", "0.0.1"},
+		{"2.1.3", "2.1.4"},
+
+		// don't touch invalid version strings
+		{"0.0.0.0", "0.0.0.0"},
+		{"0.a.0", "0.a.0"},
+		{"foo", "foo"},
+	}
+
+	for _, c := range cases {
+		result := bumpVersion(c.Version)
+		if result != c.Expected {
+			t.Errorf("bumpVersion(%#v) is expected to return %#v, but instead returned %#v", c.Version, c.Expected, result)
+		}
+	}
+}
diff --git a/internal/weldr/api_test.go b/internal/weldr/api_test.go
index d3f244b75..998ef7f85 100644
--- a/internal/weldr/api_test.go
+++ b/internal/weldr/api_test.go
@@ -160,8 +160,6 @@ func TestBlueprintsInfoToml(t *testing.T) {
 		t.Fatalf("error decoding toml file: %v", err)
 	}
 
-	t.Logf("%v", got)
-
 	expected := blueprint.Blueprint{
 		Name:        "test1",
 		Description: "Test",
@@ -169,8 +167,6 @@ func TestBlueprintsInfoToml(t *testing.T) {
 		Packages: []blueprint.Package{
 			{"httpd", "2.4.*"},
 		},
-		Groups:  []blueprint.Group{},
-		Modules: []blueprint.Package{},
 	}
 	if diff := cmp.Diff(got, expected); diff != "" {
 		t.Fatalf("received unexpected blueprint: %s", diff)
@@ -184,7 +180,7 @@ func TestBlueprintsFreeze(t *testing.T) {
 		ExpectedStatus int
 		ExpectedJSON   string
 	}{
-		{rpmmd_mock.BaseFixture, "/api/v0/blueprints/freeze/test", http.StatusOK, `{"blueprints":[{"blueprint":{"name":"test","description":"Test","version":"0.0.0","packages":[{"name":"dep-package1","version":"1.33-2.fc30.x86_64"}],"modules":[],"groups":[]}}],"errors":[]}`},
+		{rpmmd_mock.BaseFixture, "/api/v0/blueprints/freeze/test", http.StatusOK, `{"blueprints":[{"blueprint":{"name":"test","description":"Test","version":"0.0.1","packages":[{"name":"dep-package1","version":"1.33-2.fc30.x86_64"}],"modules":[],"groups":[]}}],"errors":[]}`},
 	}
 
 	for _, c := range cases {