Repository: osbuild/osbuild-composer
Pull request #972 (2020-09-08 12:03:27)

image-tests: don't use random names for CI artifacts
When using random names for artifacts like AWS snapshots, or Azure
images, it becomes hard to clean them up in case of CI failure. See this
issue for more details:
https://github.com/osbuild/osbuild-composer/issues/942

This PR introduces predictable names so that we can easily determine
which artifact belongs to which PR and therefore we can decide to wipe
all resources that are not needed any more.

msehnout: @larskarlitski the PR is again ready for review. RHEL8.3 stages fail due to corrupted mock repo and I don't know what is wrong with codecov, but I can't see any errors in the UI.
msehnout: > The corrupt repo error seems to be gone from the latest log, but vhd-boot is failing. Is that unrelated?

This seems related, I'll check it.

Patch:
diff --git a/cmd/osbuild-worker/main.go b/cmd/osbuild-worker/main.go
index dafe2a1cf..909fdcdc6 100644
--- a/cmd/osbuild-worker/main.go
+++ b/cmd/osbuild-worker/main.go
@@ -7,7 +7,6 @@ import (
 	"errors"
 	"flag"
 	"fmt"
-	"io"
 	"io/ioutil"
 	"log"
 	"os"
@@ -67,7 +66,7 @@ func (e *TargetsError) Error() string {
 	return errString
 }
 
-func RunJob(job *worker.Job, store string, uploadFunc func(uuid.UUID, string, io.Reader) error) (*osbuild.Result, error) {
+func RunJob(job worker.Job, store string) (*osbuild.Result, error) {
 	outputDirectory, err := ioutil.TempDir("/var/tmp", "osbuild-worker-*")
 	if err != nil {
 		return nil, fmt.Errorf("error creating temporary output directory: %v", err)
@@ -79,14 +78,19 @@ func RunJob(job *worker.Job, store string, uploadFunc func(uuid.UUID, string, io
 		}
 	}()
 
-	result, err := RunOSBuild(job.Manifest, store, outputDirectory, os.Stderr)
+	manifest, targets, err := job.OSBuildArgs()
+	if err != nil {
+		return nil, err
+	}
+
+	result, err := RunOSBuild(manifest, store, outputDirectory, os.Stderr)
 	if err != nil {
 		return nil, err
 	}
 
 	var r []error
 
-	for _, t := range job.Targets {
+	for _, t := range targets {
 		switch options := t.Options.(type) {
 		case *target.LocalTargetOptions:
 			var f *os.File
@@ -105,7 +109,7 @@ func RunJob(job *worker.Job, store string, uploadFunc func(uuid.UUID, string, io
 				}
 			}
 
-			err = uploadFunc(job.Id, options.Filename, f)
+			err = job.UploadArtifact(options.Filename, f)
 			if err != nil {
 				r = append(r, err)
 				continue
@@ -119,18 +123,19 @@ func RunJob(job *worker.Job, store string, uploadFunc func(uuid.UUID, string, io
 				continue
 			}
 
-			if options.Key == "" {
-				options.Key = job.Id.String()
+			key := options.Key
+			if key == "" {
+				key = uuid.New().String()
 			}
 
-			_, err = a.Upload(path.Join(outputDirectory, options.Filename), options.Bucket, options.Key)
+			_, err = a.Upload(path.Join(outputDirectory, options.Filename), options.Bucket, key)
 			if err != nil {
 				r = append(r, err)
 				continue
 			}
 
 			/* TODO: communicate back the AMI */
-			_, err = a.Register(t.ImageName, options.Bucket, options.Key)
+			_, err = a.Register(t.ImageName, options.Bucket, key)
 			if err != nil {
 				r = append(r, err)
 				continue
@@ -180,11 +185,16 @@ func RunJob(job *worker.Job, store string, uploadFunc func(uuid.UUID, string, io
 // It would be cleaner to kill the osbuild process using (`exec.CommandContext`
 // or similar), but osbuild does not currently support this. Exiting here will
 // make systemd clean up the whole cgroup and restart this service.
-func WatchJob(ctx context.Context, client *worker.Client, job *worker.Job) {
+func WatchJob(ctx context.Context, job worker.Job) {
 	for {
 		select {
 		case <-time.After(15 * time.Second):
-			if client.JobCanceled(job) {
+			canceled, err := job.Canceled()
+			if err != nil {
+				log.Printf("Error fetching job status: %v", err)
+				os.Exit(0)
+			}
+			if canceled {
 				log.Println("Job was canceled. Exiting.")
 				os.Exit(0)
 			}
@@ -238,18 +248,18 @@ func main() {
 
 	for {
 		fmt.Println("Waiting for a new job...")
-		job, err := client.AddJob()
+		job, err := client.RequestJob()
 		if err != nil {
 			log.Fatal(err)
 		}
 
-		fmt.Printf("Running job %s\n", job.Id)
+		fmt.Printf("Running job %v\n", job.Id())
 
 		ctx, cancel := context.WithCancel(context.Background())
-		go WatchJob(ctx, client, job)
+		go WatchJob(ctx, job)
 
 		var status common.ImageBuildState
-		result, err := RunJob(job, store, client.UploadImage)
+		result, err := RunJob(job, store)
 		if err != nil {
 			log.Printf("  Job failed: %v", err)
 			status = common.IBFailed
@@ -275,14 +285,14 @@ func main() {
 			// flag to indicate all error kinds.
 			result.Success = false
 		} else {
-			log.Printf("  🎉 Job completed successfully: %s", job.Id)
+			log.Printf("  🎉 Job completed successfully: %v", job.Id())
 			status = common.IBFinished
 		}
 
 		// signal to WatchJob() that it can stop watching
 		cancel()
 
-		err = client.UpdateJob(job, status, result)
+		err = job.Update(status, result)
 		if err != nil {
 			log.Fatalf("Error reporting job result: %v", err)
 		}
diff --git a/go.mod b/go.mod
index a7fab6fb0..83853e4d1 100644
--- a/go.mod
+++ b/go.mod
@@ -5,7 +5,7 @@ go 1.13
 require (
 	github.com/Azure/azure-sdk-for-go v41.3.0+incompatible
 	github.com/Azure/azure-storage-blob-go v0.8.0
-	github.com/Azure/go-autorest/autorest v0.10.0 // indirect
+	github.com/Azure/go-autorest/autorest v0.10.0
 	github.com/Azure/go-autorest/autorest/azure/auth v0.4.2
 	github.com/Azure/go-autorest/autorest/to v0.3.0 // indirect
 	github.com/Azure/go-autorest/autorest/validation v0.2.0 // indirect
diff --git a/internal/boot/aws.go b/internal/boot/aws.go
index 0928d67b1..d161bd477 100644
--- a/internal/boot/aws.go
+++ b/internal/boot/aws.go
@@ -178,7 +178,7 @@ func DeleteEC2Image(e *ec2.EC2, imageDesc *imageDescription) error {
 
 // WithBootedImageInEC2 runs the function f in the context of booted
 // image in AWS EC2
-func WithBootedImageInEC2(e *ec2.EC2, imageDesc *imageDescription, publicKey string, f func(address string) error) (retErr error) {
+func WithBootedImageInEC2(e *ec2.EC2, securityGroupName string, imageDesc *imageDescription, publicKey string, f func(address string) error) (retErr error) {
 	// generate user data with given public key
 	userData, err := CreateUserData(publicKey)
 	if err != nil {
@@ -187,12 +187,6 @@ func WithBootedImageInEC2(e *ec2.EC2, imageDesc *imageDescription, publicKey str
 
 	// Security group must be now generated, because by default
 	// all traffic to EC2 instance is filtered.
-
-	securityGroupName, err := GenerateRandomString("osbuild-image-tests-security-group-")
-	if err != nil {
-		return fmt.Errorf("cannot generate a random name for the image: %#v", err)
-	}
-
 	// Firstly create a security group
 	securityGroup, err := e.CreateSecurityGroup(&ec2.CreateSecurityGroupInput{
 		GroupName:   aws.String(securityGroupName),
diff --git a/internal/boot/helpers.go b/internal/boot/helpers.go
index f3603cfca..a78647fd6 100644
--- a/internal/boot/helpers.go
+++ b/internal/boot/helpers.go
@@ -7,8 +7,6 @@ import (
 	"os"
 	"syscall"
 	"time"
-
-	"github.com/google/uuid"
 )
 
 // durationMin returns the smaller of two given durations
@@ -52,14 +50,3 @@ func killProcessCleanly(process *os.Process, timeout time.Duration) error {
 
 	return process.Kill()
 }
-
-// GenerateRandomString generates a new random string with specified prefix.
-// The random part is based on UUID.
-func GenerateRandomString(prefix string) (string, error) {
-	id, err := uuid.NewRandom()
-	if err != nil {
-		return "", err
-	}
-
-	return prefix + id.String(), nil
-}
diff --git a/internal/worker/api/api.gen.go b/internal/worker/api/api.gen.go
index 1c3c20db5..b2bebfcd0 100644
--- a/internal/worker/api/api.gen.go
+++ b/internal/worker/api/api.gen.go
@@ -4,784 +4,46 @@
 package api
 
 import (
-	"bytes"
-	"context"
-	"encoding/json"
 	"fmt"
 	"github.com/deepmap/oapi-codegen/pkg/runtime"
 	"github.com/labstack/echo/v4"
-	"io"
-	"io/ioutil"
 	"net/http"
-	"net/url"
-	"strings"
 )
 
-// PostJobQueueV1JobsJSONBody defines parameters for PostJobQueueV1Jobs.
-type PostJobQueueV1JobsJSONBody map[string]interface{}
-
-// PatchJobQueueV1JobsJobIdJSONBody defines parameters for PatchJobQueueV1JobsJobId.
-type PatchJobQueueV1JobsJobIdJSONBody struct {
-	Result interface{} `json:"result"`
-	Status string      `json:"status"`
-}
-
-// PostJobQueueV1JobsRequestBody defines body for PostJobQueueV1Jobs for application/json ContentType.
-type PostJobQueueV1JobsJSONRequestBody PostJobQueueV1JobsJSONBody
-
-// PatchJobQueueV1JobsJobIdRequestBody defines body for PatchJobQueueV1JobsJobId for application/json ContentType.
-type PatchJobQueueV1JobsJobIdJSONRequestBody PatchJobQueueV1JobsJobIdJSONBody
-
-// RequestEditorFn  is the function signature for the RequestEditor callback function
-type RequestEditorFn func(ctx context.Context, req *http.Request) error
-
-// Doer performs HTTP requests.
-//
-// The standard http.Client implements this interface.
-type HttpRequestDoer interface {
-	Do(req *http.Request) (*http.Response, error)
-}
-
-// Client which conforms to the OpenAPI3 specification for this service.
-type Client struct {
-	// The endpoint of the server conforming to this interface, with scheme,
-	// https://api.deepmap.com for example.
-	Server string
-
-	// Doer for performing requests, typically a *http.Client with any
-	// customized settings, such as certificate chains.
-	Client HttpRequestDoer
-
-	// A callback for modifying requests which are generated before sending over
-	// the network.
-	RequestEditor RequestEditorFn
-}
-
-// ClientOption allows setting custom parameters during construction
-type ClientOption func(*Client) error
-
-// Creates a new Client, with reasonable defaults
-func NewClient(server string, opts ...ClientOption) (*Client, error) {
-	// create a client with sane default values
-	client := Client{
-		Server: server,
-	}
-	// mutate client and add all optional params
-	for _, o := range opts {
-		if err := o(&client); err != nil {
-			return nil, err
-		}
-	}
-	// ensure the server URL always has a trailing slash
-	if !strings.HasSuffix(client.Server, "/") {
-		client.Server += "/"
-	}
-	// create httpClient, if not already present
-	if client.Client == nil {
-		client.Client = http.DefaultClient
-	}
-	return &client, nil
-}
-
-// WithHTTPClient allows overriding the default Doer, which is
-// automatically created using http.Client. This is useful for tests.
-func WithHTTPClient(doer HttpRequestDoer) ClientOption {
-	return func(c *Client) error {
-		c.Client = doer
-		return nil
-	}
-}
-
-// WithRequestEditorFn allows setting up a callback function, which will be
-// called right before sending the request. This can be used to mutate the request.
-func WithRequestEditorFn(fn RequestEditorFn) ClientOption {
-	return func(c *Client) error {
-		c.RequestEditor = fn
-		return nil
-	}
-}
-
-// The interface specification for the client above.
-type ClientInterface interface {
-	// PostJobQueueV1Jobs request  with any body
-	PostJobQueueV1JobsWithBody(ctx context.Context, contentType string, body io.Reader) (*http.Response, error)
-
-	PostJobQueueV1Jobs(ctx context.Context, body PostJobQueueV1JobsJSONRequestBody) (*http.Response, error)
-
-	// GetJobQueueV1JobsJobId request
-	GetJobQueueV1JobsJobId(ctx context.Context, jobId string) (*http.Response, error)
-
-	// PatchJobQueueV1JobsJobId request  with any body
-	PatchJobQueueV1JobsJobIdWithBody(ctx context.Context, jobId string, contentType string, body io.Reader) (*http.Response, error)
-
-	PatchJobQueueV1JobsJobId(ctx context.Context, jobId string, body PatchJobQueueV1JobsJobIdJSONRequestBody) (*http.Response, error)
-
-	// PostJobQueueV1JobsJobIdArtifactsName request  with any body
-	PostJobQueueV1JobsJobIdArtifactsNameWithBody(ctx context.Context, jobId string, name string, contentType string, body io.Reader) (*http.Response, error)
-
-	// GetStatus request
-	GetStatus(ctx context.Context) (*http.Response, error)
-}
-
-func (c *Client) PostJobQueueV1JobsWithBody(ctx context.Context, contentType string, body io.Reader) (*http.Response, error) {
-	req, err := NewPostJobQueueV1JobsRequestWithBody(c.Server, contentType, body)
-	if err != nil {
-		return nil, err
-	}
-	req = req.WithContext(ctx)
-	if c.RequestEditor != nil {
-		err = c.RequestEditor(ctx, req)
-		if err != nil {
-			return nil, err
-		}
-	}
-	return c.Client.Do(req)
-}
-
-func (c *Client) PostJobQueueV1Jobs(ctx context.Context, body PostJobQueueV1JobsJSONRequestBody) (*http.Response, error) {
-	req, err := NewPostJobQueueV1JobsRequest(c.Server, body)
-	if err != nil {
-		return nil, err
-	}
-	req = req.WithContext(ctx)
-	if c.RequestEditor != nil {
-		err = c.RequestEditor(ctx, req)
-		if err != nil {
-			return nil, err
-		}
-	}
-	return c.Client.Do(req)
-}
-
-func (c *Client) GetJobQueueV1JobsJobId(ctx context.Context, jobId string) (*http.Response, error) {
-	req, err := NewGetJobQueueV1JobsJobIdRequest(c.Server, jobId)
-	if err != nil {
-		return nil, err
-	}
-	req = req.WithContext(ctx)
-	if c.RequestEditor != nil {
-		err = c.RequestEditor(ctx, req)
-		if err != nil {
-			return nil, err
-		}
-	}
-	return c.Client.Do(req)
-}
-
-func (c *Client) PatchJobQueueV1JobsJobIdWithBody(ctx context.Context, jobId string, contentType string, body io.Reader) (*http.Response, error) {
-	req, err := NewPatchJobQueueV1JobsJobIdRequestWithBody(c.Server, jobId, contentType, body)
-	if err != nil {
-		return nil, err
-	}
-	req = req.WithContext(ctx)
-	if c.RequestEditor != nil {
-		err = c.RequestEditor(ctx, req)
-		if err != nil {
-			return nil, err
-		}
-	}
-	return c.Client.Do(req)
-}
-
-func (c *Client) PatchJobQueueV1JobsJobId(ctx context.Context, jobId string, body PatchJobQueueV1JobsJobIdJSONRequestBody) (*http.Response, error) {
-	req, err := NewPatchJobQueueV1JobsJobIdRequest(c.Server, jobId, body)
-	if err != nil {
-		return nil, err
-	}
-	req = req.WithContext(ctx)
-	if c.RequestEditor != nil {
-		err = c.RequestEditor(ctx, req)
-		if err != nil {
-			return nil, err
-		}
-	}
-	return c.Client.Do(req)
+// Error defines model for Error.
+type Error struct {
+	Message string `json:"message"`
 }
 
-func (c *Client) PostJobQueueV1JobsJobIdArtifactsNameWithBody(ctx context.Context, jobId string, name string, contentType string, body io.Reader) (*http.Response, error) {
-	req, err := NewPostJobQueueV1JobsJobIdArtifactsNameRequestWithBody(c.Server, jobId, name, contentType, body)
-	if err != nil {
-		return nil, err
-	}
-	req = req.WithContext(ctx)
-	if c.RequestEditor != nil {
-		err = c.RequestEditor(ctx, req)
-		if err != nil {
-			return nil, err
-		}
-	}
-	return c.Client.Do(req)
-}
+// RequestJobJSONBody defines parameters for RequestJob.
+type RequestJobJSONBody map[string]interface{}
 
-func (c *Client) GetStatus(ctx context.Context) (*http.Response, error) {
-	req, err := NewGetStatusRequest(c.Server)
-	if err != nil {
-		return nil, err
-	}
-	req = req.WithContext(ctx)
-	if c.RequestEditor != nil {
-		err = c.RequestEditor(ctx, req)
-		if err != nil {
-			return nil, err
-		}
-	}
-	return c.Client.Do(req)
-}
-
-// NewPostJobQueueV1JobsRequest calls the generic PostJobQueueV1Jobs builder with application/json body
-func NewPostJobQueueV1JobsRequest(server string, body PostJobQueueV1JobsJSONRequestBody) (*http.Request, error) {
-	var bodyReader io.Reader
-	buf, err := json.Marshal(body)
-	if err != nil {
-		return nil, err
-	}
-	bodyReader = bytes.NewReader(buf)
-	return NewPostJobQueueV1JobsRequestWithBody(server, "application/json", bodyReader)
-}
-
-// NewPostJobQueueV1JobsRequestWithBody generates requests for PostJobQueueV1Jobs with any type of body
-func NewPostJobQueueV1JobsRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) {
-	var err error
-
-	queryUrl, err := url.Parse(server)
-	if err != nil {
-		return nil, err
-	}
-
-	basePath := fmt.Sprintf("/job-queue/v1/jobs")
-	if basePath[0] == '/' {
-		basePath = basePath[1:]
-	}
-
-	queryUrl, err = queryUrl.Parse(basePath)
-	if err != nil {
-		return nil, err
-	}
-
-	req, err := http.NewRequest("POST", queryUrl.String(), body)
-	if err != nil {
-		return nil, err
-	}
-
-	req.Header.Add("Content-Type", contentType)
-	return req, nil
-}
-
-// NewGetJobQueueV1JobsJobIdRequest generates requests for GetJobQueueV1JobsJobId
-func NewGetJobQueueV1JobsJobIdRequest(server string, jobId string) (*http.Request, error) {
-	var err error
-
-	var pathParam0 string
-
-	pathParam0, err = runtime.StyleParam("simple", false, "job_id", jobId)
-	if err != nil {
-		return nil, err
-	}
-
-	queryUrl, err := url.Parse(server)
-	if err != nil {
-		return nil, err
-	}
-
-	basePath := fmt.Sprintf("/job-queue/v1/jobs/%s", pathParam0)
-	if basePath[0] == '/' {
-		basePath = basePath[1:]
-	}
-
-	queryUrl, err = queryUrl.Parse(basePath)
-	if err != nil {
-		return nil, err
-	}
-
-	req, err := http.NewRequest("GET", queryUrl.String(), nil)
-	if err != nil {
-		return nil, err
-	}
-
-	return req, nil
-}
-
-// NewPatchJobQueueV1JobsJobIdRequest calls the generic PatchJobQueueV1JobsJobId builder with application/json body
-func NewPatchJobQueueV1JobsJobIdRequest(server string, jobId string, body PatchJobQueueV1JobsJobIdJSONRequestBody) (*http.Request, error) {
-	var bodyReader io.Reader
-	buf, err := json.Marshal(body)
-	if err != nil {
-		return nil, err
-	}
-	bodyReader = bytes.NewReader(buf)
-	return NewPatchJobQueueV1JobsJobIdRequestWithBody(server, jobId, "application/json", bodyReader)
-}
-
-// NewPatchJobQueueV1JobsJobIdRequestWithBody generates requests for PatchJobQueueV1JobsJobId with any type of body
-func NewPatchJobQueueV1JobsJobIdRequestWithBody(server string, jobId string, contentType string, body io.Reader) (*http.Request, error) {
-	var err error
-
-	var pathParam0 string
-
-	pathParam0, err = runtime.StyleParam("simple", false, "job_id", jobId)
-	if err != nil {
-		return nil, err
-	}
-
-	queryUrl, err := url.Parse(server)
-	if err != nil {
-		return nil, err
-	}
-
-	basePath := fmt.Sprintf("/job-queue/v1/jobs/%s", pathParam0)
-	if basePath[0] == '/' {
-		basePath = basePath[1:]
-	}
-
-	queryUrl, err = queryUrl.Parse(basePath)
-	if err != nil {
-		return nil, err
-	}
-
-	req, err := http.NewRequest("PATCH", queryUrl.String(), body)
-	if err != nil {
-		return nil, err
-	}
-
-	req.Header.Add("Content-Type", contentType)
-	return req, nil
-}
-
-// NewPostJobQueueV1JobsJobIdArtifactsNameRequestWithBody generates requests for PostJobQueueV1JobsJobIdArtifactsName with any type of body
-func NewPostJobQueueV1JobsJobIdArtifactsNameRequestWithBody(server string, jobId string, name string, contentType string, body io.Reader) (*http.Request, error) {
-	var err error
-
-	var pathParam0 string
-
-	pathParam0, err = runtime.StyleParam("simple", false, "job_id", jobId)
-	if err != nil {
-		return nil, err
-	}
-
-	var pathParam1 string
-
-	pathParam1, err = runtime.StyleParam("simple", false, "name", name)
-	if err != nil {
-		return nil, err
-	}
-
-	queryUrl, err := url.Parse(server)
-	if err != nil {
-		return nil, err
-	}
-
-	basePath := fmt.Sprintf("/job-queue/v1/jobs/%s/artifacts/%s", pathParam0, pathParam1)
-	if basePath[0] == '/' {
-		basePath = basePath[1:]
-	}
-
-	queryUrl, err = queryUrl.Parse(basePath)
-	if err != nil {
-		return nil, err
-	}
-
-	req, err := http.NewRequest("POST", queryUrl.String(), body)
-	if err != nil {
-		return nil, err
-	}
-
-	req.Header.Add("Content-Type", contentType)
-	return req, nil
-}
-
-// NewGetStatusRequest generates requests for GetStatus
-func NewGetStatusRequest(server string) (*http.Request, error) {
-	var err error
-
-	queryUrl, err := url.Parse(server)
-	if err != nil {
-		return nil, err
-	}
-
-	basePath := fmt.Sprintf("/status")
-	if basePath[0] == '/' {
-		basePath = basePath[1:]
-	}
-
-	queryUrl, err = queryUrl.Parse(basePath)
-	if err != nil {
-		return nil, err
-	}
-
-	req, err := http.NewRequest("GET", queryUrl.String(), nil)
-	if err != nil {
-		return nil, err
-	}
-
-	return req, nil
-}
-
-// ClientWithResponses builds on ClientInterface to offer response payloads
-type ClientWithResponses struct {
-	ClientInterface
-}
-
-// NewClientWithResponses creates a new ClientWithResponses, which wraps
-// Client with return type handling
-func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) {
-	client, err := NewClient(server, opts...)
-	if err != nil {
-		return nil, err
-	}
-	return &ClientWithResponses{client}, nil
-}
-
-// WithBaseURL overrides the baseURL.
-func WithBaseURL(baseURL string) ClientOption {
-	return func(c *Client) error {
-		newBaseURL, err := url.Parse(baseURL)
-		if err != nil {
-			return err
-		}
-		c.Server = newBaseURL.String()
-		return nil
-	}
-}
-
-// ClientWithResponsesInterface is the interface specification for the client with responses above.
-type ClientWithResponsesInterface interface {
-	// PostJobQueueV1Jobs request  with any body
-	PostJobQueueV1JobsWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader) (*PostJobQueueV1JobsResponse, error)
-
-	PostJobQueueV1JobsWithResponse(ctx context.Context, body PostJobQueueV1JobsJSONRequestBody) (*PostJobQueueV1JobsResponse, error)
-
-	// GetJobQueueV1JobsJobId request
-	GetJobQueueV1JobsJobIdWithResponse(ctx context.Context, jobId string) (*GetJobQueueV1JobsJobIdResponse, error)
-
-	// PatchJobQueueV1JobsJobId request  with any body
-	PatchJobQueueV1JobsJobIdWithBodyWithResponse(ctx context.Context, jobId string, contentType string, body io.Reader) (*PatchJobQueueV1JobsJobIdResponse, error)
-
-	PatchJobQueueV1JobsJobIdWithResponse(ctx context.Context, jobId string, body PatchJobQueueV1JobsJobIdJSONRequestBody) (*PatchJobQueueV1JobsJobIdResponse, error)
-
-	// PostJobQueueV1JobsJobIdArtifactsName request  with any body
-	PostJobQueueV1JobsJobIdArtifactsNameWithBodyWithResponse(ctx context.Context, jobId string, name string, contentType string, body io.Reader) (*PostJobQueueV1JobsJobIdArtifactsNameResponse, error)
-
-	// GetStatus request
-	GetStatusWithResponse(ctx context.Context) (*GetStatusResponse, error)
-}
-
-type PostJobQueueV1JobsResponse struct {
-	Body         []byte
-	HTTPResponse *http.Response
-	JSON200      *struct {
-		Id       string        `json:"id"`
-		Manifest interface{}   `json:"manifest"`
-		Targets  []interface{} `json:"targets"`
-	}
-}
-
-// Status returns HTTPResponse.Status
-func (r PostJobQueueV1JobsResponse) Status() string {
-	if r.HTTPResponse != nil {
-		return r.HTTPResponse.Status
-	}
-	return http.StatusText(0)
-}
-
-// StatusCode returns HTTPResponse.StatusCode
-func (r PostJobQueueV1JobsResponse) StatusCode() int {
-	if r.HTTPResponse != nil {
-		return r.HTTPResponse.StatusCode
-	}
-	return 0
-}
-
-type GetJobQueueV1JobsJobIdResponse struct {
-	Body         []byte
-	HTTPResponse *http.Response
-	JSON200      *struct {
-		Canceled bool   `json:"canceled"`
-		Id       string `json:"id"`
-	}
-}
-
-// Status returns HTTPResponse.Status
-func (r GetJobQueueV1JobsJobIdResponse) Status() string {
-	if r.HTTPResponse != nil {
-		return r.HTTPResponse.Status
-	}
-	return http.StatusText(0)
-}
-
-// StatusCode returns HTTPResponse.StatusCode
-func (r GetJobQueueV1JobsJobIdResponse) StatusCode() int {
-	if r.HTTPResponse != nil {
-		return r.HTTPResponse.StatusCode
-	}
-	return 0
-}
-
-type PatchJobQueueV1JobsJobIdResponse struct {
-	Body         []byte
-	HTTPResponse *http.Response
-}
-
-// Status returns HTTPResponse.Status
-func (r PatchJobQueueV1JobsJobIdResponse) Status() string {
-	if r.HTTPResponse != nil {
-		return r.HTTPResponse.Status
-	}
-	return http.StatusText(0)
-}
-
-// StatusCode returns HTTPResponse.StatusCode
-func (r PatchJobQueueV1JobsJobIdResponse) StatusCode() int {
-	if r.HTTPResponse != nil {
-		return r.HTTPResponse.StatusCode
-	}
-	return 0
-}
-
-type PostJobQueueV1JobsJobIdArtifactsNameResponse struct {
-	Body         []byte
-	HTTPResponse *http.Response
-}
-
-// Status returns HTTPResponse.Status
-func (r PostJobQueueV1JobsJobIdArtifactsNameResponse) Status() string {
-	if r.HTTPResponse != nil {
-		return r.HTTPResponse.Status
-	}
-	return http.StatusText(0)
-}
-
-// StatusCode returns HTTPResponse.StatusCode
-func (r PostJobQueueV1JobsJobIdArtifactsNameResponse) StatusCode() int {
-	if r.HTTPResponse != nil {
-		return r.HTTPResponse.StatusCode
-	}
-	return 0
-}
-
-type GetStatusResponse struct {
-	Body         []byte
-	HTTPResponse *http.Response
-	JSON200      *struct {
-		Status string `json:"status"`
-	}
-}
-
-// Status returns HTTPResponse.Status
-func (r GetStatusResponse) Status() string {
-	if r.HTTPResponse != nil {
-		return r.HTTPResponse.Status
-	}
-	return http.StatusText(0)
-}
-
-// StatusCode returns HTTPResponse.StatusCode
-func (r GetStatusResponse) StatusCode() int {
-	if r.HTTPResponse != nil {
-		return r.HTTPResponse.StatusCode
-	}
-	return 0
-}
-
-// PostJobQueueV1JobsWithBodyWithResponse request with arbitrary body returning *PostJobQueueV1JobsResponse
-func (c *ClientWithResponses) PostJobQueueV1JobsWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader) (*PostJobQueueV1JobsResponse, error) {
-	rsp, err := c.PostJobQueueV1JobsWithBody(ctx, contentType, body)
-	if err != nil {
-		return nil, err
-	}
-	return ParsePostJobQueueV1JobsResponse(rsp)
-}
-
-func (c *ClientWithResponses) PostJobQueueV1JobsWithResponse(ctx context.Context, body PostJobQueueV1JobsJSONRequestBody) (*PostJobQueueV1JobsResponse, error) {
-	rsp, err := c.PostJobQueueV1Jobs(ctx, body)
-	if err != nil {
-		return nil, err
-	}
-	return ParsePostJobQueueV1JobsResponse(rsp)
-}
-
-// GetJobQueueV1JobsJobIdWithResponse request returning *GetJobQueueV1JobsJobIdResponse
-func (c *ClientWithResponses) GetJobQueueV1JobsJobIdWithResponse(ctx context.Context, jobId string) (*GetJobQueueV1JobsJobIdResponse, error) {
-	rsp, err := c.GetJobQueueV1JobsJobId(ctx, jobId)
-	if err != nil {
-		return nil, err
-	}
-	return ParseGetJobQueueV1JobsJobIdResponse(rsp)
-}
-
-// PatchJobQueueV1JobsJobIdWithBodyWithResponse request with arbitrary body returning *PatchJobQueueV1JobsJobIdResponse
-func (c *ClientWithResponses) PatchJobQueueV1JobsJobIdWithBodyWithResponse(ctx context.Context, jobId string, contentType string, body io.Reader) (*PatchJobQueueV1JobsJobIdResponse, error) {
-	rsp, err := c.PatchJobQueueV1JobsJobIdWithBody(ctx, jobId, contentType, body)
-	if err != nil {
-		return nil, err
-	}
-	return ParsePatchJobQueueV1JobsJobIdResponse(rsp)
-}
-
-func (c *ClientWithResponses) PatchJobQueueV1JobsJobIdWithResponse(ctx context.Context, jobId string, body PatchJobQueueV1JobsJobIdJSONRequestBody) (*PatchJobQueueV1JobsJobIdResponse, error) {
-	rsp, err := c.PatchJobQueueV1JobsJobId(ctx, jobId, body)
-	if err != nil {
-		return nil, err
-	}
-	return ParsePatchJobQueueV1JobsJobIdResponse(rsp)
-}
-
-// PostJobQueueV1JobsJobIdArtifactsNameWithBodyWithResponse request with arbitrary body returning *PostJobQueueV1JobsJobIdArtifactsNameResponse
-func (c *ClientWithResponses) PostJobQueueV1JobsJobIdArtifactsNameWithBodyWithResponse(ctx context.Context, jobId string, name string, contentType string, body io.Reader) (*PostJobQueueV1JobsJobIdArtifactsNameResponse, error) {
-	rsp, err := c.PostJobQueueV1JobsJobIdArtifactsNameWithBody(ctx, jobId, name, contentType, body)
-	if err != nil {
-		return nil, err
-	}
-	return ParsePostJobQueueV1JobsJobIdArtifactsNameResponse(rsp)
-}
-
-// GetStatusWithResponse request returning *GetStatusResponse
-func (c *ClientWithResponses) GetStatusWithResponse(ctx context.Context) (*GetStatusResponse, error) {
-	rsp, err := c.GetStatus(ctx)
-	if err != nil {
-		return nil, err
-	}
-	return ParseGetStatusResponse(rsp)
-}
-
-// ParsePostJobQueueV1JobsResponse parses an HTTP response from a PostJobQueueV1JobsWithResponse call
-func ParsePostJobQueueV1JobsResponse(rsp *http.Response) (*PostJobQueueV1JobsResponse, error) {
-	bodyBytes, err := ioutil.ReadAll(rsp.Body)
-	defer rsp.Body.Close()
-	if err != nil {
-		return nil, err
-	}
-
-	response := &PostJobQueueV1JobsResponse{
-		Body:         bodyBytes,
-		HTTPResponse: rsp,
-	}
-
-	switch {
-	case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
-		var dest struct {
-			Id       string        `json:"id"`
-			Manifest interface{}   `json:"manifest"`
-			Targets  []interface{} `json:"targets"`
-		}
-		if err := json.Unmarshal(bodyBytes, &dest); err != nil {
-			return nil, err
-		}
-		response.JSON200 = &dest
-
-	}
-
-	return response, nil
-}
-
-// ParseGetJobQueueV1JobsJobIdResponse parses an HTTP response from a GetJobQueueV1JobsJobIdWithResponse call
-func ParseGetJobQueueV1JobsJobIdResponse(rsp *http.Response) (*GetJobQueueV1JobsJobIdResponse, error) {
-	bodyBytes, err := ioutil.ReadAll(rsp.Body)
-	defer rsp.Body.Close()
-	if err != nil {
-		return nil, err
-	}
-
-	response := &GetJobQueueV1JobsJobIdResponse{
-		Body:         bodyBytes,
-		HTTPResponse: rsp,
-	}
-
-	switch {
-	case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
-		var dest struct {
-			Canceled bool   `json:"canceled"`
-			Id       string `json:"id"`
-		}
-		if err := json.Unmarshal(bodyBytes, &dest); err != nil {
-			return nil, err
-		}
-		response.JSON200 = &dest
-
-	}
-
-	return response, nil
-}
-
-// ParsePatchJobQueueV1JobsJobIdResponse parses an HTTP response from a PatchJobQueueV1JobsJobIdWithResponse call
-func ParsePatchJobQueueV1JobsJobIdResponse(rsp *http.Response) (*PatchJobQueueV1JobsJobIdResponse, error) {
-	bodyBytes, err := ioutil.ReadAll(rsp.Body)
-	defer rsp.Body.Close()
-	if err != nil {
-		return nil, err
-	}
-
-	response := &PatchJobQueueV1JobsJobIdResponse{
-		Body:         bodyBytes,
-		HTTPResponse: rsp,
-	}
-
-	switch {
-	}
-
-	return response, nil
-}
-
-// ParsePostJobQueueV1JobsJobIdArtifactsNameResponse parses an HTTP response from a PostJobQueueV1JobsJobIdArtifactsNameWithResponse call
-func ParsePostJobQueueV1JobsJobIdArtifactsNameResponse(rsp *http.Response) (*PostJobQueueV1JobsJobIdArtifactsNameResponse, error) {
-	bodyBytes, err := ioutil.ReadAll(rsp.Body)
-	defer rsp.Body.Close()
-	if err != nil {
-		return nil, err
-	}
-
-	response := &PostJobQueueV1JobsJobIdArtifactsNameResponse{
-		Body:         bodyBytes,
-		HTTPResponse: rsp,
-	}
-
-	switch {
-	}
-
-	return response, nil
+// UpdateJobJSONBody defines parameters for UpdateJob.
+type UpdateJobJSONBody struct {
+	Result interface{} `json:"result"`
+	Status string      `json:"status"`
 }
 
-// ParseGetStatusResponse parses an HTTP response from a GetStatusWithResponse call
-func ParseGetStatusResponse(rsp *http.Response) (*GetStatusResponse, error) {
-	bodyBytes, err := ioutil.ReadAll(rsp.Body)
-	defer rsp.Body.Close()
-	if err != nil {
-		return nil, err
-	}
-
-	response := &GetStatusResponse{
-		Body:         bodyBytes,
-		HTTPResponse: rsp,
-	}
+// RequestJobRequestBody defines body for RequestJob for application/json ContentType.
+type RequestJobJSONRequestBody RequestJobJSONBody
 
-	switch {
-	case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
-		var dest struct {
-			Status string `json:"status"`
-		}
-		if err := json.Unmarshal(bodyBytes, &dest); err != nil {
-			return nil, err
-		}
-		response.JSON200 = &dest
-
-	}
-
-	return response, nil
-}
+// UpdateJobRequestBody defines body for UpdateJob for application/json ContentType.
+type UpdateJobJSONRequestBody UpdateJobJSONBody
 
 // ServerInterface represents all server handlers.
 type ServerInterface interface {
-	// create-job
-	// (POST /job-queue/v1/jobs)
-	PostJobQueueV1Jobs(ctx echo.Context) error
-	// get-job
-	// (GET /job-queue/v1/jobs/{job_id})
-	GetJobQueueV1JobsJobId(ctx echo.Context, jobId string) error
-	// update-job
-	// (PATCH /job-queue/v1/jobs/{job_id})
-	PatchJobQueueV1JobsJobId(ctx echo.Context, jobId string) error
-	// add-image
-	// (POST /job-queue/v1/jobs/{job_id}/artifacts/{name})
-	PostJobQueueV1JobsJobIdArtifactsName(ctx echo.Context, jobId string, name string) error
+	// Request a job
+	// (POST /jobs)
+	RequestJob(ctx echo.Context) error
+	// Get running job
+	// (GET /jobs/{token})
+	GetJob(ctx echo.Context, token string) error
+	// Update a running job
+	// (PATCH /jobs/{token})
+	UpdateJob(ctx echo.Context, token string) error
+	// Upload an artifact
+	// (PUT /jobs/{token}/artifacts/{name})
+	UploadJobArtifact(ctx echo.Context, token string, name string) error
 	// status
 	// (GET /status)
 	GetStatus(ctx echo.Context) error
@@ -792,56 +54,56 @@ type ServerInterfaceWrapper struct {
 	Handler ServerInterface
 }
 
-// PostJobQueueV1Jobs converts echo context to params.
-func (w *ServerInterfaceWrapper) PostJobQueueV1Jobs(ctx echo.Context) error {
+// RequestJob converts echo context to params.
+func (w *ServerInterfaceWrapper) RequestJob(ctx echo.Context) error {
 	var err error
 
 	// Invoke the callback with all the unmarshalled arguments
-	err = w.Handler.PostJobQueueV1Jobs(ctx)
+	err = w.Handler.RequestJob(ctx)
 	return err
 }
 
-// GetJobQueueV1JobsJobId converts echo context to params.
-func (w *ServerInterfaceWrapper) GetJobQueueV1JobsJobId(ctx echo.Context) error {
+// GetJob converts echo context to params.
+func (w *ServerInterfaceWrapper) GetJob(ctx echo.Context) error {
 	var err error
-	// ------------- Path parameter "job_id" -------------
-	var jobId string
+	// ------------- Path parameter "token" -------------
+	var token string
 
-	err = runtime.BindStyledParameter("simple", false, "job_id", ctx.Param("job_id"), &jobId)
+	err = runtime.BindStyledParameter("simple", false, "token", ctx.Param("token"), &token)
 	if err != nil {
-		return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter job_id: %s", err))
+		return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter token: %s", err))
 	}
 
 	// Invoke the callback with all the unmarshalled arguments
-	err = w.Handler.GetJobQueueV1JobsJobId(ctx, jobId)
+	err = w.Handler.GetJob(ctx, token)
 	return err
 }
 
-// PatchJobQueueV1JobsJobId converts echo context to params.
-func (w *ServerInterfaceWrapper) PatchJobQueueV1JobsJobId(ctx echo.Context) error {
+// UpdateJob converts echo context to params.
+func (w *ServerInterfaceWrapper) UpdateJob(ctx echo.Context) error {
 	var err error
-	// ------------- Path parameter "job_id" -------------
-	var jobId string
+	// ------------- Path parameter "token" -------------
+	var token string
 
-	err = runtime.BindStyledParameter("simple", false, "job_id", ctx.Param("job_id"), &jobId)
+	err = runtime.BindStyledParameter("simple", false, "token", ctx.Param("token"), &token)
 	if err != nil {
-		return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter job_id: %s", err))
+		return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter token: %s", err))
 	}
 
 	// Invoke the callback with all the unmarshalled arguments
-	err = w.Handler.PatchJobQueueV1JobsJobId(ctx, jobId)
+	err = w.Handler.UpdateJob(ctx, token)
 	return err
 }
 
-// PostJobQueueV1JobsJobIdArtifactsName converts echo context to params.
-func (w *ServerInterfaceWrapper) PostJobQueueV1JobsJobIdArtifactsName(ctx echo.Context) error {
+// UploadJobArtifact converts echo context to params.
+func (w *ServerInterfaceWrapper) UploadJobArtifact(ctx echo.Context) error {
 	var err error
-	// ------------- Path parameter "job_id" -------------
-	var jobId string
+	// ------------- Path parameter "token" -------------
+	var token string
 
-	err = runtime.BindStyledParameter("simple", false, "job_id", ctx.Param("job_id"), &jobId)
+	err = runtime.BindStyledParameter("simple", false, "token", ctx.Param("token"), &token)
 	if err != nil {
-		return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter job_id: %s", err))
+		return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter token: %s", err))
 	}
 
 	// ------------- Path parameter "name" -------------
@@ -853,7 +115,7 @@ func (w *ServerInterfaceWrapper) PostJobQueueV1JobsJobIdArtifactsName(ctx echo.C
 	}
 
 	// Invoke the callback with all the unmarshalled arguments
-	err = w.Handler.PostJobQueueV1JobsJobIdArtifactsName(ctx, jobId, name)
+	err = w.Handler.UploadJobArtifact(ctx, token, name)
 	return err
 }
 
@@ -888,10 +150,10 @@ func RegisterHandlers(router EchoRouter, si ServerInterface) {
 		Handler: si,
 	}
 
-	router.POST("/job-queue/v1/jobs", wrapper.PostJobQueueV1Jobs)
-	router.GET("/job-queue/v1/jobs/:job_id", wrapper.GetJobQueueV1JobsJobId)
-	router.PATCH("/job-queue/v1/jobs/:job_id", wrapper.PatchJobQueueV1JobsJobId)
-	router.POST("/job-queue/v1/jobs/:job_id/artifacts/:name", wrapper.PostJobQueueV1JobsJobIdArtifactsName)
+	router.POST("/jobs", wrapper.RequestJob)
+	router.GET("/jobs/:token", wrapper.GetJob)
+	router.PATCH("/jobs/:token", wrapper.UpdateJob)
+	router.PUT("/jobs/:token/artifacts/:name", wrapper.UploadJobArtifact)
 	router.GET("/status", wrapper.GetStatus)
 
 }
diff --git a/internal/worker/api/api.go b/internal/worker/api/api.go
index 7e9674759..b5f2978c4 100644
--- a/internal/worker/api/api.go
+++ b/internal/worker/api/api.go
@@ -1,3 +1,3 @@
-//go:generate go run github.com/deepmap/oapi-codegen/cmd/oapi-codegen -package=api -generate types,server,client -o api.gen.go openapi.yml
+//go:generate go run github.com/deepmap/oapi-codegen/cmd/oapi-codegen -package=api -generate types,server -o api.gen.go openapi.yml
 
 package api
diff --git a/internal/worker/api/openapi.yml b/internal/worker/api/openapi.yml
index 40356ed07..e1b132c1f 100644
--- a/internal/worker/api/openapi.yml
+++ b/internal/worker/api/openapi.yml
@@ -23,48 +23,77 @@ paths:
                       - OK
                 required:
                   - status
-      operationId: get-status
+        4XX:
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        5XX:
+          description: ''
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      operationId: GetStatus
       description: Simple status handler to check whether the service is up.
-  /job-queue/v1/jobs:
+  /jobs:
     post:
-      summary: create-job
+      summary: Request a job
       tags: []
       responses:
-        '200':
-          description: OK
+        '201':
+          description: Created
           content:
             application/json:
               schema:
                 type: object
                 additionalProperties: false
                 properties:
-                  id:
-                    type: string
-                    format: uuid
                   manifest: {}
                   targets:
                     type: array
                     items: {}
+                  location:
+                    type: string
+                  artifact_location:
+                    type: string
+                  id:
+                    type: string
+                    format: uuid
                 required:
-                  - id
                   - manifest
-                  - targets
-      operationId: post-job-queue-v1-jobs
+                  - location
+                  - id
+        4XX:
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        5XX:
+          description: ''
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      operationId: RequestJob
       requestBody:
         content:
           application/json:
             schema:
               type: object
-              properties: {}
-  '/job-queue/v1/jobs/{job_id}':
+              additionalProperties: false
+        description: ''
+      description: Requests a job. This operation blocks until a job is available.
+    parameters: []
+  '/jobs/{token}':
     parameters:
       - schema:
           type: string
-        name: job_id
+        name: token
         in: path
         required: true
     get:
-      summary: get-job
+      summary: Get running job
       tags: []
       responses:
         '200':
@@ -74,20 +103,29 @@ paths:
               schema:
                 type: object
                 properties:
-                  id:
-                    type: string
-                    format: uuid
                   canceled:
                     type: boolean
                 required:
-                  - id
                   - canceled
-      operationId: get-job-queue-v1-jobs-job_id
+        4XX:
+          description: ''
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        5XX:
+          description: ''
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      operationId: GetJob
+      description: ''
     patch:
-      summary: update-job
+      summary: Update a running job
       tags: []
       responses: {}
-      operationId: patch-job-queue-v1-jobs-job_id
+      operationId: UpdateJob
       requestBody:
         content:
           application/json:
@@ -105,29 +143,48 @@ paths:
               required:
                 - status
                 - result
-  '/job-queue/v1/jobs/{job_id}/artifacts/{name}':
+  '/jobs/{token}/artifacts/{name}':
     parameters:
       - schema:
           type: string
-        name: job_id
+        name: name
         in: path
         required: true
       - schema:
           type: string
-        name: name
+        name: token
         in: path
         required: true
-    post:
-      summary: add-image
+    put:
+      summary: Upload an artifact
       tags: []
       responses:
         '200':
           description: OK
-      operationId: post-job-queue-v1-jobs-job_id-artifacts-name
+        4XX:
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        5XX:
+          description: ''
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      operationId: UploadJobArtifact
       requestBody:
         content:
           application/octet-stream:
             schema:
               type: string
 components:
-  schemas: {}
+  schemas:
+    Error:
+      title: Error
+      type: object
+      properties:
+        message:
+          type: string
+      required:
+        - message
diff --git a/internal/worker/client.go b/internal/worker/client.go
index 331f59520..8b6db3aed 100644
--- a/internal/worker/client.go
+++ b/internal/worker/client.go
@@ -1,17 +1,17 @@
 package worker
 
 import (
+	"bytes"
 	"context"
 	"crypto/tls"
 	"encoding/json"
-	"errors"
 	"fmt"
 	"io"
 	"net"
 	"net/http"
+	"net/url"
 
 	"github.com/google/uuid"
-
 	"github.com/osbuild/osbuild-composer/internal/common"
 	"github.com/osbuild/osbuild-composer/internal/distro"
 	"github.com/osbuild/osbuild-composer/internal/osbuild"
@@ -20,32 +20,49 @@ import (
 )
 
 type Client struct {
-	api *api.Client
+	server    *url.URL
+	requester *http.Client
+}
+
+type Job interface {
+	Id() uuid.UUID
+	OSBuildArgs() (distro.Manifest, []*target.Target, error)
+	Update(status common.ImageBuildState, result *osbuild.Result) error
+	Canceled() (bool, error)
+	UploadArtifact(name string, reader io.Reader) error
 }
 
-type Job struct {
-	Id       uuid.UUID
-	Manifest distro.Manifest
-	Targets  []*target.Target
+type job struct {
+	requester        *http.Client
+	id               uuid.UUID
+	manifest         distro.Manifest
+	targets          []*target.Target
+	location         string
+	artifactLocation string
 }
 
 func NewClient(baseURL string, conf *tls.Config) (*Client, error) {
-	httpClient := http.Client{
+	server, err := url.Parse(baseURL)
+	if err != nil {
+		return nil, err
+	}
+
+	requester := &http.Client{
 		Transport: &http.Transport{
 			TLSClientConfig: conf,
 		},
 	}
 
-	c, err := api.NewClient(baseURL, api.WithHTTPClient(&httpClient))
-	if err != nil {
-		return nil, err
-	}
-
-	return &Client{c}, nil
+	return &Client{server, requester}, nil
 }
 
 func NewClientUnix(path string) *Client {
-	httpClient := http.Client{
+	server, err := url.Parse("http://localhost")
+	if err != nil {
+		panic(err)
+	}
+
+	requester := &http.Client{
 		Transport: &http.Transport{
 			DialContext: func(context context.Context, network, addr string) (net.Conn, error) {
 				return net.Dial("unix", path)
@@ -53,79 +70,158 @@ func NewClientUnix(path string) *Client {
 		},
 	}
 
-	c, err := api.NewClient("http://localhost", api.WithHTTPClient(&httpClient))
+	return &Client{server, requester}
+}
+
+func (c *Client) RequestJob() (Job, error) {
+	url, err := c.server.Parse("/jobs")
 	if err != nil {
+		// This only happens when "/jobs" cannot be parsed.
 		panic(err)
 	}
 
-	return &Client{c}
-}
+	var buf bytes.Buffer
+	err = json.NewEncoder(&buf).Encode(api.RequestJobJSONRequestBody{})
+	if err != nil {
+		panic(err)
+	}
 
-func (c *Client) AddJob() (*Job, error) {
-	response, err := c.api.PostJobQueueV1Jobs(context.Background(), api.PostJobQueueV1JobsJSONRequestBody{})
+	response, err := c.requester.Post(url.String(), "application/json", &buf)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("error requesting job: %v", err)
 	}
 	defer response.Body.Close()
 
 	if response.StatusCode != http.StatusCreated {
-		var er errorResponse
-		_ = json.NewDecoder(response.Body).Decode(&er)
-		return nil, fmt.Errorf("couldn't create job, got %d: %s", response.StatusCode, er.Message)
+		return nil, errorFromResponse(response, "error requesting job")
 	}
 
-	var jr addJobResponse
+	var jr requestJobResponse
 	err = json.NewDecoder(response.Body).Decode(&jr)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("error parsing response: %v", err)
+	}
+
+	location, err := c.server.Parse(jr.Location)
+	if err != nil {
+		return nil, fmt.Errorf("error parsing location url in response: %v", err)
+	}
+
+	artifactLocation, err := c.server.Parse(jr.ArtifactLocation)
+	if err != nil {
+		return nil, fmt.Errorf("error parsing artifact location url in response: %v", err)
 	}
 
-	return &Job{
-		jr.Id,
-		jr.Manifest,
-		jr.Targets,
+	return &job{
+		requester:        c.requester,
+		id:               jr.Id,
+		manifest:         jr.Manifest,
+		targets:          jr.Targets,
+		location:         location.String(),
+		artifactLocation: artifactLocation.String(),
 	}, nil
 }
 
-func (c *Client) JobCanceled(job *Job) bool {
-	response, err := c.api.GetJobQueueV1JobsJobId(context.Background(), job.Id.String())
+func (j *job) Id() uuid.UUID {
+	return j.id
+}
+
+func (j *job) OSBuildArgs() (distro.Manifest, []*target.Target, error) {
+	return j.manifest, j.targets, nil
+}
+
+func (j *job) Update(status common.ImageBuildState, result *osbuild.Result) error {
+	var buf bytes.Buffer
+	err := json.NewEncoder(&buf).Encode(api.UpdateJobJSONRequestBody{
+		Result: result,
+		Status: status.ToString(),
+	})
 	if err != nil {
-		return true
+		panic(err)
+	}
+
+	req, err := http.NewRequest("PATCH", j.location, &buf)
+	if err != nil {
+		panic(err)
+	}
+
+	req.Header.Add("Content-Type", "application/json")
+
+	response, err := j.requester.Do(req)
+	if err != nil {
+		return fmt.Errorf("error fetching job info: %v", err)
 	}
 	defer response.Body.Close()
 
 	if response.StatusCode != http.StatusOK {
-		return true
+		return errorFromResponse(response, "error setting job status")
 	}
 
-	var jr jobResponse
+	return nil
+}
+
+func (j *job) Canceled() (bool, error) {
+	response, err := j.requester.Get(j.location)
+	if err != nil {
+		return false, fmt.Errorf("error fetching job info: %v", err)
+	}
+	defer response.Body.Close()
+
+	if response.StatusCode != http.StatusOK {
+		return false, errorFromResponse(response, "error fetching job info")
+	}
+
+	var jr getJobResponse
 	err = json.NewDecoder(response.Body).Decode(&jr)
 	if err != nil {
-		return true
+		return false, fmt.Errorf("error parsing reponse: %v", err)
 	}
 
-	return jr.Canceled
+	return jr.Canceled, nil
 }
 
-func (c *Client) UpdateJob(job *Job, status common.ImageBuildState, result *osbuild.Result) error {
-	response, err := c.api.PatchJobQueueV1JobsJobId(context.Background(), job.Id.String(), api.PatchJobQueueV1JobsJobIdJSONRequestBody{
-		Result: result,
-		Status: status.ToString(),
-	})
+func (j *job) UploadArtifact(name string, reader io.Reader) error {
+	if j.artifactLocation == "" {
+		return fmt.Errorf("server does not accept artifacts for this job")
+	}
+
+	loc, err := url.Parse(j.artifactLocation)
 	if err != nil {
-		return err
+		return fmt.Errorf("error parsing job location: %v", err)
 	}
 
-	if response.StatusCode != http.StatusOK {
-		return errors.New("error setting job status")
+	loc, err = loc.Parse(url.PathEscape(name))
+	if err != nil {
+		panic(err)
+	}
+
+	req, err := http.NewRequest("PUT", loc.String(), reader)
+	if err != nil {
+		return fmt.Errorf("cannot create request: %v", err)
+	}
+
+	req.Header.Add("Content-Type", "application/octet-stream")
+
+	response, err := j.requester.Do(req)
+	if err != nil {
+		return fmt.Errorf("error uploading artifact: %v", err)
+	}
+
+	if response.StatusCode != 200 {
+		return errorFromResponse(response, "error uploading artifact")
 	}
 
 	return nil
 }
 
-func (c *Client) UploadImage(job uuid.UUID, name string, reader io.Reader) error {
-	_, err := c.api.PostJobQueueV1JobsJobIdArtifactsNameWithBody(context.Background(),
-		job.String(), name, "application/octet-stream", reader)
-
-	return err
+// Parses an api.Error from a response and returns it as a golang error. Other
+// errors, such failing to parse the response, are returned as golang error as
+// well. If client code expects an error, it gets one.
+func errorFromResponse(response *http.Response, message string) error {
+	var e api.Error
+	err := json.NewDecoder(response.Body).Decode(&e)
+	if err != nil {
+		return fmt.Errorf("failed to parse error response: %v", err)
+	}
+	return fmt.Errorf("%v: %v — %v", message, response.StatusCode, e.Message)
 }
diff --git a/internal/worker/json.go b/internal/worker/json.go
index 6f969721b..60f95fc5c 100644
--- a/internal/worker/json.go
+++ b/internal/worker/json.go
@@ -2,7 +2,6 @@ package worker
 
 import (
 	"github.com/google/uuid"
-
 	"github.com/osbuild/osbuild-composer/internal/common"
 	"github.com/osbuild/osbuild-composer/internal/distro"
 	"github.com/osbuild/osbuild-composer/internal/osbuild"
@@ -30,22 +29,16 @@ type statusResponse struct {
 	Status string `json:"status"`
 }
 
-type errorResponse struct {
-	Message string `json:"message"`
-}
-
-type addJobRequest struct {
-}
-
-type addJobResponse struct {
-	Id       uuid.UUID        `json:"id"`
-	Manifest distro.Manifest  `json:"manifest"`
-	Targets  []*target.Target `json:"targets,omitempty"`
+type requestJobResponse struct {
+	Id               uuid.UUID        `json:"id"`
+	Manifest         distro.Manifest  `json:"manifest"`
+	Targets          []*target.Target `json:"targets,omitempty"`
+	Location         string           `json:"location"`
+	ArtifactLocation string           `json:"artifact_location"`
 }
 
-type jobResponse struct {
-	Id       uuid.UUID `json:"id"`
-	Canceled bool      `json:"canceled"`
+type getJobResponse struct {
+	Canceled bool `json:"canceled"`
 }
 
 type updateJobRequest struct {
diff --git a/internal/worker/server.go b/internal/worker/server.go
index 17ac3e397..6409d11af 100644
--- a/internal/worker/server.go
+++ b/internal/worker/server.go
@@ -1,7 +1,9 @@
 package worker
 
 import (
+	"context"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"io"
 	"io/ioutil"
@@ -10,6 +12,7 @@ import (
 	"net/http"
 	"os"
 	"path"
+	"sync"
 	"time"
 
 	"github.com/google/uuid"
@@ -26,6 +29,18 @@ type Server struct {
 	jobs         jobqueue.JobQueue
 	echo         *echo.Echo
 	artifactsDir string
+
+	// Currently running jobs. Workers are not handed job ids, but
+	// independent tokens which serve as an indirection. This enables
+	// race-free uploading of artifacts and makes restarting composer more
+	// robust (workers from an old run cannot report results for jobs
+	// composer thinks are not running).
+	// This map maps these tokens to job ids. Artifacts are stored in
+	// `$STATE_DIRECTORY/artifacts/tmp/$TOKEN` while the worker is running,
+	// and renamed to `$STATE_DIRECTORY/artifacts/$JOB_ID` once the job is
+	// reported as done.
+	running      map[uuid.UUID]uuid.UUID
+	runningMutex sync.Mutex
 }
 
 type JobStatus struct {
@@ -37,10 +52,13 @@ type JobStatus struct {
 	Result   OSBuildJobResult
 }
 
+var ErrTokenNotExist = errors.New("worker token does not exist")
+
 func NewServer(logger *log.Logger, jobs jobqueue.JobQueue, artifactsDir string) *Server {
 	s := &Server{
 		jobs:         jobs,
 		artifactsDir: artifactsDir,
+		running:      make(map[uuid.UUID]uuid.UUID),
 	}
 
 	s.echo = echo.New()
@@ -151,6 +169,72 @@ func (s *Server) DeleteArtifacts(id uuid.UUID) error {
 	return os.RemoveAll(path.Join(s.artifactsDir, id.String()))
 }
 
+func (s *Server) RequestJob(ctx context.Context) (uuid.UUID, uuid.UUID, *OSBuildJob, error) {
+	token := uuid.New()
+
+	var args OSBuildJob
+	jobId, err := s.jobs.Dequeue(ctx, []string{"osbuild"}, &args)
+	if err != nil {
+		return uuid.Nil, uuid.Nil, nil, err
+	}
+
+	if s.artifactsDir != "" {
+		err := os.MkdirAll(path.Join(s.artifactsDir, "tmp", token.String()), 0700)
+		if err != nil {
+			return uuid.Nil, uuid.Nil, nil, fmt.Errorf("cannot create artifact directory: %v", err)
+		}
+	}
+
+	s.runningMutex.Lock()
+	defer s.runningMutex.Unlock()
+	s.running[token] = jobId
+
+	return token, jobId, &args, nil
+}
+
+func (s *Server) RunningJob(token uuid.UUID) (uuid.UUID, error) {
+	s.runningMutex.Lock()
+	defer s.runningMutex.Unlock()
+
+	jobId, ok := s.running[token]
+	if !ok {
+		return uuid.Nil, ErrTokenNotExist
+	}
+
+	return jobId, nil
+}
+
+func (s *Server) FinishJob(token uuid.UUID, result *OSBuildJobResult) error {
+	s.runningMutex.Lock()
+	defer s.runningMutex.Unlock()
+
+	jobId, ok := s.running[token]
+	if !ok {
+		return ErrTokenNotExist
+	}
+
+	// Always delete the running job, even if there are errors finishing
+	// the job, because callers won't call this a second time on error.
+	delete(s.running, token)
+
+	err := s.jobs.FinishJob(jobId, result)
+	if err != nil {
+		return fmt.Errorf("error finishing job: %v", err)
+	}
+
+	// Move artifacts from the temporary location to the final job
+	// location. Log any errors, but do not treat them as fatal. The job is
+	// already finished.
+	if s.artifactsDir != "" {
+		err := os.Rename(path.Join(s.artifactsDir, "tmp", token.String()), path.Join(s.artifactsDir, jobId.String()))
+		if err != nil {
+			log.Printf("Error moving artifacts for job%s: %v", jobId, err)
+		}
+	}
+
+	return nil
+}
+
 // apiHandlers implements api.ServerInterface - the http api route handlers
 // generated from api/openapi.yml. This is a separate object, because these
 // handlers should not be exposed on the `Server` object.
@@ -164,52 +248,61 @@ func (h *apiHandlers) GetStatus(ctx echo.Context) error {
 	})
 }
 
-func (h *apiHandlers) GetJobQueueV1JobsJobId(ctx echo.Context, jobId string) error {
-	id, err := uuid.Parse(jobId)
+func (h *apiHandlers) RequestJob(ctx echo.Context) error {
+	var body struct{}
+	err := ctx.Bind(&body)
 	if err != nil {
-		return echo.NewHTTPError(http.StatusBadRequest, "cannot parse compose id: %v", err)
+		return err
 	}
 
-	status, err := h.server.JobStatus(id)
+	token, jobId, jobArgs, err := h.server.RequestJob(ctx.Request().Context())
 	if err != nil {
-		switch err {
-		case jobqueue.ErrNotExist:
-			return echo.NewHTTPError(http.StatusNotFound, "job does not exist: %s", id)
-		default:
-			return err
-		}
+		return err
 	}
 
-	return ctx.JSON(http.StatusOK, jobResponse{
-		Id:       id,
-		Canceled: status.Canceled,
+	return ctx.JSON(http.StatusCreated, requestJobResponse{
+		Id:               jobId,
+		Manifest:         jobArgs.Manifest,
+		Targets:          jobArgs.Targets,
+		Location:         fmt.Sprintf("/jobs/%v", token),
+		ArtifactLocation: fmt.Sprintf("/jobs/%v/artifacts/", token),
 	})
 }
 
-func (h *apiHandlers) PostJobQueueV1Jobs(ctx echo.Context) error {
-	var body addJobRequest
-	err := ctx.Bind(&body)
+func (h *apiHandlers) GetJob(ctx echo.Context, tokenstr string) error {
+	token, err := uuid.Parse(tokenstr)
 	if err != nil {
-		return err
+		return echo.NewHTTPError(http.StatusBadRequest, "cannot parse job token")
 	}
 
-	var job OSBuildJob
-	id, err := h.server.jobs.Dequeue(ctx.Request().Context(), []string{"osbuild"}, &job)
+	jobId, err := h.server.RunningJob(token)
 	if err != nil {
-		return echo.NewHTTPError(http.StatusInternalServerError, "%v", err)
+		switch err {
+		case ErrTokenNotExist:
+			return echo.NewHTTPError(http.StatusNotFound, "not found")
+		default:
+			return err
+		}
+	}
+
+	if jobId == uuid.Nil {
+		return ctx.JSON(http.StatusOK, getJobResponse{})
+	}
+
+	status, err := h.server.JobStatus(jobId)
+	if err != nil {
+		return err
 	}
 
-	return ctx.JSON(http.StatusCreated, addJobResponse{
-		Id:       id,
-		Manifest: job.Manifest,
-		Targets:  job.Targets,
+	return ctx.JSON(http.StatusOK, getJobResponse{
+		Canceled: status.Canceled,
 	})
 }
 
-func (h *apiHandlers) PatchJobQueueV1JobsJobId(ctx echo.Context, jobId string) error {
-	id, err := uuid.Parse(jobId)
+func (h *apiHandlers) UpdateJob(ctx echo.Context, idstr string) error {
+	token, err := uuid.Parse(idstr)
 	if err != nil {
-		return echo.NewHTTPError(http.StatusBadRequest, "cannot parse compose id: %v", err)
+		return echo.NewHTTPError(http.StatusBadRequest, "cannot parse job token")
 	}
 
 	var body updateJobRequest
@@ -225,13 +318,11 @@ func (h *apiHandlers) PatchJobQueueV1JobsJobId(ctx echo.Context, jobId string) e
 		return echo.NewHTTPError(http.StatusBadRequest, "setting status of a job to waiting or running is not supported")
 	}
 
-	err = h.server.jobs.FinishJob(id, OSBuildJobResult{OSBuildOutput: body.Result})
+	err = h.server.FinishJob(token, &OSBuildJobResult{OSBuildOutput: body.Result})
 	if err != nil {
 		switch err {
-		case jobqueue.ErrNotExist:
-			return echo.NewHTTPError(http.StatusNotFound, "job does not exist: %s", id)
-		case jobqueue.ErrNotRunning:
-			return echo.NewHTTPError(http.StatusBadRequest, "job is not running: %s", id)
+		case ErrTokenNotExist:
+			return echo.NewHTTPError(http.StatusNotFound, "not found")
 		default:
 			return err
 		}
@@ -240,10 +331,10 @@ func (h *apiHandlers) PatchJobQueueV1JobsJobId(ctx echo.Context, jobId string) e
 	return ctx.JSON(http.StatusOK, updateJobResponse{})
 }
 
-func (h *apiHandlers) PostJobQueueV1JobsJobIdArtifactsName(ctx echo.Context, jobId string, name string) error {
-	id, err := uuid.Parse(jobId)
+func (h *apiHandlers) UploadJobArtifact(ctx echo.Context, tokenstr string, name string) error {
+	token, err := uuid.Parse(tokenstr)
 	if err != nil {
-		return echo.NewHTTPError(http.StatusBadRequest, "cannot parse compose id: %v", err)
+		return echo.NewHTTPError(http.StatusBadRequest, "cannot parse job token")
 	}
 
 	request := ctx.Request()
@@ -251,24 +342,19 @@ func (h *apiHandlers) PostJobQueueV1JobsJobIdArtifactsName(ctx echo.Context, job
 	if h.server.artifactsDir == "" {
 		_, err := io.Copy(ioutil.Discard, request.Body)
 		if err != nil {
-			return echo.NewHTTPError(http.StatusInternalServerError, "error discarding artifact: %v", err)
+			return fmt.Errorf("error discarding artifact: %v", err)
 		}
 		return ctx.NoContent(http.StatusOK)
 	}
 
-	err = os.Mkdir(path.Join(h.server.artifactsDir, id.String()), 0700)
-	if err != nil {
-		return echo.NewHTTPError(http.StatusInternalServerError, "cannot create artifact directory: %v", err)
-	}
-
-	f, err := os.Create(path.Join(h.server.artifactsDir, id.String(), name))
+	f, err := os.Create(path.Join(h.server.artifactsDir, "tmp", token.String(), name))
 	if err != nil {
-		return echo.NewHTTPError(http.StatusInternalServerError, "cannot create artifact file: %v", err)
+		return fmt.Errorf("cannot create artifact file: %v", err)
 	}
 
 	_, err = io.Copy(f, request.Body)
 	if err != nil {
-		return echo.NewHTTPError(http.StatusInternalServerError, "error writing artifact file: %v", err)
+		return fmt.Errorf("error writing artifact file: %v", err)
 	}
 
 	return ctx.NoContent(http.StatusOK)
@@ -289,7 +375,7 @@ func (b binder) Bind(i interface{}, ctx echo.Context) error {
 
 	err := json.NewDecoder(request.Body).Decode(i)
 	if err != nil {
-		return echo.NewHTTPError(http.StatusBadRequest, "cannot parse request body: %v", err)
+		return echo.NewHTTPError(http.StatusBadRequest, "cannot parse request body: "+err.Error())
 	}
 
 	return nil
diff --git a/osbuild b/osbuild
index d26cffe58..7f249afe0 160000
--- a/osbuild
+++ b/osbuild
@@ -1,1 +1,1 @@
-Subproject commit d26cffe585f2ced34f9cb89e3c9531987f33e9e9
+Subproject commit 7f249afe07d6ae0cda08fbf439359ec99e78dd37
diff --git a/schutzbot/Jenkinsfile b/schutzbot/Jenkinsfile
index d04338b9f..cd2a8af4c 100644
--- a/schutzbot/Jenkinsfile
+++ b/schutzbot/Jenkinsfile
@@ -133,6 +133,7 @@ pipeline {
                         AZURE_CREDS = credentials('azure')
                         OPENSTACK_CREDS = credentials("psi-openstack-creds")
                         VCENTER_CREDS = credentials('vmware-vcenter-credentials')
+                        DISTRO_CODE = "fedora31"
                     }
                     steps {
                         unstash 'fedora31'
@@ -182,6 +183,7 @@ pipeline {
                         AZURE_CREDS = credentials('azure')
                         OPENSTACK_CREDS = credentials("psi-openstack-creds")
                         VCENTER_CREDS = credentials('vmware-vcenter-credentials')
+                        DISTRO_CODE = "fedora32"
                     }
                     steps {
                         unstash 'fedora32'
@@ -246,6 +248,7 @@ pipeline {
                         OPENSTACK_CREDS = credentials("psi-openstack-creds")
                         RHN_REGISTRATION_SCRIPT = credentials('rhn-register-script-production')
                         VCENTER_CREDS = credentials('vmware-vcenter-credentials')
+                        DISTRO_CODE = "rhel8"
                     }
                     steps {
                         unstash 'rhel8cdn'
@@ -300,6 +303,7 @@ pipeline {
                         OPENSTACK_CREDS = credentials("psi-openstack-creds")
                         RHN_REGISTRATION_SCRIPT = credentials('rhn-register-script-production-beta')
                         VCENTER_CREDS = credentials('vmware-vcenter-credentials')
+                        DISTRO_CODE = "rhel83"
                     }
                     steps {
                         unstash 'rhel83'


Test patch:
diff --git a/cmd/osbuild-image-tests/main_test.go b/cmd/osbuild-image-tests/main_test.go
index eca446f7f..4c871b380 100644
--- a/cmd/osbuild-image-tests/main_test.go
+++ b/cmd/osbuild-image-tests/main_test.go
@@ -47,6 +47,20 @@ type testcaseStruct struct {
 
 var disableLocalBoot = flag.Bool("disable-local-boot", false, "when this flag is given, no images are booted locally using qemu (this does not affect testing in clouds)")
 
+// GenerateCIArtifactName generates a new identifier for CI artifacts which is based
+// on environment variables specified by Jenkins
+// note: in case of migration to sth else like Github Actions, change it to whatever variables GH Action provides
+func GenerateCIArtifactName(prefix string) (string, error) {
+	distroCode := os.Getenv("DISTRO_CODE")
+	changeId := os.Getenv("CHANGE_ID")
+	buildId := os.Getenv("BUILD_ID")
+	if changeId == "" || buildId == "" || distroCode == "" {
+		return "", fmt.Errorf("The environment variables must specify CHANGE_ID, BUILD_ID, and DISTRO_CODE")
+	}
+
+	return fmt.Sprintf("%s%s-%s-%s", prefix, distroCode, changeId, buildId), nil
+}
+
 // runOsbuild runs osbuild with the specified manifest and output-directory.
 func runOsbuild(manifest []byte, store, outputDirectory string) error {
 	cmd := constants.GetOsbuildCommand(store, outputDirectory)
@@ -225,7 +239,7 @@ func testBootUsingAWS(t *testing.T, imagePath string) {
 
 	}
 
-	imageName, err := boot.GenerateRandomString("osbuild-image-tests-image-")
+	imageName, err := GenerateCIArtifactName("osbuild-image-tests-image-")
 	require.NoError(t, err)
 
 	e, err := boot.NewEC2(creds)
@@ -244,9 +258,11 @@ func testBootUsingAWS(t *testing.T, imagePath string) {
 		require.NoErrorf(t, err, "cannot delete the ec2 image, resources could have been leaked")
 	}()
 
+	securityGroupName, err := GenerateCIArtifactName("osbuild-image-tests-security-group-")
+	require.NoError(t, err)
 	// boot the uploaded image and try to connect to it
 	err = boot.WithSSHKeyPair(func(privateKey, publicKey string) error {
-		return boot.WithBootedImageInEC2(e, imageDesc, publicKey, func(address string) error {
+		return boot.WithBootedImageInEC2(e, securityGroupName, imageDesc, publicKey, func(address string) error {
 			testSSH(t, address, privateKey, nil)
 			return nil
 		})
@@ -266,7 +282,7 @@ func testBootUsingAzure(t *testing.T, imagePath string) {
 	}
 
 	// create a random test id to name all the resources used in this test
-	testId, err := boot.GenerateRandomString("")
+	testId, err := GenerateCIArtifactName("")
 	require.NoError(t, err)
 
 	imageName := "image-" + testId + ".vhd"
@@ -307,7 +323,7 @@ func testBootUsingOpenStack(t *testing.T, imagePath string) {
 	require.NoError(t, err)
 
 	// create a random test id to name all the resources used in this test
-	imageName, err := boot.GenerateRandomString("osbuild-image-tests-openstack-image-")
+	imageName, err := GenerateCIArtifactName("osbuild-image-tests-openstack-image-")
 	require.NoError(t, err)
 
 	// the following line should be done by osbuild-composer at some point
@@ -357,7 +373,7 @@ func testBootUsingVMware(t *testing.T, imagePath string) {
 	defer os.Remove(imagePath)
 
 	// create a random test id to name all the resources used in this test
-	imageName, err := boot.GenerateRandomString("osbuild-image-tests-vmware-image-")
+	imageName, err := GenerateCIArtifactName("osbuild-image-tests-vmware-image-")
 	require.NoError(t, err)
 
 	// the following line should be done by osbuild-composer at some point
diff --git a/internal/boot/azuretest/azure.go b/internal/boot/azuretest/azure.go
index d5463c7dc..9d1ef8305 100644
--- a/internal/boot/azuretest/azure.go
+++ b/internal/boot/azuretest/azure.go
@@ -14,6 +14,7 @@ import (
 	"github.com/Azure/azure-sdk-for-go/services/network/mgmt/2019-09-01/network"
 	"github.com/Azure/azure-sdk-for-go/services/resources/mgmt/2019-05-01/resources"
 	"github.com/Azure/azure-storage-blob-go/azblob"
+	"github.com/Azure/go-autorest/autorest"
 	"github.com/Azure/go-autorest/autorest/azure/auth"
 
 	"github.com/osbuild/osbuild-composer/internal/upload/azure"
@@ -156,33 +157,15 @@ func deleteResource(client resources.Client, id string, apiVersion string) error
 	return nil
 }
 
-// WithBootedImageInAzure runs the function f in the context of booted
-// image in Azure
-func WithBootedImageInAzure(creds *azureCredentials, imageName, testId, publicKeyFile string, f func(address string) error) (retErr error) {
-	publicKey, err := readPublicKey(publicKeyFile)
-	if err != nil {
-		return err
-	}
-
-	clientCredentialsConfig := auth.NewClientCredentialsConfig(creds.ClientID, creds.ClientSecret, creds.TenantID)
-	authorizer, err := clientCredentialsConfig.Authorizer()
-	if err != nil {
-		return fmt.Errorf("cannot create the authorizer: %v", err)
-	}
-
-	template, err := loadDeploymentTemplate()
-	if err != nil {
-		return err
-	}
-
+func NewDeploymentParameters(creds *azureCredentials, imageName, testId, publicKey string) DeploymentParameters {
 	// Azure requires a lot of names - for a virtual machine, a virtual network,
 	// a virtual interface and so on and so forth.
 	// Let's create all of them here from the test id so we can delete them
 	// later.
-	deploymentName := testId
+
 	imagePath := fmt.Sprintf("https://%s.blob.core.windows.net/%s/%s", creds.StorageAccount, creds.ContainerName, imageName)
 
-	parameters := deploymentParameters{
+	return DeploymentParameters{
 		NetworkInterfaceName:     newDeploymentParameter("iface-" + testId),
 		NetworkSecurityGroupName: newDeploymentParameter("nsg-" + testId),
 		VirtualNetworkName:       newDeploymentParameter("vnet-" + testId),
@@ -195,10 +178,129 @@ func WithBootedImageInAzure(creds *azureCredentials, imageName, testId, publicKe
 		AdminUsername:            newDeploymentParameter("redhat"),
 		AdminPublicKey:           newDeploymentParameter(publicKey),
 	}
+}
+
+func CleanUpBootedVM(creds *azureCredentials, parameters DeploymentParameters, authorizer autorest.Authorizer, testId string) (retErr error) {
+	deploymentName := testId
 
 	deploymentsClient := resources.NewDeploymentsClient(creds.SubscriptionID)
 	deploymentsClient.Authorizer = authorizer
 
+	resourcesClient := resources.NewClient(creds.SubscriptionID)
+	resourcesClient.Authorizer = authorizer
+
+	// This array specifies all the resources we need to delete. The
+	// order is important, e.g. one cannot delete a network interface
+	// that is still attached to a virtual machine.
+	resourcesToDelete := []struct {
+		resType    string
+		name       string
+		apiVersion string
+	}{
+		{
+			resType:    "Microsoft.Compute/virtualMachines",
+			name:       parameters.VirtualMachineName.Value,
+			apiVersion: "2019-07-01",
+		},
+		{
+			resType:    "Microsoft.Network/networkInterfaces",
+			name:       parameters.NetworkInterfaceName.Value,
+			apiVersion: "2019-09-01",
+		},
+		{
+			resType:    "Microsoft.Network/publicIPAddresses",
+			name:       parameters.PublicIPAddressName.Value,
+			apiVersion: "2019-09-01",
+		},
+		{
+			resType:    "Microsoft.Network/networkSecurityGroups",
+			name:       parameters.NetworkSecurityGroupName.Value,
+			apiVersion: "2019-09-01",
+		},
+		{
+			resType:    "Microsoft.Network/virtualNetworks",
+			name:       parameters.VirtualNetworkName.Value,
+			apiVersion: "2019-09-01",
+		},
+		{
+			resType:    "Microsoft.Compute/disks",
+			name:       parameters.DiskName.Value,
+			apiVersion: "2019-07-01",
+		},
+		{
+			resType:    "Microsoft.Compute/images",
+			name:       parameters.ImageName.Value,
+			apiVersion: "2019-07-01",
+		},
+	}
+
+	// Delete all the resources
+	for _, resourceToDelete := range resourcesToDelete {
+		resourceID := fmt.Sprintf(
+			"subscriptions/%s/resourceGroups/%s/providers/%s/%s",
+			creds.SubscriptionID,
+			creds.ResourceGroup,
+			resourceToDelete.resType,
+			resourceToDelete.name,
+		)
+
+		err := deleteResource(resourcesClient, resourceID, resourceToDelete.apiVersion)
+		if err != nil {
+			log.Printf("deleting the resource %s errored: %v", resourceToDelete.name, err)
+			retErr = wrapErrorf(retErr, "cannot delete the resource %s: %v", resourceToDelete.name, err)
+			// do not return here, try deleting as much as possible
+		}
+	}
+
+	// Delete the deployment
+	// This actually does not delete any resources created by the
+	// deployment as one might think. Therefore the code above
+	// is needed.
+	result, err := deploymentsClient.Delete(context.Background(), creds.ResourceGroup, deploymentName)
+	if err != nil {
+		retErr = wrapErrorf(retErr, "cannot create the request for the deployment deletion: %v", err)
+		return
+	}
+
+	err = result.WaitForCompletionRef(context.Background(), deploymentsClient.Client)
+	if err != nil {
+		retErr = wrapErrorf(retErr, "waiting for the deployment deletion failed: %v", err)
+		return
+	}
+
+	_, err = result.Result(deploymentsClient)
+	if err != nil {
+		retErr = wrapErrorf(retErr, "cannot retrieve the deployment deletion result: %v", err)
+		return
+	}
+	return
+}
+
+// WithBootedImageInAzure runs the function f in the context of booted
+// image in Azure
+func WithBootedImageInAzure(creds *azureCredentials, imageName, testId, publicKeyFile string, f func(address string) error) (retErr error) {
+	publicKey, err := readPublicKey(publicKeyFile)
+	if err != nil {
+		return err
+	}
+
+	clientCredentialsConfig := auth.NewClientCredentialsConfig(creds.ClientID, creds.ClientSecret, creds.TenantID)
+	authorizer, err := clientCredentialsConfig.Authorizer()
+	if err != nil {
+		return fmt.Errorf("cannot create the authorizer: %v", err)
+	}
+
+	template, err := loadDeploymentTemplate()
+	if err != nil {
+		return err
+	}
+
+	deploymentsClient := resources.NewDeploymentsClient(creds.SubscriptionID)
+	deploymentsClient.Authorizer = authorizer
+
+	deploymentName := testId
+	parameters := NewDeploymentParameters(creds, imageName, testId, publicKey)
+
 	deploymentFuture, err := deploymentsClient.CreateOrUpdate(context.Background(), creds.ResourceGroup, deploymentName, resources.Deployment{
 		Properties: &resources.DeploymentProperties{
 			Mode:       resources.Incremental,
@@ -209,93 +311,7 @@ func WithBootedImageInAzure(creds *azureCredentials, imageName, testId, publicKe
 
 	// Let's registed the clean-up function as soon as possible.
 	defer func() {
-		resourcesClient := resources.NewClient(creds.SubscriptionID)
-		resourcesClient.Authorizer = authorizer
-
-		// This array specifies all the resources we need to delete. The
-		// order is important, e.g. one cannot delete a network interface
-		// that is still attached to a virtual machine.
-		resourcesToDelete := []struct {
-			resType    string
-			name       string
-			apiVersion string
-		}{
-			{
-				resType:    "Microsoft.Compute/virtualMachines",
-				name:       parameters.VirtualMachineName.Value,
-				apiVersion: "2019-07-01",
-			},
-			{
-				resType:    "Microsoft.Network/networkInterfaces",
-				name:       parameters.NetworkInterfaceName.Value,
-				apiVersion: "2019-09-01",
-			},
-			{
-				resType:    "Microsoft.Network/publicIPAddresses",
-				name:       parameters.PublicIPAddressName.Value,
-				apiVersion: "2019-09-01",
-			},
-			{
-				resType:    "Microsoft.Network/networkSecurityGroups",
-				name:       parameters.NetworkSecurityGroupName.Value,
-				apiVersion: "2019-09-01",
-			},
-			{
-				resType:    "Microsoft.Network/virtualNetworks",
-				name:       parameters.VirtualNetworkName.Value,
-				apiVersion: "2019-09-01",
-			},
-			{
-				resType:    "Microsoft.Compute/disks",
-				name:       parameters.DiskName.Value,
-				apiVersion: "2019-07-01",
-			},
-			{
-				resType:    "Microsoft.Compute/images",
-				name:       parameters.ImageName.Value,
-				apiVersion: "2019-07-01",
-			},
-		}
-
-		// Delete all the resources
-		for _, resourceToDelete := range resourcesToDelete {
-			resourceID := fmt.Sprintf(
-				"subscriptions/%s/resourceGroups/%s/providers/%s/%s",
-				creds.SubscriptionID,
-				creds.ResourceGroup,
-				resourceToDelete.resType,
-				resourceToDelete.name,
-			)
-
-			err := deleteResource(resourcesClient, resourceID, resourceToDelete.apiVersion)
-			if err != nil {
-				log.Printf("deleting the resource %s errored: %v", resourceToDelete.name, err)
-				retErr = wrapErrorf(retErr, "cannot delete the resource %s: %v", resourceToDelete.name, err)
-				// do not return here, try deleting as much as possible
-			}
-		}
-
-		// Delete the deployment
-		// This actually does not delete any resources created by the
-		// deployment as one might think. Therefore the code above
-		// is needed.
-		result, err := deploymentsClient.Delete(context.Background(), creds.ResourceGroup, deploymentName)
-		if err != nil {
-			retErr = wrapErrorf(retErr, "cannot create the request for the deployment deletion: %v", err)
-			return
-		}
-
-		err = result.WaitForCompletionRef(context.Background(), deploymentsClient.Client)
-		if err != nil {
-			retErr = wrapErrorf(retErr, "waiting for the deployment deletion failed: %v", err)
-			return
-		}
-
-		_, err = result.Result(deploymentsClient)
-		if err != nil {
-			retErr = wrapErrorf(retErr, "cannot retrieve the deployment deletion result: %v", err)
-			return
-		}
+		retErr = CleanUpBootedVM(creds, parameters, authorizer, testId)
 	}()
 
 	if err != nil {
diff --git a/internal/boot/azuretest/deployment.go b/internal/boot/azuretest/deployment.go
index f3e51a1d0..86528efc9 100644
--- a/internal/boot/azuretest/deployment.go
+++ b/internal/boot/azuretest/deployment.go
@@ -41,7 +41,7 @@ func newDeploymentParameter(value string) deploymentParameter {
 }
 
 // struct for encoding deployment parameters
-type deploymentParameters struct {
+type DeploymentParameters struct {
 	NetworkInterfaceName     deploymentParameter `json:"networkInterfaceName"`
 	NetworkSecurityGroupName deploymentParameter `json:"networkSecurityGroupName"`
 	VirtualNetworkName       deploymentParameter `json:"virtualNetworkName"`
diff --git a/internal/worker/server_test.go b/internal/worker/server_test.go
index baa1db5f1..ee3200613 100644
--- a/internal/worker/server_test.go
+++ b/internal/worker/server_test.go
@@ -1,11 +1,11 @@
 package worker_test
 
 import (
+	"context"
 	"fmt"
 	"net/http"
 	"testing"
 
-	"github.com/google/uuid"
 	"github.com/stretchr/testify/require"
 
 	"github.com/osbuild/osbuild-composer/internal/distro"
@@ -31,15 +31,15 @@ func TestErrors(t *testing.T) {
 		// Bogus path
 		{"GET", "/foo", ``, http.StatusNotFound},
 		// Create job with invalid body
-		{"POST", "/job-queue/v1/jobs", ``, http.StatusBadRequest},
+		{"POST", "/jobs", ``, http.StatusBadRequest},
 		// Wrong method
-		{"GET", "/job-queue/v1/jobs", ``, http.StatusMethodNotAllowed},
+		{"GET", "/jobs", ``, http.StatusMethodNotAllowed},
 		// Update job with invalid ID
-		{"PATCH", "/job-queue/v1/jobs/foo", `{"status":"FINISHED"}`, http.StatusBadRequest},
+		{"PATCH", "/jobs/foo", `{"status":"FINISHED"}`, http.StatusBadRequest},
 		// Update job that does not exist, with invalid body
-		{"PATCH", "/job-queue/v1/jobs/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", ``, http.StatusBadRequest},
+		{"PATCH", "/jobs/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", ``, http.StatusBadRequest},
 		// Update job that does not exist
-		{"PATCH", "/job-queue/v1/jobs/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", `{"status":"FINISHED"}`, http.StatusNotFound},
+		{"PATCH", "/jobs/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", `{"status":"FINISHED"}`, http.StatusNotFound},
 	}
 
 	for _, c := range cases {
@@ -64,14 +64,11 @@ func TestCreate(t *testing.T) {
 	}
 	server := worker.NewServer(nil, testjobqueue.New(), "")
 
-	id, err := server.Enqueue(manifest, nil)
+	_, err = server.Enqueue(manifest, nil)
 	require.NoError(t, err)
 
-	test.TestRoute(t, server, false, "POST", "/job-queue/v1/jobs", `{}`, http.StatusCreated,
-		`{"id":"`+id.String()+`","manifest":{"sources":{},"pipeline":{}}}`, "created")
-
-	test.TestRoute(t, server, false, "GET", fmt.Sprintf("/job-queue/v1/jobs/%s", id), `{}`, http.StatusOK,
-		`{"id":"`+id.String()+`","canceled":false}`)
+	test.TestRoute(t, server, false, "POST", "/jobs", `{}`, http.StatusCreated,
+		`{"manifest":{"sources":{},"pipeline":{}}}`, "id", "location", "artifact_location")
 }
 
 func TestCancel(t *testing.T) {
@@ -90,82 +87,16 @@ func TestCancel(t *testing.T) {
 	}
 	server := worker.NewServer(nil, testjobqueue.New(), "")
 
-	id, err := server.Enqueue(manifest, nil)
+	jobId, err := server.Enqueue(manifest, nil)
 	require.NoError(t, err)
 
-	test.TestRoute(t, server, false, "POST", "/job-queue/v1/jobs", `{}`, http.StatusCreated,
-		`{"id":"`+id.String()+`","manifest":{"sources":{},"pipeline":{}}}`, "created")
-
-	err = server.Cancel(id)
+	token, j, _, err := server.RequestJob(context.Background())
 	require.NoError(t, err)
+	require.Equal(t, jobId, j)
 
-	test.TestRoute(t, server, false, "GET", fmt.Sprintf("/job-queue/v1/jobs/%s", id), `{}`, http.StatusOK,
-		`{"id":"`+id.String()+`","canceled":true}`)
-}
-
-func testUpdateTransition(t *testing.T, from, to string, expectedStatus int) {
-	distroStruct := fedoratest.New()
-	arch, err := distroStruct.GetArch("x86_64")
-	if err != nil {
-		t.Fatalf("error getting arch from distro")
-	}
-	imageType, err := arch.GetImageType("qcow2")
-	if err != nil {
-		t.Fatalf("error getting image type from arch")
-	}
-	server := worker.NewServer(nil, testjobqueue.New(), "")
-
-	id := uuid.Nil
-	if from != "VOID" {
-		manifest, err := imageType.Manifest(nil, distro.ImageOptions{Size: imageType.Size(0)}, nil, nil, nil)
-		if err != nil {
-			t.Fatalf("error creating osbuild manifest")
-		}
-
-		id, err = server.Enqueue(manifest, nil)
-		require.NoError(t, err)
-
-		if from != "WAITING" {
-			test.SendHTTP(server, false, "POST", "/job-queue/v1/jobs", `{}`)
-			if from != "RUNNING" {
-				test.SendHTTP(server, false, "PATCH", "/job-queue/v1/jobs/"+id.String(), `{"status":"`+from+`"}`)
-			}
-		}
-	}
-
-	test.TestRoute(t, server, false, "PATCH", "/job-queue/v1/jobs/"+id.String(), `{"status":"`+to+`"}`, expectedStatus, "{}", "message")
-}
-
-func TestUpdate(t *testing.T) {
-	var cases = []struct {
-		From           string
-		To             string
-		ExpectedStatus int
-	}{
-		{"VOID", "WAITING", http.StatusBadRequest},
-		{"VOID", "RUNNING", http.StatusBadRequest},
-		{"VOID", "FINISHED", http.StatusNotFound},
-		{"VOID", "FAILED", http.StatusNotFound},
-		{"WAITING", "WAITING", http.StatusBadRequest},
-		{"WAITING", "RUNNING", http.StatusBadRequest},
-		{"WAITING", "FINISHED", http.StatusBadRequest},
-		{"WAITING", "FAILED", http.StatusBadRequest},
-		{"RUNNING", "WAITING", http.StatusBadRequest},
-		{"RUNNING", "RUNNING", http.StatusBadRequest},
-		{"RUNNING", "FINISHED", http.StatusOK},
-		{"RUNNING", "FAILED", http.StatusOK},
-		{"FINISHED", "WAITING", http.StatusBadRequest},
-		{"FINISHED", "RUNNING", http.StatusBadRequest},
-		{"FINISHED", "FINISHED", http.StatusBadRequest},
-		{"FINISHED", "FAILED", http.StatusBadRequest},
-		{"FAILED", "WAITING", http.StatusBadRequest},
-		{"FAILED", "RUNNING", http.StatusBadRequest},
-		{"FAILED", "FINISHED", http.StatusBadRequest},
-		{"FAILED", "FAILED", http.StatusBadRequest},
-	}
+	err = server.Cancel(jobId)
+	require.NoError(t, err)
 
-	for _, c := range cases {
-		t.Log(c)
-		testUpdateTransition(t, c.From, c.To, c.ExpectedStatus)
-	}
+	test.TestRoute(t, server, false, "GET", fmt.Sprintf("/jobs/%s", token), `{}`, http.StatusOK,
+		`{"canceled":true}`)
 }
diff --git a/schutzbot/run_image_tests.sh b/schutzbot/run_image_tests.sh
index 38712ae33..213e24ba0 100755
--- a/schutzbot/run_image_tests.sh
+++ b/schutzbot/run_image_tests.sh
@@ -58,7 +58,7 @@ run_test_case () {
     AZURE_CREDS=${AZURE_CREDS-/dev/null}
     OPENSTACK_CREDS=${OPENSTACK_CREDS-/dev/null}
     VCENTER_CREDS=${VCENTER_CREDS-/dev/null}
-    TEST_CMD="env $(cat $AZURE_CREDS $OPENSTACK_CREDS $VCENTER_CREDS) $TEST_RUNNER -test.v ${IMAGE_TEST_CASES_PATH}/${TEST_CASE_FILENAME}"
+    TEST_CMD="env $(cat $AZURE_CREDS $OPENSTACK_CREDS $VCENTER_CREDS) CHANGE_ID=$CHANGE_ID BUILD_ID=$BUILD_ID DISTRO_CODE=$DISTRO_CODE $TEST_RUNNER -test.v ${IMAGE_TEST_CASES_PATH}/${TEST_CASE_FILENAME}"
 
     # Run the test and add the test name to the list of passed or failed
     # tests depending on the result.