diff --git a/cmd/osbuild-worker-executor/build_result.go b/cmd/osbuild-worker-executor/build_result.go new file mode 100644 index 000000000..8d3d73451 --- /dev/null +++ b/cmd/osbuild-worker-executor/build_result.go @@ -0,0 +1,38 @@ +package main + +import ( + "io/ioutil" + "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 ioutil.WriteFile(br.resultGood, nil, 0600) + } else { + return ioutil.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..8bdaba110 --- /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("oaas", 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/oaas", "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/export_test.go b/cmd/osbuild-worker-executor/export_test.go new file mode 100644 index 000000000..bdf255bb6 --- /dev/null +++ b/cmd/osbuild-worker-executor/export_test.go @@ -0,0 +1,45 @@ +package main + +import ( + "io/ioutil" + "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") + if err := ioutil.WriteFile(osbuildBinary, []byte(new), 0755); err != nil { + t.Fatal(err) + } + + return func() { + osbuildBinary = saved + } +} diff --git a/cmd/osbuild-worker-executor/handler_build.go b/cmd/osbuild-worker-executor/handler_build.go new file mode 100644 index 000000000..6c62910d2 --- /dev/null +++ b/cmd/osbuild-worker-executor/handler_build.go @@ -0,0 +1,286 @@ +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} + // and also write to our internal log + logf, err := os.Create(filepath.Join(buildDir, "build.log")) + 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 + } + + 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") + + f, err := os.Create(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 + if filepath.Clean(hdr.Name) != strings.TrimSuffix(hdr.Name, "/") { + return fmt.Errorf("name not clean: %v != %v", filepath.Clean(hdr.Name), hdr.Name) + } + if !strings.HasPrefix(hdr.Name, "store/") { + return fmt.Errorf("expected store/ prefix, got %v", hdr.Name) + } + + // this assume "well" behaving tars, i.e. all dirs that lead + // up to the tar are included etc + target := filepath.Join(buildDir, hdr.Name) + 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: + 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_build_test.go b/cmd/osbuild-worker-executor/handler_build_test.go new file mode 100644 index 000000000..be69f59ad --- /dev/null +++ b/cmd/osbuild-worker-executor/handler_build_test.go @@ -0,0 +1,321 @@ +package main_test + +import ( + "archive/tar" + "bufio" + "bytes" + "fmt" + "io/ioutil" + "net/http" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + main "github.com/osbuild/osbuild-composer/cmd/oaas" +) + +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 := ioutil.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 ioutil.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 := ioutil.ReadAll(reader) + assert.NoError(t, err) + assert.Equal(t, string(content), expectedContent) + // check log too + logFileContent, err := ioutil.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 := ioutil.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 = ioutil.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 ioutil.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 ioutil.ReadAll(rsp.Body) + defer rsp.Body.Close() + + assert.Equal(t, rsp.StatusCode, http.StatusCreated) + reader := bufio.NewReader(rsp.Body) + content, err := ioutil.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 = ioutil.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 ioutil.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.Now().Sub(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 := ioutil.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.go b/cmd/osbuild-worker-executor/handler_result.go new file mode 100644 index 000000000..40769fee1 --- /dev/null +++ b/cmd/osbuild-worker-executor/handler_result.go @@ -0,0 +1,43 @@ +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() + io.Copy(w, f) + 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_result_test.go b/cmd/osbuild-worker-executor/handler_result_test.go new file mode 100644 index 000000000..82f757edd --- /dev/null +++ b/cmd/osbuild-worker-executor/handler_result_test.go @@ -0,0 +1,62 @@ +package main_test + +import ( + "io/ioutil" + "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 = ioutil.WriteFile(filepath.Join(buildBaseDir, "result.bad"), nil, 0644) + assert.NoError(t, err) + err = ioutil.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 := ioutil.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 = ioutil.WriteFile(filepath.Join(buildBaseDir, "result.good"), nil, 0644) + assert.NoError(t, err) + err = ioutil.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 := ioutil.ReadAll(rsp.Body) + assert.NoError(t, err) + assert.Equal(t, "fake-build-result", string(body)) +} 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/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.go b/cmd/osbuild-worker-executor/main.go new file mode 100644 index 000000000..669449aa3 --- /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, + } + 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 := context.Background() + shutdownCtx, cancel := context.WithTimeout(ctx, 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/main_test.go b/cmd/osbuild-worker-executor/main_test.go new file mode 100644 index 000000000..76d7721ad --- /dev/null +++ b/cmd/osbuild-worker-executor/main_test.go @@ -0,0 +1,74 @@ +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/oaas" +) + +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 main.Run(ctx, args, os.Getenv) + + err := waitReady(ctx, defaultTimeout, baseURL) + assert.NoError(t, err) + + return baseURL, buildBaseDir, loggerHook +} 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)) +}