Skip to content

Commit

Permalink
progress: add progressbar module
Browse files Browse the repository at this point in the history
This commit adds a progressbar interface and implementations:
1. pbProgressBar based on github.com/cheggaaa/pb/v3
2. debugProgressBar to just print the osbuild/images status
3. nullProgressBar for tests etc to just silence the progress
  • Loading branch information
mvo5 committed Dec 5, 2024
1 parent ae5e817 commit 827c9b5
Show file tree
Hide file tree
Showing 3 changed files with 302 additions and 0 deletions.
7 changes: 7 additions & 0 deletions bib/internal/progress/export_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package progress

type (
PbProgressBar = pbProgressBar
DebugProgressBar = debugProgressBar
NullProgressBar = nullProgressBar
)
269 changes: 269 additions & 0 deletions bib/internal/progress/progress.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
package progress

import (
"bytes"
"fmt"
"io"
"os"
"os/exec"
"strings"

"github.com/cheggaaa/pb/v3"

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

// ProgressBar is an interfacs for progress reporting when there is
// an arbitrary amount of sub-progress information (like osbuild)
type ProgressBar interface {
// SetProgress sets the progress details at the given "level".
// Levels should start with "0" and increase as the nesting
// gets deeper.
SetProgress(level int, msg string, done int, total int) error
// The high-level message that is displayed in a spinner
// (e.g. "Building image foo")
SetPulseMsg(fmt string, args ...interface{})
// A high level message with the last high level status
// (e.g. "Started downloading")
SetMessage(fmt string, args ...interface{})
Start() error
Stop() error
}

// New creates a new progressbar based on the requested type
func New(progress string) ProgressBar {
switch progress {
case "", "null":
return NewNullProgressBar()
case "text":
return NewProgressBar()
case "debug":
return NewDebugProgressBar()
default:
return nil
}
}

type pbProgressBar struct {
spinnerPb *pb.ProgressBar
msgPb *pb.ProgressBar
subLevelPbs []*pb.ProgressBar

pool *pb.Pool
startedMain bool
startedSub bool
}

// NewProgressBar creates a new default pb3 based progressbar suitable for
// most terminals.
func NewProgressBar() ProgressBar {
ppb := &pbProgressBar{}
ppb.spinnerPb = pb.New(0)
ppb.spinnerPb.SetTemplate(`[{{ (cycle . "|" "/" "-" "\\") }}] {{ string . "spinnerMsg" }}`)
ppb.msgPb = pb.New(0)
ppb.msgPb.SetTemplate(`Message: {{ string . "msg" }}`)
return ppb
}

func (ppb *pbProgressBar) SetProgress(subLevel int, msg string, done int, total int) error {
// auto-add as needed, requires sublevels to get added in order
// i.e. adding 0 and then 2 will fail
switch {
case subLevel == len(ppb.subLevelPbs):
apb := pb.New(0)
ppb.subLevelPbs = append(ppb.subLevelPbs, apb)
progressBarTmpl := `[{{ counters . }}] {{ string . "prefix" }} {{ bar .}} {{ percent . }}`
apb.SetTemplateString(progressBarTmpl)
ppb.pool.Add(apb)
case subLevel > len(ppb.subLevelPbs):
return fmt.Errorf("sublevel added out of order, have %v sublevels but want level %v", len(ppb.subLevelPbs), subLevel)
}
apb := ppb.subLevelPbs[subLevel]
apb.SetTotal(int64(total) + 1)
apb.SetCurrent(int64(done) + 1)
if msg != "" {
apb.Set("prefix", msg)
}
return nil
}

func shorten(msg string) string {
msg = strings.Replace(msg, "\n", " ", -1)
// XXX: make this smarter
if len(msg) > 60 {
return msg[:60] + "..."
}
return msg
}

func (ppb *pbProgressBar) SetPulseMsg(msg string, args ...interface{}) {
ppb.spinnerPb.Set("spinnerMsg", shorten(fmt.Sprintf(msg, args...)))
}

func (ppb *pbProgressBar) SetMessage(msg string, args ...interface{}) {
ppb.msgPb.Set("msg", shorten(fmt.Sprintf(msg, args...)))
}

func (ppb *pbProgressBar) Start() (err error) {
ppb.pool, err = pb.StartPool(ppb.spinnerPb, ppb.msgPb)
if err != nil {
return fmt.Errorf("progress bar failed: %w", err)
}
return nil
}

func (ppb *pbProgressBar) Stop() (err error) {
return ppb.pool.Stop()
}

type nullProgressBar struct {
w io.Writer
}

// NewNullProgressBar starts a new "empty" progressbar that will just
// do nothing.
func NewNullProgressBar() ProgressBar {
np := &nullProgressBar{w: os.Stderr}
return np
}

func (np *nullProgressBar) SetPulseMsg(msg string, args ...interface{}) {
}

func (np *nullProgressBar) SetMessage(msg string, args ...interface{}) {
}

func (np *nullProgressBar) Start() (err error) {
return nil
}

func (np *nullProgressBar) Stop() (err error) {
return nil
}

func (np *nullProgressBar) SetProgress(subLevel int, msg string, done int, total int) error {
return nil
}

type debugProgressBar struct {
w io.Writer
}

// NewDebugProgressBar will create a progressbar aimed to debug the
// lower level osbuild/images message. It will never clear the screen
// so "glitches/weird" messages from the lower-layers can be inspected
// easier.
func NewDebugProgressBar() ProgressBar {
np := &debugProgressBar{w: os.Stderr}
return np
}

func (np *debugProgressBar) SetPulseMsg(msg string, args ...interface{}) {
fmt.Fprintf(np.w, msg, args...)
fmt.Fprintf(np.w, "\n")
}

func (np *debugProgressBar) SetMessage(msg string, args ...interface{}) {
fmt.Fprintf(np.w, msg, args...)
fmt.Fprintf(np.w, "\n")
}

func (np *debugProgressBar) Start() (err error) {
fmt.Fprintf(np.w, "Start\n")
return nil
}

func (np *debugProgressBar) Stop() (err error) {
fmt.Fprintf(np.w, "Stop\n")
return nil
}

func (np *debugProgressBar) SetProgress(subLevel int, msg string, done int, total int) error {
fmt.Fprintf(np.w, "%s[%v / %v], %s", strings.Repeat(" ", subLevel), done, total, msg)
fmt.Fprintf(np.w, "\n")
return nil
}

// XXX: merge variant back into images/pkg/osbuild/osbuild-exec.go
func RunOSBuild(pb ProgressBar, manifest []byte, store, outputDirectory string, exports, extraEnv []string) error {
switch pb.(type) {
case *pbProgressBar, *debugProgressBar:
return runOSBuildNew(pb, manifest, store, outputDirectory, exports, extraEnv)
default:
return runOSBuildOld(pb, manifest, store, outputDirectory, exports, extraEnv)
}
}

func runOSBuildOld(pb ProgressBar, manifest []byte, store, outputDirectory string, exports, extraEnv []string) error {
_, err := osbuild.RunOSBuild(manifest, store, outputDirectory, exports, nil, extraEnv, false, os.Stderr)
return err
}

func runOSBuildNew(pb ProgressBar, manifest []byte, store, outputDirectory string, exports, extraEnv []string) 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 := exec.Command(
"osbuild",
"--store", store,
"--output-directory", outputDirectory,
"--monitor=JSONSeqMonitor",
"--monitor-fd=3",
"-",
)
for _, export := range exports {
cmd.Args = append(cmd.Args, "--export", export)
}

cmd.Env = append(os.Environ(), extraEnv...)
cmd.Stdin = bytes.NewBuffer(manifest)
cmd.Stderr = os.Stderr
// we could use "--json" here and would get the build-result
// exported here
cmd.Stdout = nil
cmd.ExtraFiles = []*os.File{wp}

osbuildStatus := osbuild.NewStatusScanner(rp)
if err := cmd.Start(); err != nil {
return fmt.Errorf("error starting osbuild: %v", err)
}
wp.Close()

var tracesMsgs []string
for {
st, err := osbuildStatus.Status()
if err != nil {
return fmt.Errorf("error reading osbuild status: %w", err)
}
if st == nil {
break
}
i := 0
for p := st.Progress; p != nil; p = p.SubProgress {
// XXX: osbuild gives us bad progress messages
pb.SetProgress(i, p.Message, p.Done, p.Total)
i++
}
// keep the messages/traces for better error reporting
if st.Message != "" {
tracesMsgs = append(tracesMsgs, st.Message)
}
if st.Trace != "" {
tracesMsgs = append(tracesMsgs, st.Trace)
}
// forward to user
if st.Message != "" {
pb.SetMessage(st.Message)
}
}

if err := cmd.Wait(); err != nil {
return fmt.Errorf("error running osbuild: %w\nLog:\n%s", err, strings.Join(tracesMsgs, "\n"))
}

return nil
}
26 changes: 26 additions & 0 deletions bib/internal/progress/progress_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package progress_test

import (
"fmt"
"reflect"
"testing"

"github.com/stretchr/testify/assert"

"github.com/osbuild/bootc-image-builder/bib/internal/progress"
)

func TestProgressNew(t *testing.T) {
for _, tc := range []struct {
Type string
Expected interface{}
}{
{"text", &progress.PbProgressBar{}},
{"debug", &progress.DebugProgressBar{}},
{"null", &progress.NullProgressBar{}},
{"bad", nil},
} {
pb := progress.New(tc.Type)
assert.Equal(t, reflect.TypeOf(pb), reflect.TypeOf(tc.Expected), fmt.Sprintf("[%v] %T not the expected %T", tc.Type, pb, tc.Expected))
}
}

0 comments on commit 827c9b5

Please sign in to comment.