Repository: osbuild/osbuild-composer
Pull request #4189 (2024-06-05 11:03:39)

cmd/osbuild-worker-executor: add oaas as worker executor
This PR is a split out of https://github.com/osbuild/osbuild-composer/pull/4184

It adds the new `osbuild-worker-executor` without actually using it (yet). This way we can retire the old https://github.com/mvo5/oaas repo and work in the osbuild-composer repo together


Patch:
diff --git a/.golangci.yml b/.golangci.yml
index 86dce5fb3..0ee29297c 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -23,3 +23,6 @@ issues:
 
   # Maximum count of issues with the same text. Set to 0 to disable. Default is 3.
   max-same-issues: 0
+  exclude-rules:
+    - path: ^cmd/osbuild-worker-executor/.*_test\.go$
+      linters: ["gosec"]
diff --git a/Makefile b/Makefile
index fd6dcab89..7ecb2e8de 100644
--- a/Makefile
+++ b/Makefile
@@ -118,6 +118,7 @@ man: $(MANPAGES_TROFF)
 build: $(BUILDDIR)/bin/
 	go build -o $<osbuild-composer ./cmd/osbuild-composer/
 	go build -o $<osbuild-worker ./cmd/osbuild-worker/
+	go build -o $<osbuild-worker-executor ./cmd/osbuild-worker-executor/
 	go build -o $<osbuild-upload-azure ./cmd/osbuild-upload-azure/
 	go build -o $<osbuild-upload-aws ./cmd/osbuild-upload-aws/
 	go build -o $<osbuild-upload-gcp ./cmd/osbuild-upload-gcp/
diff --git a/cmd/osbuild-worker-executor/build_result.go b/cmd/osbuild-worker-executor/build_result.go
new file mode 100644
index 000000000..b52ef38c8
--- /dev/null
+++ b/cmd/osbuild-worker-executor/build_result.go
@@ -0,0 +1,37 @@
+package main
+
+import (
+	"os"
+	"path/filepath"
+)
+
+type buildResult struct {
+	resultGood string
+	resultBad  string
+}
+
+func newBuildResult(config *Config) *buildResult {
+	return &buildResult{
+		resultGood: filepath.Join(config.BuildDirBase, "result.good"),
+		resultBad:  filepath.Join(config.BuildDirBase, "result.bad"),
+	}
+}
+
+func (br *buildResult) Mark(err error) error {
+	if err == nil {
+		return os.WriteFile(br.resultGood, nil, 0600)
+	} else {
+		return os.WriteFile(br.resultBad, nil, 0600)
+	}
+}
+
+// todo: switch to (Good, Bad, Unknown)
+func (br *buildResult) Good() bool {
+	_, err := os.Stat(br.resultGood)
+	return err == nil
+}
+
+func (br *buildResult) Bad() bool {
+	_, err := os.Stat(br.resultBad)
+	return err == nil
+}
diff --git a/cmd/osbuild-worker-executor/config.go b/cmd/osbuild-worker-executor/config.go
new file mode 100644
index 000000000..f570923c5
--- /dev/null
+++ b/cmd/osbuild-worker-executor/config.go
@@ -0,0 +1,25 @@
+package main
+
+import (
+	"flag"
+)
+
+type Config struct {
+	Host string
+	Port string
+
+	BuildDirBase string
+}
+
+func newConfigFromCmdline(args []string) (*Config, error) {
+	var config Config
+
+	fs := flag.NewFlagSet("worker-executor", flag.ContinueOnError)
+	fs.StringVar(&config.Host, "host", "localhost", "host to listen on")
+	fs.StringVar(&config.Port, "port", "8001", "port to listen on")
+	fs.StringVar(&config.BuildDirBase, "build-path", "/var/tmp/worker-executor", "base dir to run the builds in")
+	if err := fs.Parse(args); err != nil {
+		return nil, err
+	}
+	return &config, nil
+}
diff --git a/cmd/osbuild-worker-executor/handler_build.go b/cmd/osbuild-worker-executor/handler_build.go
new file mode 100644
index 000000000..a790ae523
--- /dev/null
+++ b/cmd/osbuild-worker-executor/handler_build.go
@@ -0,0 +1,299 @@
+package main
+
+import (
+	"archive/tar"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+
+	"golang.org/x/exp/slices"
+
+	"github.com/sirupsen/logrus"
+)
+
+var (
+	supportedBuildContentTypes = []string{"application/x-tar"}
+	osbuildBinary              = "osbuild"
+)
+
+var (
+	ErrAlreadyBuilding = errors.New("build already starte")
+)
+
+type writeFlusher struct {
+	w       io.Writer
+	flusher http.Flusher
+}
+
+func (wf *writeFlusher) Write(p []byte) (n int, err error) {
+	n, err = wf.w.Write(p)
+	if wf.flusher != nil {
+		wf.flusher.Flush()
+	}
+	return n, err
+}
+
+func runOsbuild(buildDir string, control *controlJSON, output io.Writer) (string, error) {
+	flusher, ok := output.(http.Flusher)
+	if !ok {
+		return "", fmt.Errorf("cannot stream the output")
+	}
+	// stream output over http
+	wf := writeFlusher{w: output, flusher: flusher}
+	// note that the "filepath.Clean()" is here for gosec:G304
+	logfPath := filepath.Clean(filepath.Join(buildDir, "build.log"))
+	// and also write to our internal log
+	logf, err := os.Create(logfPath)
+	if err != nil {
+		return "", fmt.Errorf("cannot create log file: %v", err)
+	}
+	defer logf.Close()
+
+	// use multi writer to get same output for stream and log
+	mw := io.MultiWriter(&wf, logf)
+	outputDir := filepath.Join(buildDir, "output")
+	storeDir := filepath.Join(buildDir, "store")
+	cmd := exec.Command(osbuildBinary)
+	cmd.Stdout = mw
+	cmd.Stderr = mw
+	for _, exp := range control.Exports {
+		cmd.Args = append(cmd.Args, []string{"--export", exp}...)
+	}
+	cmd.Env = append(cmd.Env, control.Environments...)
+	cmd.Args = append(cmd.Args, []string{"--output-dir", outputDir}...)
+	cmd.Args = append(cmd.Args, []string{"--store", storeDir}...)
+	cmd.Args = append(cmd.Args, "--json")
+	cmd.Args = append(cmd.Args, filepath.Join(buildDir, "manifest.json"))
+	if err := cmd.Start(); err != nil {
+		return "", err
+	}
+
+	if err := cmd.Wait(); err != nil {
+		// we cannot use "http.Error()" here because the http
+		// header was already set to "201" when we started streaming
+		_, _ = mw.Write([]byte(fmt.Sprintf("cannot run osbuild: %v", err)))
+		return "", err
+	}
+
+	// #nosec G204
+	cmd = exec.Command(
+		"tar",
+		"-Scf",
+		filepath.Join(outputDir, "output.tar"),
+		"output",
+	)
+	cmd.Dir = buildDir
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		err = fmt.Errorf("cannot tar output directory: %w, output:\n%s", err, out)
+		logrus.Errorf(err.Error())
+		_, _ = mw.Write([]byte(err.Error()))
+		return "", err
+	}
+	logrus.Infof("tar output:\n%s", out)
+	return outputDir, nil
+}
+
+type controlJSON struct {
+	Environments []string `json:"environments"`
+	Exports      []string `json:"exports"`
+}
+
+func mustRead(atar *tar.Reader, name string) error {
+	hdr, err := atar.Next()
+	if err != nil {
+		return fmt.Errorf("cannot read tar %v: %v", name, err)
+	}
+	if hdr.Name != name {
+		return fmt.Errorf("expected tar %v, got %v", name, hdr.Name)
+	}
+	return nil
+}
+
+func handleControlJSON(atar *tar.Reader) (*controlJSON, error) {
+	if err := mustRead(atar, "control.json"); err != nil {
+		return nil, err
+	}
+
+	var control controlJSON
+	if err := json.NewDecoder(atar).Decode(&control); err != nil {
+		return nil, err
+	}
+	return &control, nil
+}
+
+func createBuildDir(config *Config) (string, error) {
+	buildDirBase := config.BuildDirBase
+
+	// we could create a per-build dir here but the goal is to
+	// only have a single build only so we don't bother
+	if err := os.MkdirAll(buildDirBase, 0700); err != nil {
+		return "", fmt.Errorf("cannot create build base dir: %v", err)
+	}
+
+	// ensure there is only a single build
+	buildDir := filepath.Join(buildDirBase, "build")
+	if err := os.Mkdir(buildDir, 0700); err != nil {
+		if os.IsExist(err) {
+			return "", ErrAlreadyBuilding
+		}
+		return "", err
+	}
+
+	return buildDir, nil
+}
+
+func handleManifestJSON(atar *tar.Reader, buildDir string) error {
+	if err := mustRead(atar, "manifest.json"); err != nil {
+		return err
+	}
+	manifestJSONPath := filepath.Join(buildDir, "manifest.json")
+
+	// note that the filepath.Clean() is here just to appease gosec:G304
+	f, err := os.Create(filepath.Clean(manifestJSONPath))
+	if err != nil {
+		return fmt.Errorf("cannot create manifest.json: %v", err)
+	}
+	defer f.Close()
+
+	if _, err := io.Copy(f, atar); err != nil {
+		return fmt.Errorf("cannot read body: %v", err)
+	}
+
+	if err := f.Close(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func handleIncludedSources(atar *tar.Reader, buildDir string) error {
+	for {
+		hdr, err := atar.Next()
+		if err == io.EOF {
+			return nil
+		}
+		if err != nil {
+			return fmt.Errorf("cannot read from tar %v", err)
+		}
+
+		// ensure we only allow "store/" things
+		name := hdr.Name
+		if filepath.Clean(name) != strings.TrimSuffix(name, "/") {
+			return fmt.Errorf("name not clean: %v != %v", filepath.Clean(name), name)
+		}
+		if !strings.HasPrefix(name, "store/") {
+			return fmt.Errorf("expected store/ prefix, got %v", hdr.Name)
+		}
+		// note that the extra filepath.Clean() is just there to
+		// appease gosec G305
+		target := filepath.Join(buildDir, filepath.Clean(name))
+
+		// this assume "well" behaving tars, i.e. all dirs that lead
+		// up to the tar are included etc
+		mode := os.FileMode(hdr.Mode)
+		switch hdr.Typeflag {
+		case tar.TypeDir:
+			if err := os.Mkdir(target, mode); err != nil {
+				return fmt.Errorf("unpack: %w", err)
+			}
+		case tar.TypeReg:
+			// Note that the G304 here is silly, target is carefully
+			// checked (and is no error in the flow above). Mode is
+			// not relevant for an information leak. But it seems
+			// there is no way to get a debug trace from gosec what
+			// exactly is wrong.
+			// #nosec G304
+			f, err := os.OpenFile(target, os.O_RDWR|os.O_CREATE, mode)
+			if err != nil {
+				return fmt.Errorf("unpack: %w", err)
+			}
+			defer f.Close()
+			if _, err := io.Copy(f, atar); err != nil {
+				return fmt.Errorf("unpack: %w", err)
+			}
+			if err := f.Close(); err != nil {
+				return fmt.Errorf("unpack: %w", err)
+			}
+		default:
+			return fmt.Errorf("unsupported tar type %v", hdr.Typeflag)
+		}
+		if err := os.Chtimes(target, hdr.AccessTime, hdr.ModTime); err != nil {
+			return fmt.Errorf("unpack: %w", err)
+		}
+	}
+}
+
+// test for real via:
+// curl -o - --data-binary "@./test.tar" -H "Content-Type: application/x-tar"  -X POST http://localhost:8001/api/v1/build
+func handleBuild(logger *logrus.Logger, config *Config) http.Handler {
+	return http.HandlerFunc(
+		func(w http.ResponseWriter, r *http.Request) {
+			logger.Debugf("handlerBuild called on %s", r.URL.Path)
+			defer r.Body.Close()
+
+			if r.Method != http.MethodPost {
+				http.Error(w, "build endpoint only supports POST", http.StatusMethodNotAllowed)
+				return
+			}
+
+			contentType := r.Header.Get("Content-Type")
+			if !slices.Contains(supportedBuildContentTypes, contentType) {
+				http.Error(w, fmt.Sprintf("Content-Type must be %v, got %v", supportedBuildContentTypes, contentType), http.StatusUnsupportedMediaType)
+				return
+			}
+
+			// control.json passes the build parameters
+			atar := tar.NewReader(r.Body)
+			control, err := handleControlJSON(atar)
+			if err != nil {
+				logger.Error(err)
+				http.Error(w, "cannot decode control.json", http.StatusBadRequest)
+				return
+			}
+
+			buildDir, err := createBuildDir(config)
+			if err != nil {
+				logger.Error(err)
+				if err == ErrAlreadyBuilding {
+					http.Error(w, "build already started", http.StatusConflict)
+				} else {
+					http.Error(w, "create build dir", http.StatusBadRequest)
+				}
+				return
+			}
+
+			// manifest.json is the osbuild input
+			if err := handleManifestJSON(atar, buildDir); err != nil {
+				logger.Error(err)
+				http.Error(w, "manifest.json", http.StatusBadRequest)
+				return
+			}
+			// extract ".osbuild/sources" here too from the tar
+			if err := handleIncludedSources(atar, buildDir); err != nil {
+				logger.Error(err)
+				http.Error(w, "included sources/", http.StatusBadRequest)
+				return
+			}
+
+			w.WriteHeader(http.StatusCreated)
+
+			// run osbuild and stream the output to the client
+			buildResult := newBuildResult(config)
+			_, err = runOsbuild(buildDir, control, w)
+			if werr := buildResult.Mark(err); werr != nil {
+				logger.Errorf("cannot write result file %v", werr)
+			}
+			if err != nil {
+				logger.Errorf("canot run osbuild: %v", err)
+				return
+			}
+		},
+	)
+}
diff --git a/cmd/osbuild-worker-executor/handler_result.go b/cmd/osbuild-worker-executor/handler_result.go
new file mode 100644
index 000000000..cbcaf02e4
--- /dev/null
+++ b/cmd/osbuild-worker-executor/handler_result.go
@@ -0,0 +1,45 @@
+package main
+
+import (
+	"io"
+	"net/http"
+	"os"
+	"path/filepath"
+
+	"github.com/sirupsen/logrus"
+)
+
+func handleResult(logger *logrus.Logger, config *Config) http.Handler {
+	return http.HandlerFunc(
+		func(w http.ResponseWriter, r *http.Request) {
+			logger.Debugf("handlerResult called on %s", r.URL.Path)
+			if r.Method != http.MethodGet {
+				http.Error(w, "result endpoint only supports Get", http.StatusMethodNotAllowed)
+				return
+			}
+			buildResult := newBuildResult(config)
+			switch {
+			case buildResult.Bad():
+				http.Error(w, "build failed", http.StatusBadRequest)
+				f, err := os.Open(filepath.Join(config.BuildDirBase, "build/build.log"))
+				if err != nil {
+					logger.Errorf("cannot open log: %v", err)
+					return
+				}
+				defer f.Close()
+				if _, err := io.Copy(w, f); err != nil {
+					logger.Errorf("Unable to write log to response")
+				}
+				return
+			case buildResult.Good():
+				// good result
+			default:
+				http.Error(w, "build still running", http.StatusTooEarly)
+				return
+			}
+
+			fss := http.FileServer(http.Dir(filepath.Join(config.BuildDirBase, "build/output")))
+			fss.ServeHTTP(w, r)
+		},
+	)
+}
diff --git a/cmd/osbuild-worker-executor/handler_root.go b/cmd/osbuild-worker-executor/handler_root.go
new file mode 100644
index 000000000..eafeca05b
--- /dev/null
+++ b/cmd/osbuild-worker-executor/handler_root.go
@@ -0,0 +1,16 @@
+package main
+
+import (
+	"net/http"
+
+	"github.com/sirupsen/logrus"
+)
+
+func handleRoot(logger *logrus.Logger, _ *Config) http.Handler {
+	return http.HandlerFunc(
+		func(w http.ResponseWriter, r *http.Request) {
+			// we just return ok here
+			logger.Info("/ handler called")
+		},
+	)
+}
diff --git a/cmd/osbuild-worker-executor/main.go b/cmd/osbuild-worker-executor/main.go
new file mode 100644
index 000000000..59edfcbc9
--- /dev/null
+++ b/cmd/osbuild-worker-executor/main.go
@@ -0,0 +1,83 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"net"
+	"net/http"
+	"os"
+	"os/signal"
+	"sync"
+	"time"
+
+	"github.com/sirupsen/logrus"
+)
+
+// based on the excellent post
+// https://grafana.com/blog/2024/02/09/how-i-write-http-services-in-go-after-13-years/
+
+var logrusNew = logrus.New
+
+func newServer(logger *logrus.Logger, config *Config) http.Handler {
+	mux := http.NewServeMux()
+	addRoutes(mux, logger, config)
+	var handler http.Handler = mux
+	// todo: consider centralize logginer here?
+	//handler = loggingMiddleware(handler)
+	return handler
+}
+
+func run(ctx context.Context, args []string, getenv func(string) string) error {
+	ctx, cancel := signal.NotifyContext(ctx, os.Interrupt)
+	defer cancel()
+
+	logger := logrusNew()
+	config, err := newConfigFromCmdline(args)
+	if err != nil {
+		return err
+	}
+
+	srv := newServer(logger, config)
+	httpServer := &http.Server{
+		Addr:              net.JoinHostPort(config.Host, config.Port),
+		Handler:           srv,
+		ReadHeaderTimeout: 10 * time.Second,
+	}
+	go func() {
+		logger.Printf("listening on %s\n", httpServer.Addr)
+		if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+			fmt.Fprintf(os.Stderr, "error listening and serving: %s\n", err)
+		}
+	}()
+
+	// todo: this seems kinda complicated, why a waitgroup and not
+	// do it flat?
+	var wg sync.WaitGroup
+	wg.Add(1)
+	go func() {
+		defer wg.Done()
+		<-ctx.Done()
+		shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+		defer cancel()
+		if err := httpServer.Shutdown(shutdownCtx); err != nil {
+			fmt.Fprintf(os.Stderr, "error shutting down http server: %s\n", err)
+		}
+	}()
+	wg.Wait()
+
+	// cleanup
+	if err := os.RemoveAll(config.BuildDirBase); err != nil {
+		logger.Errorf("cannot cleanup: %v", err)
+		return err
+	}
+
+	return nil
+}
+
+func main() {
+	ctx := context.Background()
+	if err := run(ctx, os.Args, os.Getenv); err != nil {
+		fmt.Fprintf(os.Stderr, "%s\n", err)
+		os.Exit(1)
+	}
+}
diff --git a/cmd/osbuild-worker-executor/routes.go b/cmd/osbuild-worker-executor/routes.go
new file mode 100644
index 000000000..be75ed448
--- /dev/null
+++ b/cmd/osbuild-worker-executor/routes.go
@@ -0,0 +1,13 @@
+package main
+
+import (
+	"net/http"
+
+	"github.com/sirupsen/logrus"
+)
+
+func addRoutes(mux *http.ServeMux, logger *logrus.Logger, config *Config) {
+	mux.Handle("/api/v1/build", handleBuild(logger, config))
+	mux.Handle("/api/v1/result/", http.StripPrefix("/api/v1/result/", handleResult(logger, config)))
+	mux.Handle("/", handleRoot(logger, config))
+}
diff --git a/osbuild-composer.spec b/osbuild-composer.spec
index 36f65e026..1c7855897 100644
--- a/osbuild-composer.spec
+++ b/osbuild-composer.spec
@@ -106,6 +106,7 @@ export LDFLAGS="${LDFLAGS} -X 'github.com/osbuild/osbuild-composer/internal/comm
 
 %gobuild ${GOTAGS:+-tags=$GOTAGS} -o _bin/osbuild-composer %{goipath}/cmd/osbuild-composer
 %gobuild ${GOTAGS:+-tags=$GOTAGS} -o _bin/osbuild-worker %{goipath}/cmd/osbuild-worker
+%gobuild ${GOTAGS:+-tags=$GOTAGS} -o _bin/osbuild-worker-executor %{goipath}/cmd/osbuild-worker-executor
 %gobuild ${GOTAGS:+-tags=$GOTAGS} -o _bin/osbuild-jobsite-manager %{goipath}/cmd/osbuild-jobsite-manager
 %gobuild ${GOTAGS:+-tags=$GOTAGS} -o _bin/osbuild-jobsite-builder %{goipath}/cmd/osbuild-jobsite-builder
 
@@ -146,6 +147,7 @@ go build -tags="integration${GOTAGS:+,$GOTAGS}" -ldflags="${TEST_LDFLAGS}" -o _b
 install -m 0755 -vd                                                %{buildroot}%{_libexecdir}/osbuild-composer
 install -m 0755 -vp _bin/osbuild-composer                          %{buildroot}%{_libexecdir}/osbuild-composer/
 install -m 0755 -vp _bin/osbuild-worker                            %{buildroot}%{_libexecdir}/osbuild-composer/
+install -m 0755 -vp _bin/osbuild-worker-executor                   %{buildroot}%{_libexecdir}/osbuild-composer/
 install -m 0755 -vp _bin/osbuild-jobsite-manager                   %{buildroot}%{_libexecdir}/osbuild-composer/
 install -m 0755 -vp _bin/osbuild-jobsite-builder                   %{buildroot}%{_libexecdir}/osbuild-composer/
 
@@ -340,6 +342,7 @@ The worker for osbuild-composer
 
 %files worker
 %{_libexecdir}/osbuild-composer/osbuild-worker
+%{_libexecdir}/osbuild-composer/osbuild-worker-executor
 %{_libexecdir}/osbuild-composer/osbuild-jobsite-manager
 %{_libexecdir}/osbuild-composer/osbuild-jobsite-builder
 %{_unitdir}/osbuild-worker@.service
diff --git a/vendor/modules.txt b/vendor/modules.txt
index add036041..383426e1f 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -974,6 +974,7 @@ github.com/sigstore/sigstore/pkg/signature/payload
 # github.com/sirupsen/logrus v1.9.3
 ## explicit; go 1.13
 github.com/sirupsen/logrus
+github.com/sirupsen/logrus/hooks/test
 # github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
 ## explicit
 github.com/skratchdot/open-golang/open


Test patch:
diff --git a/cmd/osbuild-worker-executor/export_test.go b/cmd/osbuild-worker-executor/export_test.go
new file mode 100644
index 000000000..cf9b430d4
--- /dev/null
+++ b/cmd/osbuild-worker-executor/export_test.go
@@ -0,0 +1,46 @@
+package main
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/sirupsen/logrus"
+	logrusTest "github.com/sirupsen/logrus/hooks/test"
+)
+
+var (
+	Run = run
+
+	HandleIncludedSources = handleIncludedSources
+)
+
+func MockLogger() (hook *logrusTest.Hook, restore func()) {
+	saved := logrusNew
+	logger, hook := logrusTest.NewNullLogger()
+	logrusNew = func() *logrus.Logger {
+		return logger
+	}
+	logger.SetLevel(logrus.DebugLevel)
+
+	return hook, func() {
+		logrusNew = saved
+	}
+}
+
+func MockOsbuildBinary(t *testing.T, new string) (restore func()) {
+	t.Helper()
+
+	saved := osbuildBinary
+
+	tmpdir := t.TempDir()
+	osbuildBinary = filepath.Join(tmpdir, "fake-osbuild")
+	/* #nosec G306 */
+	if err := os.WriteFile(osbuildBinary, []byte(new), 0755); err != nil {
+		t.Fatal(err)
+	}
+
+	return func() {
+		osbuildBinary = saved
+	}
+}
diff --git a/cmd/osbuild-worker-executor/handler_build_test.go b/cmd/osbuild-worker-executor/handler_build_test.go
new file mode 100644
index 000000000..c89558bcb
--- /dev/null
+++ b/cmd/osbuild-worker-executor/handler_build_test.go
@@ -0,0 +1,322 @@
+package main_test
+
+import (
+	"archive/tar"
+	"bufio"
+	"bytes"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+
+	main "github.com/osbuild/osbuild-composer/cmd/osbuild-worker-executor"
+)
+
+func TestBuildMustPOST(t *testing.T) {
+	baseURL, _, loggerHook := runTestServer(t)
+
+	endpoint := baseURL + "api/v1/build"
+	rsp, err := http.Get(endpoint)
+	assert.NoError(t, err)
+	defer rsp.Body.Close()
+	assert.Equal(t, rsp.StatusCode, 405)
+	assert.Equal(t, loggerHook.LastEntry().Message, "handlerBuild called on /api/v1/build")
+}
+
+func writeToTar(atar *tar.Writer, name, content string) error {
+	hdr := &tar.Header{
+		Name: name,
+		Mode: 0644,
+		Size: int64(len(content)),
+	}
+	if err := atar.WriteHeader(hdr); err != nil {
+		return err
+	}
+	_, err := atar.Write([]byte(content))
+	return err
+}
+
+func TestBuildChecksContentType(t *testing.T) {
+	baseURL, _, _ := runTestServer(t)
+
+	endpoint := baseURL + "api/v1/build"
+	rsp, err := http.Post(endpoint, "random/encoding", nil)
+	assert.NoError(t, err)
+	defer rsp.Body.Close()
+	assert.Equal(t, rsp.StatusCode, http.StatusUnsupportedMediaType)
+	body, err := io.ReadAll(rsp.Body)
+	assert.NoError(t, err)
+	assert.Equal(t, string(body), "Content-Type must be [application/x-tar], got random/encoding\n")
+}
+
+func makeTestPost(t *testing.T, controlJSON, manifestJSON string) *bytes.Buffer {
+	buf := bytes.NewBuffer(nil)
+	archive := tar.NewWriter(buf)
+	err := writeToTar(archive, "control.json", controlJSON)
+	assert.NoError(t, err)
+	err = writeToTar(archive, "manifest.json", manifestJSON)
+	assert.NoError(t, err)
+	// for now we assume we get validated data, for files we could
+	// trivially validate on the fly but for containers that is
+	// harder
+	for _, dir := range []string{"store/", "store/sources", "store/sources/org.osbuild.files"} {
+		err = archive.WriteHeader(&tar.Header{
+			Name:     dir,
+			Mode:     0755,
+			Typeflag: tar.TypeDir,
+		})
+		assert.NoError(t, err)
+	}
+	err = writeToTar(archive, "store/sources/org.osbuild.files/sha256:ff800c5263b915d8a0776be5620575df2d478332ad35e8dd18def6a8c720f9c7", "random-data")
+	assert.NoError(t, err)
+	err = writeToTar(archive, "store/sources/org.osbuild.files/sha256:aabbcc5263b915d8a0776be5620575df2d478332ad35e8dd18def6a8c720f9c7", "other-data")
+	assert.NoError(t, err)
+	return buf
+}
+
+func TestBuildIntegration(t *testing.T) {
+	baseURL, baseBuildDir, _ := runTestServer(t)
+	endpoint := baseURL + "api/v1/build"
+
+	// osbuild is called with --export tree and then the manifest.json
+	restore := main.MockOsbuildBinary(t, fmt.Sprintf(`#!/bin/sh -e
+# echo our inputs for the test to validate
+echo fake-osbuild "$1" "$2" "$3" "$4" "$5" "$6" "$7"
+echo ---
+cat "$8"
+
+test "$MY" = "env"
+
+# simulate output
+mkdir -p %[1]s/build/output/image
+echo "fake-build-result" > %[1]s/build/output/image/disk.img
+`, baseBuildDir))
+	defer restore()
+
+	buf := makeTestPost(t, `{"exports": ["tree"], "environments": ["MY=env"]}`, `{"fake": "manifest"}`)
+	rsp, err := http.Post(endpoint, "application/x-tar", buf)
+	assert.NoError(t, err)
+	defer func() { _, _ = io.ReadAll(rsp.Body) }()
+	defer rsp.Body.Close()
+
+	assert.Equal(t, rsp.StatusCode, http.StatusCreated)
+	reader := bufio.NewReader(rsp.Body)
+
+	// check that we get the output of osbuild streamed to us
+	expectedContent := fmt.Sprintf(`fake-osbuild --export tree --output-dir %[1]s/build/output --store %[1]s/build/store --json
+---
+{"fake": "manifest"}`, baseBuildDir)
+	content, err := io.ReadAll(reader)
+	assert.NoError(t, err)
+	assert.Equal(t, string(content), expectedContent)
+	// check log too
+	logFileContent, err := os.ReadFile(filepath.Join(baseBuildDir, "build/build.log"))
+	assert.NoError(t, err)
+	assert.Equal(t, expectedContent, string(logFileContent))
+	// check that the "store" dir got created
+	stat, err := os.Stat(filepath.Join(baseBuildDir, "build/store"))
+	assert.NoError(t, err)
+	assert.True(t, stat.IsDir())
+
+	// now get the result
+	endpoint = baseURL + "api/v1/result/image/disk.img"
+	rsp, err = http.Get(endpoint)
+	assert.NoError(t, err)
+	defer rsp.Body.Close()
+	assert.Equal(t, http.StatusOK, rsp.StatusCode)
+	body, err := io.ReadAll(rsp.Body)
+	assert.NoError(t, err)
+	assert.Equal(t, "fake-build-result\n", string(body))
+
+	// check that the output tarball has the disk in it
+	endpoint = baseURL + "api/v1/result/output.tar"
+	rsp, err = http.Get(endpoint)
+	assert.NoError(t, err)
+	defer rsp.Body.Close()
+	assert.Equal(t, http.StatusOK, rsp.StatusCode)
+	body, err = io.ReadAll(rsp.Body)
+	assert.NoError(t, err)
+	tarPath := filepath.Join(baseBuildDir, "output.tar")
+	assert.NoError(t, os.WriteFile(tarPath, body, 0644))
+	cmd := exec.Command("tar", "-tf", tarPath)
+	out, err := cmd.Output()
+	assert.NoError(t, err)
+	assert.Equal(t, "output/\noutput/image/\noutput/image/disk.img\n", string(out))
+}
+
+func TestBuildErrorsForMultipleBuilds(t *testing.T) {
+	baseURL, buildDir, loggerHook := runTestServer(t)
+	endpoint := baseURL + "api/v1/build"
+
+	restore := main.MockOsbuildBinary(t, fmt.Sprintf(`#!/bin/sh
+
+mkdir -p %[1]s/build/output/image
+echo "fake-build-result" > %[1]s/build/output/image/disk.img
+`, buildDir))
+	defer restore()
+
+	buf := makeTestPost(t, `{"exports": ["tree"]}`, `{"fake": "manifest"}`)
+	rsp, err := http.Post(endpoint, "application/x-tar", buf)
+	assert.NoError(t, err)
+	assert.Equal(t, rsp.StatusCode, http.StatusCreated)
+	defer func() { _, _ = io.ReadAll(rsp.Body) }()
+	defer rsp.Body.Close()
+
+	buf = makeTestPost(t, `{"exports": ["tree"]}`, `{"fake": "manifest"}`)
+	rsp, err = http.Post(endpoint, "application/x-tar", buf)
+	assert.NoError(t, err)
+	defer rsp.Body.Close()
+	assert.Equal(t, rsp.StatusCode, http.StatusConflict)
+	assert.Equal(t, loggerHook.LastEntry().Message, main.ErrAlreadyBuilding.Error())
+}
+
+func TestHandleIncludedSourcesUnclean(t *testing.T) {
+	tmpdir := t.TempDir()
+
+	buf := bytes.NewBuffer(nil)
+	atar := tar.NewWriter(buf)
+	err := writeToTar(atar, "store/../../etc/passwd", "some-content")
+	assert.NoError(t, err)
+
+	err = main.HandleIncludedSources(tar.NewReader(buf), tmpdir)
+	assert.EqualError(t, err, "name not clean: ../etc/passwd != store/../../etc/passwd")
+}
+
+func TestHandleIncludedSourcesNotFromStore(t *testing.T) {
+	tmpdir := t.TempDir()
+
+	buf := bytes.NewBuffer(nil)
+	atar := tar.NewWriter(buf)
+	err := writeToTar(atar, "not-store", "some-content")
+	assert.NoError(t, err)
+
+	err = main.HandleIncludedSources(tar.NewReader(buf), tmpdir)
+	assert.EqualError(t, err, "expected store/ prefix, got not-store")
+}
+
+func TestHandleIncludedSourcesBadTypes(t *testing.T) {
+	tmpdir := t.TempDir()
+
+	for _, badType := range []byte{tar.TypeLink, tar.TypeSymlink, tar.TypeChar, tar.TypeBlock, tar.TypeFifo} {
+		buf := bytes.NewBuffer(nil)
+		atar := tar.NewWriter(buf)
+		err := atar.WriteHeader(&tar.Header{
+			Name:     "store/bad-type",
+			Typeflag: badType,
+		})
+		assert.NoError(t, err)
+
+		err = main.HandleIncludedSources(tar.NewReader(buf), tmpdir)
+		assert.EqualError(t, err, fmt.Sprintf("unsupported tar type %v", badType))
+	}
+}
+
+func TestBuildIntegrationOsbuildError(t *testing.T) {
+	baseURL, _, _ := runTestServer(t)
+	endpoint := baseURL + "api/v1/build"
+
+	// osbuild is called with --export tree and then the manifest.json
+	restore := main.MockOsbuildBinary(t, `#!/bin/sh -e
+# simulate failure
+echo "err on stdout"
+>&2 echo "err on stderr"
+exit 23
+`)
+	defer restore()
+
+	buf := makeTestPost(t, `{"exports": ["tree"], "environments": ["MY=env"]}`, `{"fake": "manifest"}`)
+	rsp, err := http.Post(endpoint, "application/x-tar", buf)
+	assert.NoError(t, err)
+	defer func() { _, _ = io.ReadAll(rsp.Body) }()
+	defer rsp.Body.Close()
+
+	assert.Equal(t, rsp.StatusCode, http.StatusCreated)
+	reader := bufio.NewReader(rsp.Body)
+	content, err := io.ReadAll(reader)
+	assert.NoError(t, err)
+	expectedContent := `err on stdout
+err on stderr
+cannot run osbuild: exit status 23`
+	assert.Equal(t, expectedContent, string(content))
+
+	// check that the result is an error and we get the log
+	endpoint = baseURL + "api/v1/result/image/disk.img"
+	rsp, err = http.Get(endpoint)
+	assert.NoError(t, err)
+	defer rsp.Body.Close()
+	assert.Equal(t, http.StatusBadRequest, rsp.StatusCode)
+	reader = bufio.NewReader(rsp.Body)
+	content, err = io.ReadAll(reader)
+	assert.NoError(t, err)
+	assert.Equal(t, "build failed\n"+expectedContent, string(content))
+}
+
+func TestBuildStreamsOutput(t *testing.T) {
+	baseURL, baseBuildDir, _ := runTestServer(t)
+	endpoint := baseURL + "api/v1/build"
+
+	restore := main.MockOsbuildBinary(t, fmt.Sprintf(`#!/bin/sh -e
+for i in $(seq 3); do
+   # generate the exact timestamp of the output line
+   echo "line-$i: $(date  +'%%s.%%N')"
+   sleep 0.2
+done
+
+# simulate output
+mkdir -p %[1]s/build/output/image
+echo "fake-build-result" > %[1]s/build/output/image/disk.img
+`, baseBuildDir))
+	defer restore()
+
+	buf := makeTestPost(t, `{"exports": ["tree"], "environments": ["MY=env"]}`, `{"fake": "manifest"}`)
+	rsp, err := http.Post(endpoint, "application/x-tar", buf)
+	assert.NoError(t, err)
+	defer func() { _, _ = io.ReadAll(rsp.Body) }()
+	defer rsp.Body.Close()
+
+	assert.Equal(t, rsp.StatusCode, http.StatusCreated)
+	reader := bufio.NewReader(rsp.Body)
+
+	var lineno, seconds, nano int64
+	for i := 1; i <= 3; i++ {
+		line, err := reader.ReadString('\n')
+		assert.NoError(t, err)
+		// the out contains when it was generated
+		_, err = fmt.Sscanf(line, "line-%d: %d.%d\n", &lineno, &seconds, &nano)
+		assert.NoError(t, err)
+		timeSinceOutput := time.Since(time.Unix(seconds, nano))
+		// we expect lines to appear right away, for really slow VMs
+		// we give a grace time of 200ms (which should be plenty and
+		// is also a bit arbitrary)
+		assert.True(t, timeSinceOutput < 200*time.Millisecond, fmt.Sprintf("output did not arrive in the expected time interval, delay: %v", timeSinceOutput))
+	}
+}
+
+func TestBuildErrorHandlingTar(t *testing.T) {
+	restore := main.MockOsbuildBinary(t, `#!/bin/sh
+
+# not creating an output dir, this will lead to errors from the "tar"
+# step
+`)
+	defer restore()
+
+	baseURL, _, loggerHook := runTestServer(t)
+	endpoint := baseURL + "api/v1/build"
+
+	buf := makeTestPost(t, `{"exports": ["tree"]}`, `{"fake": "manifest"}`)
+	rsp, err := http.Post(endpoint, "application/x-tar", buf)
+	assert.NoError(t, err)
+	defer rsp.Body.Close()
+	assert.Equal(t, rsp.StatusCode, http.StatusCreated)
+
+	body, err := io.ReadAll(rsp.Body)
+	assert.NoError(t, err)
+	assert.Contains(t, string(body), "cannot tar output directory:")
+	assert.Contains(t, loggerHook.LastEntry().Message, "cannot tar output directory:")
+}
diff --git a/cmd/osbuild-worker-executor/handler_result_test.go b/cmd/osbuild-worker-executor/handler_result_test.go
new file mode 100644
index 000000000..ccc75a893
--- /dev/null
+++ b/cmd/osbuild-worker-executor/handler_result_test.go
@@ -0,0 +1,62 @@
+package main_test
+
+import (
+	"io"
+	"net/http"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestResultTooEarly(t *testing.T) {
+	baseURL, _, _ := runTestServer(t)
+	endpoint := baseURL + "api/v1/result"
+
+	rsp, err := http.Get(endpoint)
+	assert.NoError(t, err)
+	assert.Equal(t, rsp.StatusCode, http.StatusTooEarly)
+}
+
+func TestResultBad(t *testing.T) {
+	baseURL, buildBaseDir, _ := runTestServer(t)
+	endpoint := baseURL + "api/v1/result/disk.img"
+
+	// simulate build failure
+	// todo: make a nice helper method
+	err := os.MkdirAll(filepath.Join(buildBaseDir, "build"), 0755)
+	assert.NoError(t, err)
+	err = os.WriteFile(filepath.Join(buildBaseDir, "result.bad"), nil, 0644)
+	assert.NoError(t, err)
+	err = os.WriteFile(filepath.Join(buildBaseDir, "build/build.log"), []byte("failure log"), 0644)
+	assert.NoError(t, err)
+
+	rsp, err := http.Get(endpoint)
+	assert.NoError(t, err)
+	assert.Equal(t, http.StatusBadRequest, rsp.StatusCode)
+	body, err := io.ReadAll(rsp.Body)
+	assert.NoError(t, err)
+	assert.Equal(t, "build failed\nfailure log", string(body))
+}
+
+func TestResultGood(t *testing.T) {
+	baseURL, buildBaseDir, _ := runTestServer(t)
+	endpoint := baseURL + "api/v1/result/disk.img"
+
+	// simulate build failure
+	// todo: make a nice helper method
+	err := os.MkdirAll(filepath.Join(buildBaseDir, "build/output"), 0755)
+	assert.NoError(t, err)
+	err = os.WriteFile(filepath.Join(buildBaseDir, "result.good"), nil, 0644)
+	assert.NoError(t, err)
+	err = os.WriteFile(filepath.Join(buildBaseDir, "build/output/disk.img"), []byte("fake-build-result"), 0644)
+	assert.NoError(t, err)
+
+	rsp, err := http.Get(endpoint)
+	assert.NoError(t, err)
+	assert.Equal(t, http.StatusOK, rsp.StatusCode)
+	body, err := io.ReadAll(rsp.Body)
+	assert.NoError(t, err)
+	assert.Equal(t, "fake-build-result", string(body))
+}
diff --git a/cmd/osbuild-worker-executor/handler_root_test.go b/cmd/osbuild-worker-executor/handler_root_test.go
new file mode 100644
index 000000000..da6200238
--- /dev/null
+++ b/cmd/osbuild-worker-executor/handler_root_test.go
@@ -0,0 +1,18 @@
+package main_test
+
+import (
+	"net/http"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestTrivialRootEndpoint(t *testing.T) {
+	baseURL, _, loggerHook := runTestServer(t)
+
+	endpoint := baseURL
+	resp, err := http.Get(endpoint)
+	assert.NoError(t, err)
+	assert.Equal(t, resp.StatusCode, 200)
+	assert.Equal(t, loggerHook.LastEntry().Message, "/ handler called")
+}
diff --git a/cmd/osbuild-worker-executor/main_test.go b/cmd/osbuild-worker-executor/main_test.go
new file mode 100644
index 000000000..50ff90446
--- /dev/null
+++ b/cmd/osbuild-worker-executor/main_test.go
@@ -0,0 +1,76 @@
+package main_test
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"os"
+	"testing"
+	"time"
+
+	logrusTest "github.com/sirupsen/logrus/hooks/test"
+	"github.com/stretchr/testify/assert"
+
+	main "github.com/osbuild/osbuild-composer/cmd/osbuild-worker-executor"
+)
+
+const defaultTimeout = 5 * time.Second
+
+func waitReady(ctx context.Context, timeout time.Duration, endpoint string) error {
+	client := http.Client{}
+	startTime := time.Now()
+	for {
+		req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+		if err != nil {
+			return fmt.Errorf("failed to create request: %w", err)
+		}
+
+		resp, err := client.Do(req)
+		if err != nil {
+			continue
+		}
+		if resp.StatusCode == http.StatusOK {
+			resp.Body.Close()
+			return nil
+		}
+		resp.Body.Close()
+
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		default:
+			if time.Since(startTime) >= timeout {
+				return fmt.Errorf("timeout reached while waiting for endpoint")
+			}
+			// wait a little while between checks
+			time.Sleep(250 * time.Millisecond)
+		}
+	}
+}
+
+func runTestServer(t *testing.T) (baseURL, buildBaseDir string, loggerHook *logrusTest.Hook) {
+	host := "localhost"
+	port := "18002"
+	buildBaseDir = t.TempDir()
+	baseURL = fmt.Sprintf("http://%s:%s/", host, port)
+
+	ctx, cancel := context.WithCancel(context.Background())
+	t.Cleanup(cancel)
+
+	loggerHook, restore := main.MockLogger()
+	defer restore()
+
+	args := []string{
+		"-host", host,
+		"-port", port,
+		"-build-path", buildBaseDir,
+	}
+	go func() {
+		_ = main.Run(ctx, args, os.Getenv)
+	}()
+
+	err := waitReady(ctx, defaultTimeout, baseURL)
+	assert.NoError(t, err)
+
+	return baseURL, buildBaseDir, loggerHook
+}
diff --git a/vendor/github.com/sirupsen/logrus/hooks/test/test.go b/vendor/github.com/sirupsen/logrus/hooks/test/test.go
new file mode 100644
index 000000000..046f0bf6c
--- /dev/null
+++ b/vendor/github.com/sirupsen/logrus/hooks/test/test.go
@@ -0,0 +1,91 @@
+// The Test package is used for testing logrus.
+// It provides a simple hooks which register logged messages.
+package test
+
+import (
+	"io/ioutil"
+	"sync"
+
+	"github.com/sirupsen/logrus"
+)
+
+// Hook is a hook designed for dealing with logs in test scenarios.
+type Hook struct {
+	// Entries is an array of all entries that have been received by this hook.
+	// For safe access, use the AllEntries() method, rather than reading this
+	// value directly.
+	Entries []logrus.Entry
+	mu      sync.RWMutex
+}
+
+// NewGlobal installs a test hook for the global logger.
+func NewGlobal() *Hook {
+
+	hook := new(Hook)
+	logrus.AddHook(hook)
+
+	return hook
+
+}
+
+// NewLocal installs a test hook for a given local logger.
+func NewLocal(logger *logrus.Logger) *Hook {
+
+	hook := new(Hook)
+	logger.AddHook(hook)
+
+	return hook
+
+}
+
+// NewNullLogger creates a discarding logger and installs the test hook.
+func NewNullLogger() (*logrus.Logger, *Hook) {
+
+	logger := logrus.New()
+	logger.Out = ioutil.Discard
+
+	return logger, NewLocal(logger)
+
+}
+
+func (t *Hook) Fire(e *logrus.Entry) error {
+	t.mu.Lock()
+	defer t.mu.Unlock()
+	t.Entries = append(t.Entries, *e)
+	return nil
+}
+
+func (t *Hook) Levels() []logrus.Level {
+	return logrus.AllLevels
+}
+
+// LastEntry returns the last entry that was logged or nil.
+func (t *Hook) LastEntry() *logrus.Entry {
+	t.mu.RLock()
+	defer t.mu.RUnlock()
+	i := len(t.Entries) - 1
+	if i < 0 {
+		return nil
+	}
+	return &t.Entries[i]
+}
+
+// AllEntries returns all entries that were logged.
+func (t *Hook) AllEntries() []*logrus.Entry {
+	t.mu.RLock()
+	defer t.mu.RUnlock()
+	// Make a copy so the returned value won't race with future log requests
+	entries := make([]*logrus.Entry, len(t.Entries))
+	for i := 0; i < len(t.Entries); i++ {
+		// Make a copy, for safety
+		entries[i] = &t.Entries[i]
+	}
+	return entries
+}
+
+// Reset removes all Entries from this test hook.
+func (t *Hook) Reset() {
+	t.mu.Lock()
+	defer t.mu.Unlock()
+	t.Entries = make([]logrus.Entry, 0)
+}