Do not use the global logger but pass instead the locally created logger. This means the test output is silent again. Sadly using the global logger is difficult because it is a global resource so replacing it in tests means all tests (that are potentially run in parallel) will write to it which makes testing specific log output hard.
299 lines
8.1 KiB
Go
299 lines
8.1 KiB
Go
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(logger *logrus.Logger, buildDir string, control *controlJSON, output io.Writer) (string, error) {
|
|
flusher, ok := output.(http.Flusher)
|
|
if !ok {
|
|
return "", fmt.Errorf("cannot stream the output")
|
|
}
|
|
// stream output over http
|
|
wf := writeFlusher{w: output, flusher: flusher}
|
|
// note that the "filepath.Clean()" is here for gosec:G304
|
|
logfPath := filepath.Clean(filepath.Join(buildDir, "build.log"))
|
|
// and also write to our internal log
|
|
logf, err := os.Create(logfPath)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot create log file: %v", err)
|
|
}
|
|
defer logf.Close()
|
|
|
|
// use multi writer to get same output for stream and log
|
|
mw := io.MultiWriter(&wf, logf)
|
|
outputDir := filepath.Join(buildDir, "output")
|
|
storeDir := filepath.Join(buildDir, "store")
|
|
cmd := exec.Command(osbuildBinary)
|
|
cmd.Stdout = mw
|
|
cmd.Stderr = mw
|
|
for _, exp := range control.Exports {
|
|
cmd.Args = append(cmd.Args, []string{"--export", exp}...)
|
|
}
|
|
cmd.Env = append(cmd.Env, control.Environments...)
|
|
cmd.Args = append(cmd.Args, []string{"--output-dir", outputDir}...)
|
|
cmd.Args = append(cmd.Args, []string{"--store", storeDir}...)
|
|
cmd.Args = append(cmd.Args, "--json")
|
|
cmd.Args = append(cmd.Args, filepath.Join(buildDir, "manifest.json"))
|
|
if err := cmd.Start(); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if err := cmd.Wait(); err != nil {
|
|
// we cannot use "http.Error()" here because the http
|
|
// header was already set to "201" when we started streaming
|
|
_, _ = mw.Write([]byte(fmt.Sprintf("cannot run osbuild: %v", err)))
|
|
return "", err
|
|
}
|
|
|
|
// #nosec G204
|
|
cmd = exec.Command(
|
|
"tar",
|
|
"-Scf",
|
|
filepath.Join(outputDir, "output.tar"),
|
|
"output",
|
|
)
|
|
cmd.Dir = buildDir
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
err = fmt.Errorf("cannot tar output directory: %w, output:\n%s", err, out)
|
|
logger.Errorf(err.Error())
|
|
_, _ = mw.Write([]byte(err.Error()))
|
|
return "", err
|
|
}
|
|
logger.Infof("tar output:\n%s", out)
|
|
return outputDir, nil
|
|
}
|
|
|
|
type controlJSON struct {
|
|
Environments []string `json:"environments"`
|
|
Exports []string `json:"exports"`
|
|
}
|
|
|
|
func mustRead(atar *tar.Reader, name string) error {
|
|
hdr, err := atar.Next()
|
|
if err != nil {
|
|
return fmt.Errorf("cannot read tar %v: %v", name, err)
|
|
}
|
|
if hdr.Name != name {
|
|
return fmt.Errorf("expected tar %v, got %v", name, hdr.Name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func handleControlJSON(atar *tar.Reader) (*controlJSON, error) {
|
|
if err := mustRead(atar, "control.json"); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var control controlJSON
|
|
if err := json.NewDecoder(atar).Decode(&control); err != nil {
|
|
return nil, err
|
|
}
|
|
return &control, nil
|
|
}
|
|
|
|
func createBuildDir(config *Config) (string, error) {
|
|
buildDirBase := config.BuildDirBase
|
|
|
|
// we could create a per-build dir here but the goal is to
|
|
// only have a single build only so we don't bother
|
|
if err := os.MkdirAll(buildDirBase, 0700); err != nil {
|
|
return "", fmt.Errorf("cannot create build base dir: %v", err)
|
|
}
|
|
|
|
// ensure there is only a single build
|
|
buildDir := filepath.Join(buildDirBase, "build")
|
|
if err := os.Mkdir(buildDir, 0700); err != nil {
|
|
if os.IsExist(err) {
|
|
return "", ErrAlreadyBuilding
|
|
}
|
|
return "", err
|
|
}
|
|
|
|
return buildDir, nil
|
|
}
|
|
|
|
func handleManifestJSON(atar *tar.Reader, buildDir string) error {
|
|
if err := mustRead(atar, "manifest.json"); err != nil {
|
|
return err
|
|
}
|
|
manifestJSONPath := filepath.Join(buildDir, "manifest.json")
|
|
|
|
// note that the filepath.Clean() is here just to appease gosec:G304
|
|
f, err := os.Create(filepath.Clean(manifestJSONPath))
|
|
if err != nil {
|
|
return fmt.Errorf("cannot create manifest.json: %v", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
if _, err := io.Copy(f, atar); err != nil {
|
|
return fmt.Errorf("cannot read body: %v", err)
|
|
}
|
|
|
|
if err := f.Close(); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func handleIncludedSources(atar *tar.Reader, buildDir string) error {
|
|
for {
|
|
hdr, err := atar.Next()
|
|
if err == io.EOF {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("cannot read from tar %v", err)
|
|
}
|
|
|
|
// ensure we only allow "store/" things
|
|
name := hdr.Name
|
|
if filepath.Clean(name) != strings.TrimSuffix(name, "/") {
|
|
return fmt.Errorf("name not clean: %v != %v", filepath.Clean(name), name)
|
|
}
|
|
if !strings.HasPrefix(name, "store/") {
|
|
return fmt.Errorf("expected store/ prefix, got %v", hdr.Name)
|
|
}
|
|
// note that the extra filepath.Clean() is just there to
|
|
// appease gosec G305
|
|
target := filepath.Join(buildDir, filepath.Clean(name))
|
|
|
|
// this assume "well" behaving tars, i.e. all dirs that lead
|
|
// up to the tar are included etc
|
|
mode := os.FileMode(hdr.Mode)
|
|
switch hdr.Typeflag {
|
|
case tar.TypeDir:
|
|
if err := os.Mkdir(target, mode); err != nil {
|
|
return fmt.Errorf("unpack: %w", err)
|
|
}
|
|
case tar.TypeReg:
|
|
// Note that the G304 here is silly, target is carefully
|
|
// checked (and is no error in the flow above). Mode is
|
|
// not relevant for an information leak. But it seems
|
|
// there is no way to get a debug trace from gosec what
|
|
// exactly is wrong.
|
|
// #nosec G304
|
|
f, err := os.OpenFile(target, os.O_RDWR|os.O_CREATE, mode)
|
|
if err != nil {
|
|
return fmt.Errorf("unpack: %w", err)
|
|
}
|
|
defer f.Close()
|
|
if _, err := io.Copy(f, atar); err != nil {
|
|
return fmt.Errorf("unpack: %w", err)
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
return fmt.Errorf("unpack: %w", err)
|
|
}
|
|
default:
|
|
return fmt.Errorf("unsupported tar type %v", hdr.Typeflag)
|
|
}
|
|
if err := os.Chtimes(target, hdr.AccessTime, hdr.ModTime); err != nil {
|
|
return fmt.Errorf("unpack: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// test for real via:
|
|
// curl -o - --data-binary "@./test.tar" -H "Content-Type: application/x-tar" -X POST http://localhost:8001/api/v1/build
|
|
func handleBuild(logger *logrus.Logger, config *Config) http.Handler {
|
|
return http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
logger.Debugf("handlerBuild called on %s", r.URL.Path)
|
|
defer r.Body.Close()
|
|
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "build endpoint only supports POST", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
contentType := r.Header.Get("Content-Type")
|
|
if !slices.Contains(supportedBuildContentTypes, contentType) {
|
|
http.Error(w, fmt.Sprintf("Content-Type must be %v, got %v", supportedBuildContentTypes, contentType), http.StatusUnsupportedMediaType)
|
|
return
|
|
}
|
|
|
|
// control.json passes the build parameters
|
|
atar := tar.NewReader(r.Body)
|
|
control, err := handleControlJSON(atar)
|
|
if err != nil {
|
|
logger.Error(err)
|
|
http.Error(w, "cannot decode control.json", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
buildDir, err := createBuildDir(config)
|
|
if err != nil {
|
|
logger.Error(err)
|
|
if err == ErrAlreadyBuilding {
|
|
http.Error(w, "build already started", http.StatusConflict)
|
|
} else {
|
|
http.Error(w, "create build dir", http.StatusBadRequest)
|
|
}
|
|
return
|
|
}
|
|
|
|
// manifest.json is the osbuild input
|
|
if err := handleManifestJSON(atar, buildDir); err != nil {
|
|
logger.Error(err)
|
|
http.Error(w, "manifest.json", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// extract ".osbuild/sources" here too from the tar
|
|
if err := handleIncludedSources(atar, buildDir); err != nil {
|
|
logger.Error(err)
|
|
http.Error(w, "included sources/", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusCreated)
|
|
|
|
// run osbuild and stream the output to the client
|
|
buildResult := newBuildResult(config)
|
|
_, err = runOsbuild(logger, 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
|
|
}
|
|
},
|
|
)
|
|
}
|