cmd/osbuild-worker-executor: import verbatim from mvo5/oaas

This commit imports the repository https://github.com/mvo5/oaas
without keeping the history. The history is largely unimportant
and can be looked up in https://github.com/mvo5/oaas/commits/main
if needed.
This commit is contained in:
Michael Vogt 2024-06-05 12:28:35 +02:00
parent fa416e4545
commit 372d9f07dd
12 changed files with 1024 additions and 0 deletions

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}
}

View file

@ -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
}
},
)
}

View file

@ -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:")
}

View file

@ -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)
},
)
}

View file

@ -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))
}

View file

@ -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")
},
)
}

View file

@ -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")
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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))
}