Skip to content

Commit 40f31c8

Browse files
committed
gosbuild/osbuild-exec: add support for buildlog
This commit adds a new `BuildLog` option to the `OSBuildOptions` that can be used to generate a streamed buildlog (e.g. to a file or a websocket). This supports the `--with-buildlog` option in image-builder-cli. With the buildlog option set, stderr is forwarded to stdout to make sure statements appear in order. If `BuildLogMu` is set, this mutex will be used for the synced writer which writes to both stdout and the buildlog. Callers can use the same mutex to write statements to the same buildlog. The errorWriter option got removed, and callers can just use `Stdout` and `Stderr` to set the stdout and stderr of the command.
1 parent f84927b commit 40f31c8

File tree

5 files changed

+137
-13
lines changed

5 files changed

+137
-13
lines changed

cmd/build/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ func run() error {
136136
fmt.Printf("Building manifest: %s\n", manifestPath)
137137

138138
jobOutput := filepath.Join(outputDir, buildName)
139-
_, err = osbuild.RunOSBuild(mf, nil, &osbuild.OSBuildOptions{
139+
_, err = osbuild.RunOSBuild(mf, &osbuild.OSBuildOptions{
140140
StoreDir: osbuildStore,
141141
OutputDir: jobOutput,
142142
Exports: imgType.Exports(),

cmd/osbuild-playground/playground.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ func RunPlayground(img image.ImageKind, d distro.Distro, arch distro.Arch, repos
5757

5858
store := path.Join(state_dir, "osbuild-store")
5959

60-
_, err = osbuild.RunOSBuild(bytes, nil, &osbuild.OSBuildOptions{
60+
_, err = osbuild.RunOSBuild(bytes, &osbuild.OSBuildOptions{
6161
StoreDir: store,
6262
OutputDir: "./",
6363
Exports: manifest.GetExports(),

pkg/osbuild/export_test.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ type (
44
StatusJSON = statusJSON
55
)
66

7-
func MockOsbuildCmd(s string) (restore func()) {
7+
var (
8+
NewSyncedWriter = newSyncedWriter
9+
)
10+
11+
func MockOSBuildCmd(s string) (restore func()) {
812
saved := osbuildCmd
913
osbuildCmd = s
1014
return func() {

pkg/osbuild/osbuild-exec.go

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"os"
99
"os/exec"
1010
"strings"
11+
"sync"
1112

1213
"github.com/hashicorp/go-version"
1314

@@ -34,6 +35,14 @@ type OSBuildOptions struct {
3435
Checkpoints []string
3536
ExtraEnv []string
3637

38+
// If specified, the mutex is used for the syncwriter so the caller may write to the build
39+
// log as well. Also note that in case BuildLog is specified, stderr will be combined into
40+
// stdout.
41+
BuildLog io.Writer
42+
BuildLogMu *sync.Mutex
43+
Stdout io.Writer
44+
Stderr io.Writer
45+
3746
Monitor MonitorType
3847
MonitorFD uintptr
3948

@@ -73,10 +82,44 @@ func NewOSBuildCmd(manifest []byte, optsPtr *OSBuildOptions) *exec.Cmd {
7382
if opts.MonitorFD != 0 {
7483
cmd.Args = append(cmd.Args, fmt.Sprintf("--monitor-fd=%d", opts.MonitorFD))
7584
}
85+
7686
if opts.JSONOutput {
7787
cmd.Args = append(cmd.Args, "--json")
7888
}
7989

90+
// Default to os stdout/stderr. This is for maximum compatibility with the existing
91+
// bootc-image-builder in "verbose" mode where stdout, stderr come directly from osbuild.
92+
var stdout, stderr io.Writer
93+
stdout = os.Stdout
94+
if opts.Stdout != nil {
95+
stdout = opts.Stdout
96+
}
97+
cmd.Stdout = stdout
98+
stderr = os.Stderr
99+
if opts.Stderr != nil {
100+
stderr = opts.Stderr
101+
}
102+
cmd.Stderr = stderr
103+
104+
if opts.BuildLog != nil {
105+
// There is a slight wrinkle here: when requesting a buildlog we can no longer write
106+
// to separate stdout/stderr streams without being racy and give potential
107+
// out-of-order output (which is very bad and confusing in a log). The reason is
108+
// that if cmd.Std{out,err} are different "go" will start two go-routine to
109+
// monitor/copy those are racy when both stdout,stderr output happens close together
110+
// (TestRunOSBuildWithBuildlog demos that). We cannot have our cake and eat it so
111+
// here we need to combine osbuilds stderr into our stdout.
112+
// stdout → syncw → multiw → stdoutw or os stdout
113+
// stderr ↗↗↗ → buildlog
114+
var mw io.Writer
115+
if opts.BuildLogMu == nil {
116+
opts.BuildLogMu = new(sync.Mutex)
117+
}
118+
mw = newSyncedWriter(opts.BuildLogMu, io.MultiWriter(stdout, opts.BuildLog))
119+
cmd.Stdout = mw
120+
cmd.Stderr = mw
121+
}
122+
80123
cmd.Env = append(os.Environ(), opts.ExtraEnv...)
81124
cmd.Stdin = bytes.NewBuffer(manifest)
82125
return cmd
@@ -87,7 +130,7 @@ func NewOSBuildCmd(manifest []byte, optsPtr *OSBuildOptions) *exec.Cmd {
87130
// Note that osbuild returns non-zero when the pipeline fails. This function
88131
// does not return an error in this case. Instead, the failure is communicated
89132
// with its corresponding logs through osbuild.Result.
90-
func RunOSBuild(manifest []byte, errorWriter io.Writer, optsPtr *OSBuildOptions) (*Result, error) {
133+
func RunOSBuild(manifest []byte, optsPtr *OSBuildOptions) (*Result, error) {
91134
opts := common.ValueOrEmpty(optsPtr)
92135

93136
if err := CheckMinimumOSBuildVersion(); err != nil {
@@ -100,11 +143,7 @@ func RunOSBuild(manifest []byte, errorWriter io.Writer, optsPtr *OSBuildOptions)
100143

101144
if opts.JSONOutput {
102145
cmd.Stdout = &stdoutBuffer
103-
} else {
104-
cmd.Stdout = os.Stdout
105146
}
106-
cmd.Stderr = errorWriter
107-
108147
err := cmd.Start()
109148
if err != nil {
110149
return nil, fmt.Errorf("error starting osbuild: %v", err)
@@ -186,3 +225,19 @@ func OSBuildInspect(manifest []byte) ([]byte, error) {
186225

187226
return out, nil
188227
}
228+
229+
type syncedWriter struct {
230+
mu *sync.Mutex
231+
w io.Writer
232+
}
233+
234+
func newSyncedWriter(mu *sync.Mutex, w io.Writer) io.Writer {
235+
return &syncedWriter{mu: mu, w: w}
236+
}
237+
238+
func (sw *syncedWriter) Write(p []byte) (n int, err error) {
239+
sw.mu.Lock()
240+
defer sw.mu.Unlock()
241+
242+
return sw.w.Write(p)
243+
}

pkg/osbuild/osbuild-exec_test.go

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,24 @@
11
package osbuild_test
22

33
import (
4+
"bufio"
5+
"bytes"
46
"fmt"
57
"io"
68
"os"
79
"path/filepath"
10+
"strings"
11+
"sync"
812
"testing"
13+
"time"
914

1015
"github.com/stretchr/testify/assert"
1116

1217
"github.com/osbuild/images/pkg/datasizes"
1318
"github.com/osbuild/images/pkg/osbuild"
1419
)
1520

16-
func makeFakeOsbuild(t *testing.T, content string) string {
21+
func makeFakeOSBuild(t *testing.T, content string) string {
1722
p := filepath.Join(t.TempDir(), "fake-osbuild")
1823
err := os.WriteFile(p, []byte("#!/bin/sh\n"+content), 0755)
1924
assert.NoError(t, err)
@@ -98,22 +103,82 @@ func TestNewOSBuildCmdFullOptions(t *testing.T) {
98103
assert.Equal(t, mf, stdin)
99104
}
100105

101-
func TestRunOSBuild(t *testing.T) {
102-
fakeOsbuildBinary := makeFakeOsbuild(t, `
106+
func TestRunOSBuildJSONOutput(t *testing.T) {
107+
fakeOSBuildBinary := makeFakeOSBuild(t, `
103108
if [ "$1" = "--version" ]; then
104109
echo '90000.0'
105110
else
106111
echo '{"success": true}'
107112
fi
108113
`)
109-
restore := osbuild.MockOsbuildCmd(fakeOsbuildBinary)
114+
restore := osbuild.MockOSBuildCmd(fakeOSBuildBinary)
110115
defer restore()
111116

112117
opts := &osbuild.OSBuildOptions{
113118
JSONOutput: true,
114119
}
115-
result, err := osbuild.RunOSBuild([]byte(`{"fake":"manifest"}`), nil, opts)
120+
result, err := osbuild.RunOSBuild([]byte(`{"fake":"manifest"}`), opts)
116121
assert.NoError(t, err)
117122
assert.NotNil(t, result)
118123
assert.True(t, result.Success)
119124
}
125+
126+
func TestRunOSBuildBuildLog(t *testing.T) {
127+
fakeOSBuildBinary := makeFakeOSBuild(t, `
128+
if [ "$1" = "--version" ]; then
129+
echo '90000.0'
130+
else
131+
echo osbuild-stdout-output
132+
fi
133+
>&2 echo osbuild-stderr-output
134+
`)
135+
restore := osbuild.MockOSBuildCmd(fakeOSBuildBinary)
136+
defer restore()
137+
138+
var buildLog, stdout, stderr bytes.Buffer
139+
opts := &osbuild.OSBuildOptions{
140+
BuildLog: &buildLog,
141+
Stdout: &stdout,
142+
Stderr: &stderr,
143+
}
144+
145+
result, err := osbuild.RunOSBuild(nil, opts)
146+
assert.NoError(t, err)
147+
// without json output set the result should be empty
148+
assert.Empty(t, result)
149+
150+
assert.NoError(t, err)
151+
assert.Equal(t, `osbuild-stdout-output
152+
osbuild-stderr-output
153+
`, buildLog.String())
154+
assert.Equal(t, `osbuild-stdout-output
155+
osbuild-stderr-output
156+
`, stdout.String())
157+
assert.Empty(t, stderr.Bytes())
158+
}
159+
160+
func TestSyncWriter(t *testing.T) {
161+
var mu sync.Mutex
162+
var buf bytes.Buffer
163+
var wg sync.WaitGroup
164+
165+
for id := 0; id < 100; id++ {
166+
wg.Add(1)
167+
w := osbuild.NewSyncedWriter(&mu, &buf)
168+
go func(id int) {
169+
defer wg.Done()
170+
for i := 0; i < 500; i++ {
171+
fmt.Fprintln(w, strings.Repeat(fmt.Sprintf("%v", id%10), 60))
172+
time.Sleep(10 * time.Nanosecond)
173+
}
174+
}(id)
175+
}
176+
wg.Wait()
177+
178+
scanner := bufio.NewScanner(&buf)
179+
for res := scanner.Scan(); res; res = scanner.Scan() {
180+
line := scanner.Text()
181+
assert.True(t, len(line) == 60, fmt.Sprintf("len %v: line: %v", len(line), line))
182+
}
183+
assert.NoError(t, scanner.Err())
184+
}

0 commit comments

Comments
 (0)