Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cmd/image-builder/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"github.com/osbuild/image-builder-cli/pkg/progress"
"github.com/osbuild/images/pkg/imagefilter"
"github.com/osbuild/images/pkg/osbuild"
)

type buildOptions struct {
Expand Down Expand Up @@ -35,7 +36,7 @@ func buildImage(pbar progress.ProgressBar, res *imagefilter.Result, osbuildManif
}
}

osbuildOpts := &progress.OSBuildOptions{
osbuildOpts := &osbuild.OSBuildOptions{
StoreDir: opts.StoreDir,
OutputDir: opts.OutputDir,
}
Expand Down
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ require (
github.com/gobwas/glob v0.2.3
github.com/mattn/go-isatty v0.0.20
github.com/osbuild/blueprint v1.16.0
github.com/osbuild/images v0.206.0
github.com/osbuild/images v0.209.0
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.11.1
golang.org/x/sys v0.35.0
Expand Down Expand Up @@ -141,3 +141,5 @@ require (
gopkg.in/ini.v1 v1.67.0 // indirect
libvirt.org/go/libvirt v1.11006.0 // indirect
)

replace github.com/osbuild/images => github.com/croissanne/images v0.0.0-20251023114920-81eefc443b5e
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As this is still draft, this is just to show what it would look like with osbuild/images#1966. Not actually intending to merge this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure was just highlighting it, I guess we have a linter for that. Or not, depends on the repo :-)

4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ github.com/containers/storage v1.59.1/go.mod h1:KoAYHnAjP3/cTsRS+mmWZGkufSY2GACi
github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4=
github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/croissanne/images v0.0.0-20251023114920-81eefc443b5e h1:MuftBLwWLE8gWoJY1/P26tw0TL9HIkb4LcoTepZjVyY=
github.com/croissanne/images v0.0.0-20251023114920-81eefc443b5e/go.mod h1:tZqcrs3eNUA0VPs1h3YCnbnpAskVVfo36CIi2USSfDs=
github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q=
github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw=
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
Expand Down Expand Up @@ -245,8 +247,6 @@ github.com/opencontainers/selinux v1.12.0 h1:6n5JV4Cf+4y0KNXW48TLj5DwfXpvWlxXplU
github.com/opencontainers/selinux v1.12.0/go.mod h1:BTPX+bjVbWGXw7ZZWUbdENt8w0htPSrlgOOysQaU62U=
github.com/osbuild/blueprint v1.16.0 h1:f/kHih+xpeJ1v7wtIfzdHPZTsiXsqKeDQ1+rrue6298=
github.com/osbuild/blueprint v1.16.0/go.mod h1:HPlJzkEl7q5g8hzaGksUk7ifFAy9QFw9LmzhuFOAVm4=
github.com/osbuild/images v0.206.0 h1:F9G6dnqrURRUcWE2eWIPQLzHutSCT0OyWMcITK28uCQ=
github.com/osbuild/images v0.206.0/go.mod h1:iF6bTLzBtyp9l27fexsD5AzwHEn9+bXF5Jr4HHQecmI=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should not have been in this commit, there is a separate one I guess you accidentally squashed it in.

Expand Down
106 changes: 22 additions & 84 deletions pkg/progress/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,30 +7,17 @@ import (
"io"
"log"
"os"
"os/exec"
"strings"
"sync"
"syscall"

"github.com/osbuild/images/pkg/datasizes"
"github.com/osbuild/images/pkg/osbuild"
)

type OSBuildOptions struct {
StoreDir string
OutputDir string
ExtraEnv []string

// BuildLog writes the osbuild output to the given writer
BuildLog io.Writer

CacheMaxSize int64
}

// XXX: merge variant back into images/pkg/osbuild/osbuild-exec.go
func RunOSBuild(pb ProgressBar, manifest []byte, exports []string, opts *OSBuildOptions) error {
func RunOSBuild(pb ProgressBar, manifest []byte, exports []string, opts *osbuild.OSBuildOptions) error {
if opts == nil {
opts = &OSBuildOptions{}
opts = &osbuild.OSBuildOptions{}
}

// To keep maximum compatibility keep the old behavior to run osbuild
Expand All @@ -48,92 +35,39 @@ func RunOSBuild(pb ProgressBar, manifest []byte, exports []string, opts *OSBuild
}
}

func newOsbuildCmd(manifest []byte, exports []string, opts *OSBuildOptions) *exec.Cmd {
cacheMaxSize := int64(20 * datasizes.GiB)
if opts.CacheMaxSize != 0 {
cacheMaxSize = opts.CacheMaxSize
}
cmd := exec.Command(
osbuildCmd,
"--store", opts.StoreDir,
"--output-directory", opts.OutputDir,
fmt.Sprintf("--cache-max-size=%v", cacheMaxSize),
"-",
)
for _, export := range exports {
cmd.Args = append(cmd.Args, "--export", export)
}
cmd.Env = append(os.Environ(), opts.ExtraEnv...)
cmd.Stdin = bytes.NewBuffer(manifest)
return cmd
}

func runOSBuildNoProgress(pb ProgressBar, manifest []byte, exports []string, opts *OSBuildOptions) error {
var stdout, stderr io.Writer

var writeMu sync.Mutex
if opts.BuildLog == nil {
// No external build log requested and we won't need an
// internal one because all output goes directly to
// stdout/stderr. This is for maximum compatibility with
// the existing bootc-image-builder in "verbose" mode
// where stdout, stderr come directly from osbuild.
stdout = osStdout()
stderr = osStderr()
} else {
// There is a slight wrinkle here: when requesting a
// buildlog we can no longer write to separate
// stdout/stderr streams without being racy and give
// potential out-of-order output (which is very bad
// and confusing in a log). The reason is that if
// cmd.Std{out,err} are different "go" will start two
// go-routine to monitor/copy those are racy when both
// stdout,stderr output happens close together
// (TestRunOSBuildWithBuildlog demos that). We cannot
// have our cake and eat it so here we need to combine
// osbuilds stderr into our stdout.
mw := newSyncedWriter(&writeMu, io.MultiWriter(osStdout(), opts.BuildLog))
stdout = mw
stderr = mw
}

cmd := newOsbuildCmd(manifest, exports, opts)
cmd.Stdout = stdout
cmd.Stderr = stderr
func runOSBuildNoProgress(pb ProgressBar, manifest []byte, exports []string, opts *osbuild.OSBuildOptions) error {
cmd := osbuild.NewOSBuildCmd(manifest, exports, opts)
if err := cmd.Run(); err != nil {
return fmt.Errorf("error running osbuild: %w", err)
}
return nil
}

var osbuildCmd = "osbuild"

func runOSBuildWithProgress(pb ProgressBar, manifest []byte, exports []string, opts *OSBuildOptions) (err error) {
func runOSBuildWithProgress(pb ProgressBar, manifest []byte, exports []string, opts *osbuild.OSBuildOptions) (err error) {
rp, wp, err := os.Pipe()
if err != nil {
return fmt.Errorf("cannot create pipe for osbuild: %w", err)
}
defer rp.Close()
defer wp.Close()

cmd := newOsbuildCmd(manifest, exports, opts)
cmd.Args = append(cmd.Args, "--monitor=JSONSeqMonitor")
cmd.Args = append(cmd.Args, "--monitor-fd=3")

opts.Monitor = osbuild.MonitorJSONSeq
opts.MonitorFD = 3
opts.MonitorFile = wp
var stdio bytes.Buffer
var mw, buildLog io.Writer
var writeMu sync.Mutex
if opts.BuildLog != nil {
mw = newSyncedWriter(&writeMu, io.MultiWriter(&stdio, opts.BuildLog))
buildLog = newSyncedWriter(&writeMu, opts.BuildLog)
} else {
mw = &stdio
var mu sync.Mutex
buildLog := opts.BuildLog
opts.BuildLogMu = &mu
Copy link
Contributor

@lzap lzap Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can someone explain why newSyncedWriter was written in a way it accepts a mutex? I do not understand why a mutex could be shared for this decorator design pattern. It should have been:

type SyncedWriter struct {
	mu sync.Mutex
	w  io.Writer
}

func NewSyncedWriter(w io.Writer) *SyncedWriter {
	return &SyncedWriter{w: w}
}

func (sw *SyncedWriter) Write(p []byte) (n int, err error) {
	sw.mu.Lock()
	defer sw.mu.Unlock()

	return sw.w.Write(p)
}

Than we could drop this passing of mutex, it is created on stack either way.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 reasons:

  1. ibcli needs to write to the buildlog itself, it needs access to the mutex. So passing the mutex to the synced writer is needed, or we need to retrieve the mutex from the writer. It's ugly, but I can't think of an alternative.

  2. 2 separate instances of synced writer need to share this mutex

mw = newSyncedWriter(&writeMu, io.MultiWriter(&stdio, opts.BuildLog))
buildLog = newSyncedWriter(&writeMu, opts.BuildLog)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 2nd reason is actually no longer the case with osbuild/images#1966 ; but we still need to pass along the mutex, unless we don't care about the ordering for these extra statements ibcli adds to the buildlog.

Copy link
Contributor

@lzap lzap Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm, right. Well, how about to tee before sync so each writer has its individual mutex that can be shared? Something like:

syncedStdio := newSyncedWriter(&stdio)
syncedBuildLog := newSyncedWriter(opts.BuildLog)
io.MultiWriter(syncedStdio, syncedBuildLog)

Then use syncedBuildLog to write separately only to the build log independent of stdio. The example assumes newSyncedWriter no longer takes a shared mutex.

Copy link
Member Author

@croissanne croissanne Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the flow should be

stdout → syncw → multiw → stdoutw or os stdout     
stderr ↗↗↗              → buildlog

If we create the syncw after the mw we get

stdout → multiw → syncw
stderr ↗↗↗      → syncw

I think by having both stdout and stderr go via the multiw into syncw we're already out of order? Unsure.

Copy link
Member Author

@croissanne croissanne Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh actually this works in images, I guess io.MultiWriter isn't racy?

	if opts.BuildLog != nil {
		// There is a slight wrinkle here: when requesting a buildlog we can no longer write
		// to separate stdout/stderr streams without being racy and give potential
		// out-of-order output (which is very bad and confusing in a log). The reason is
		// that if cmd.Std{out,err} are different "go" will start two go-routine to
		// monitor/copy those are racy when both stdout,stderr output happens close together
		// (TestRunOSBuildWithBuildlog demos that). We cannot have our cake and eat it so
		// here we need to combine osbuilds stderr into our stdout.
		// stdout → syncw → multiw → stdoutw or os stdout
		// stderr ↗↗↗             → buildlog
		//
		//
		// stdout → syncw → multiw → stdoutw or os stdout
		// stderr ↗↗↗             → buildlog
		// mu := common.ValueOrEmpty(opts.BuildLogMu)
		var mu1 sync.Mutex
		var mu2 sync.Mutex
		mw := io.MultiWriter(newSyncedWriter(&mu1, stdout), newSyncedWriter(&mu2, opts.BuildLog))
		cmd.Stdout = mw
		cmd.Stderr = mw
	}

The only problem is still writing the the buildlog from ibcli if we actually wanna move the synced writer over to images.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reversing multiwriter and syncedwriter can work, test pass with go test -race iwth this in images:

		if opts.BuildLogMu == nil {
			var mu sync.Mutex
			opts.BuildLogMu = &mu
		}

		var syncedStdio, syncedBuildlog io.Writer
		syncedStdio = newSyncedWriter(opts.BuildLogMu, stdout)
		syncedBuildlog = newSyncedWriter(opts.BuildLogMu, opts.BuildLog)
		mw := io.MultiWriter(syncedStdio, syncedBuildlog)
		cmd.Stdout = mw
		cmd.Stderr = mw

But this doesn't solve problem 2 :/

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image maybe helpful

if opts.BuildLog == nil {
mw := &stdio
opts.Stdout = mw
opts.Stderr = mw
buildLog = io.Discard
} else {
opts.Stdout = &stdio
}

cmd.Stdout = mw
cmd.Stderr = mw
cmd.ExtraFiles = []*os.File{wp}
cmd := osbuild.NewOSBuildCmd(manifest, exports, opts)

osbuildStatus := osbuild.NewStatusScanner(rp)
if err := cmd.Start(); err != nil {
Expand Down Expand Up @@ -186,11 +120,15 @@ func runOSBuildWithProgress(pb ProgressBar, manifest []byte, exports []string, o
// external build log
if st.Message != "" {
tracesMsgs = append(tracesMsgs, st.Message)
mu.Lock()
fmt.Fprintln(buildLog, st.Message)
mu.Unlock()
}
if st.Trace != "" {
tracesMsgs = append(tracesMsgs, st.Trace)
mu.Lock()
fmt.Fprintln(buildLog, st.Trace)
mu.Unlock()
}
}

Expand Down
43 changes: 16 additions & 27 deletions pkg/progress/command_test.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
package progress_test

import (
"bytes"
"fmt"
Expand All @@ -12,6 +11,7 @@ import (
"github.com/stretchr/testify/assert"

"github.com/osbuild/image-builder-cli/pkg/progress"
"github.com/osbuild/images/pkg/osbuild"
)

func makeFakeOsbuild(t *testing.T, content string) string {
Expand All @@ -22,10 +22,7 @@ func makeFakeOsbuild(t *testing.T, content string) string {
}

func TestRunOSBuildWithProgressErrorReporting(t *testing.T) {
restore := progress.MockOsStderr(io.Discard)
defer restore()

restore = progress.MockOsbuildCmd(makeFakeOsbuild(t, `
restore := progress.MockOsbuildCmd(makeFakeOsbuild(t, `
>&3 echo '{"message": "osbuild-stage-message"}'

echo osbuild-stdout-output
Expand All @@ -34,9 +31,13 @@ exit 112
`))
defer restore()

opts := &osbuild.OSBuildOptions{
Stderr: io.Discard,
}

pbar, err := progress.New("debug")
assert.NoError(t, err)
err = progress.RunOSBuild(pbar, []byte(`{"fake":"manifest"}`), nil, nil)
err = progress.RunOSBuild(pbar, []byte(`{"fake":"manifest"}`), nil, opts)
assert.EqualError(t, err, `error running osbuild: exit status 112
BuildLog:
osbuild-stage-message
Expand Down Expand Up @@ -93,20 +94,14 @@ sleep 0.1
`))
defer restore()

var fakeStdout, fakeStderr bytes.Buffer
restore = progress.MockOsStdout(&fakeStdout)
defer restore()
restore = progress.MockOsStderr(&fakeStderr)
defer restore()

pbar, err := progress.New("term")
assert.NoError(t, err)

var buildLog bytes.Buffer
opts := &progress.OSBuildOptions{
var buildLog, stdout bytes.Buffer
err = progress.RunOSBuild(pbar, []byte(`{"fake":"manifest"}`), nil, &osbuild.OSBuildOptions{
Stdout: &stdout,
BuildLog: &buildLog,
}
err = progress.RunOSBuild(pbar, []byte(`{"fake":"manifest"}`), nil, opts)
})
assert.NoError(t, err)
expectedOutput := `osbuild-stdout-output
osbuild-stderr-output
Expand All @@ -122,20 +117,14 @@ echo osbuild-stdout-output
`))
defer restore()

var fakeStdout, fakeStderr bytes.Buffer
restore = progress.MockOsStdout(&fakeStdout)
defer restore()
restore = progress.MockOsStderr(&fakeStderr)
defer restore()

pbar, err := progress.New("verbose")
assert.NoError(t, err)

var buildLog bytes.Buffer
opts := &progress.OSBuildOptions{
var buildLog, stdout bytes.Buffer
err = progress.RunOSBuild(pbar, []byte(`{"fake":"manifest"}`), nil, &osbuild.OSBuildOptions{
Stdout: &stdout,
BuildLog: &buildLog,
}
err = progress.RunOSBuild(pbar, []byte(`{"fake":"manifest"}`), nil, opts)
})
assert.NoError(t, err)
expectedOutput := `osbuild-stdout-output
osbuild-stderr-output
Expand All @@ -151,7 +140,7 @@ func TestRunOSBuildCacheMaxSize(t *testing.T) {
pbar, err := progress.New("debug")
assert.NoError(t, err)

osbuildOpts := &progress.OSBuildOptions{
osbuildOpts := &osbuild.OSBuildOptions{
CacheMaxSize: 77,
}
err = progress.RunOSBuild(pbar, []byte(`{"fake":"manifest"}`), nil, osbuildOpts)
Expand Down
18 changes: 6 additions & 12 deletions pkg/progress/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package progress

import (
"io"

"github.com/osbuild/images/pkg/osbuild"
)

type (
Expand All @@ -11,17 +13,9 @@ type (
)

var (
NewSyncedWriter = newSyncedWriter
OSStderr = osStderr
)

func MockOsStdout(w io.Writer) (restore func()) {
saved := osStdout
osStdout = func() io.Writer { return w }
return func() {
osStdout = saved
}
}

func MockOsStderr(w io.Writer) (restore func()) {
saved := osStderr
osStderr = func() io.Writer { return w }
Expand All @@ -39,9 +33,9 @@ func MockIsattyIsTerminal(fn func(uintptr) bool) (restore func()) {
}

func MockOsbuildCmd(s string) (restore func()) {
saved := osbuildCmd
osbuildCmd = s
saved := osbuild.OSBuildCmd
osbuild.OSBuildCmd = s
return func() {
osbuildCmd = saved
osbuild.OSBuildCmd = saved
}
}
3 changes: 0 additions & 3 deletions pkg/progress/progress.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,6 @@ var (
// Used for testing, this must be a function (instead of the usual
// "var osStderr = os.Stderr" so that higher level libraries can test
// this code by replacing "os.Stderr", e.g. testutil.CaptureStdio()
var osStdout = func() io.Writer {
return os.Stdout
}
var osStderr = func() io.Writer {
return os.Stderr
}
Expand Down
22 changes: 0 additions & 22 deletions pkg/progress/syncwriter.go

This file was deleted.

Loading
Loading