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:
parent
fa416e4545
commit
372d9f07dd
12 changed files with 1024 additions and 0 deletions
38
cmd/osbuild-worker-executor/build_result.go
Normal file
38
cmd/osbuild-worker-executor/build_result.go
Normal 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
|
||||
}
|
||||
25
cmd/osbuild-worker-executor/config.go
Normal file
25
cmd/osbuild-worker-executor/config.go
Normal 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
|
||||
}
|
||||
45
cmd/osbuild-worker-executor/export_test.go
Normal file
45
cmd/osbuild-worker-executor/export_test.go
Normal 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
|
||||
}
|
||||
}
|
||||
286
cmd/osbuild-worker-executor/handler_build.go
Normal file
286
cmd/osbuild-worker-executor/handler_build.go
Normal 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
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
321
cmd/osbuild-worker-executor/handler_build_test.go
Normal file
321
cmd/osbuild-worker-executor/handler_build_test.go
Normal 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:")
|
||||
}
|
||||
43
cmd/osbuild-worker-executor/handler_result.go
Normal file
43
cmd/osbuild-worker-executor/handler_result.go
Normal 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
62
cmd/osbuild-worker-executor/handler_result_test.go
Normal file
62
cmd/osbuild-worker-executor/handler_result_test.go
Normal 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))
|
||||
}
|
||||
16
cmd/osbuild-worker-executor/handler_root.go
Normal file
16
cmd/osbuild-worker-executor/handler_root.go
Normal 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")
|
||||
},
|
||||
)
|
||||
}
|
||||
18
cmd/osbuild-worker-executor/handler_root_test.go
Normal file
18
cmd/osbuild-worker-executor/handler_root_test.go
Normal 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")
|
||||
}
|
||||
83
cmd/osbuild-worker-executor/main.go
Normal file
83
cmd/osbuild-worker-executor/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
74
cmd/osbuild-worker-executor/main_test.go
Normal file
74
cmd/osbuild-worker-executor/main_test.go
Normal 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
|
||||
}
|
||||
13
cmd/osbuild-worker-executor/routes.go
Normal file
13
cmd/osbuild-worker-executor/routes.go
Normal 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))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue