-
Notifications
You must be signed in to change notification settings - Fork 83
copy: Add text-based progress output for non-TTY environments (skopeo#658) #665
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| package copy | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "io" | ||
| "time" | ||
|
|
||
| "github.com/vbauerster/mpb/v8/decor" | ||
| "go.podman.io/image/v5/types" | ||
| ) | ||
|
|
||
| const ( | ||
| // nonTTYProgressChannelSize is the buffer size for the progress channel | ||
| // in non-TTY mode. Buffered to prevent blocking during parallel downloads. | ||
| nonTTYProgressChannelSize = 10 | ||
|
|
||
| // nonTTYProgressInterval is how often aggregate progress is printed | ||
| // in non-TTY mode. | ||
| nonTTYProgressInterval = 500 * time.Millisecond | ||
| ) | ||
|
|
||
| // nonTTYProgressWriter consumes ProgressProperties from a channel and writes | ||
| // aggregate text-based progress output suitable for non-TTY environments. | ||
| // No mutex needed - single goroutine processes events sequentially from channel. | ||
| type nonTTYProgressWriter struct { | ||
| output io.Writer | ||
|
|
||
| // Aggregate tracking (no per-blob state needed) | ||
| totalSize int64 // Sum of all known blob sizes | ||
| downloaded int64 // Total bytes downloaded (accumulated from OffsetUpdate) | ||
|
|
||
| // Output throttling | ||
| lastOutput time.Time | ||
| outputInterval time.Duration | ||
| progressChannel <-chan types.ProgressProperties | ||
| } | ||
|
|
||
| // newNonTTYProgressWriter creates a writer that outputs aggregate download | ||
| // progress as simple text lines, suitable for non-TTY environments like | ||
| // CI/CD pipelines or redirected output. | ||
| func newNonTTYProgressWriter(output io.Writer, interval time.Duration, pch chan types.ProgressProperties) *nonTTYProgressWriter { | ||
| return &nonTTYProgressWriter{ | ||
| output: output, | ||
| outputInterval: interval, | ||
| progressChannel: pch, | ||
| } | ||
| } | ||
|
|
||
| // setupNonTTYProgress configures text-based progress output for non-TTY | ||
| // environments unless the caller already provided a buffered Progress channel. | ||
| // It relies on the idea that options. Progress channel is only used once, to track progress with a progress bar | ||
| // Otherwise we must do some sort of fan-out | ||
| func setupNonTTYProgress(reportWriter io.Writer, options *Options) { | ||
| // // Use user's interval if greater than our default, otherwise use default. | ||
| // // This allows users to slow down output while maintaining a sensible minimum. | ||
| // interval := max(options.ProgressInterval, nonTTYProgressInterval) | ||
| // if options.ProgressInterval <= 0 { | ||
| // options.ProgressInterval = nonTTYProgressInterval | ||
| // } | ||
|
|
||
| // if options.Progress == nil || cap(options.Progress) == 0 { | ||
| // options.Progress = make(chan types.ProgressProperties, nonTTYProgressChannelSize) | ||
| // } | ||
|
|
||
| pw := newNonTTYProgressWriter(reportWriter, options.ProgressInterval, options.Progress) | ||
| go pw.Run() | ||
| } | ||
|
|
||
| // Run consumes progress events from the channel and prints throttled | ||
| // aggregate progress. Blocks until the channel is closed. Intended to | ||
| // be called as a goroutine: go tw.Run(progressChan) | ||
| func (w *nonTTYProgressWriter) Run() { | ||
| for props := range w.progressChannel { | ||
| switch props.Event { | ||
| case types.ProgressEventNewArtifact: | ||
| // New blob starting - add its size to total | ||
| w.totalSize += props.Artifact.Size | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That’s not a very useful concept of a “total progress” if the total is increasing over time. And sometimes we trigger an upload but then never consume all of the source; if this is not watching How does this interact with multi-platform images? (Working one platform at a time would be easier in that we usually (not always!) know the size of the input in advance.)
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Idea of total progress is to give at least rough estimate, and short output. Do you want to track per-blob progress?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @mtrmac this is how I tested. --policy ./default-policy.json \
--override-os linux \
--override-arch amd64 \
copy \
--image-parallel-copies 8 \
docker://docker.io/library/golang:1.24-bookworm \
dir:/tmp/golang-copy | catCould you provide better/more ways to test?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
We don’t start all layer copies simultaneously. Try a 20-layer image (large enough, or on a network slow enough, to be able to observe the progress.). Compare We read the manifest before starting copies, and that usually (not always!) contains all of the layers’ sizes for the per-platform instance.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| case types.ProgressEventRead: | ||
| // Bytes downloaded - accumulate and maybe print | ||
| w.downloaded += int64(props.OffsetUpdate) | ||
| if time.Since(w.lastOutput) > w.outputInterval { | ||
| fmt.Fprintf(w.output, "Progress: %.1f / %.1f\n", | ||
| decor.SizeB1024(w.downloaded), decor.SizeB1024(w.totalSize)) | ||
| w.lastOutput = time.Now() | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,179 @@ | ||
| package copy | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "strings" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| "go.podman.io/image/v5/types" | ||
| ) | ||
|
|
||
| func TestNonTTYProgressWriterRun(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| interval time.Duration | ||
| events []types.ProgressProperties | ||
| wantTotalSize int64 | ||
| wantDownloaded int64 | ||
| wantLines int | ||
| wantContains string | ||
| }{ | ||
| { | ||
| name: "new artifacts only", | ||
| interval: 500 * time.Millisecond, | ||
| events: []types.ProgressProperties{ | ||
| {Event: types.ProgressEventNewArtifact, Artifact: types.BlobInfo{Size: 1024}}, | ||
| {Event: types.ProgressEventNewArtifact, Artifact: types.BlobInfo{Size: 2048}}, | ||
| }, | ||
| wantTotalSize: 3072, | ||
| wantDownloaded: 0, | ||
| wantLines: 0, | ||
| }, | ||
| { | ||
| name: "read events produce output", | ||
| interval: -1 * time.Millisecond, | ||
| events: []types.ProgressProperties{ | ||
| {Event: types.ProgressEventNewArtifact, Artifact: types.BlobInfo{Size: 10240}}, | ||
| {Event: types.ProgressEventRead, OffsetUpdate: 5120}, | ||
| }, | ||
| wantTotalSize: 10240, | ||
| wantDownloaded: 5120, | ||
| wantLines: 1, | ||
| wantContains: "Progress:", | ||
| }, | ||
| { | ||
| name: "throttling limits output", | ||
| interval: time.Hour, | ||
| events: []types.ProgressProperties{ | ||
| {Event: types.ProgressEventNewArtifact, Artifact: types.BlobInfo{Size: 10240}}, | ||
| {Event: types.ProgressEventRead, OffsetUpdate: 1024}, | ||
| {Event: types.ProgressEventRead, OffsetUpdate: 1024}, | ||
| {Event: types.ProgressEventRead, OffsetUpdate: 1024}, | ||
| }, | ||
| wantTotalSize: 10240, | ||
| wantDownloaded: 3072, | ||
| wantLines: 1, | ||
| }, | ||
| { | ||
| name: "unknown events ignored", | ||
| interval: 500 * time.Millisecond, | ||
| events: []types.ProgressProperties{ | ||
| {Event: types.ProgressEventDone}, | ||
| }, | ||
| wantTotalSize: 0, | ||
| wantDownloaded: 0, | ||
| wantLines: 0, | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| var buf bytes.Buffer | ||
|
|
||
| ch := make(chan types.ProgressProperties, len(tt.events)) | ||
| pw := newNonTTYProgressWriter(&buf, tt.interval, ch) | ||
| for _, e := range tt.events { | ||
| ch <- e | ||
| } | ||
| close(ch) | ||
|
|
||
| pw.Run() | ||
|
|
||
| assert.Equal(t, tt.wantTotalSize, pw.totalSize) | ||
| assert.Equal(t, tt.wantDownloaded, pw.downloaded) | ||
|
|
||
| output := buf.String() | ||
| if tt.wantLines == 0 { | ||
| assert.Empty(t, output) | ||
| } else { | ||
| lines := strings.Split(strings.TrimSpace(output), "\n") | ||
| assert.Equal(t, tt.wantLines, len(lines)) | ||
| } | ||
| if tt.wantContains != "" { | ||
| assert.Contains(t, output, tt.wantContains) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestSetupNonTTYProgressWriter(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| progress chan types.ProgressProperties | ||
| progressInterval time.Duration | ||
| wantProgressSet bool | ||
| wantIntervalSet bool | ||
| wantMinInterval time.Duration | ||
| }{ | ||
| { | ||
| name: "nil channel gets default setup", | ||
| progress: nil, | ||
| progressInterval: 0, | ||
| wantProgressSet: true, | ||
| wantIntervalSet: true, | ||
| wantMinInterval: nonTTYProgressInterval, | ||
| }, | ||
| { | ||
| name: "unbuffered channel gets replaced", | ||
| progress: make(chan types.ProgressProperties), | ||
| progressInterval: 0, | ||
| wantProgressSet: true, | ||
| wantIntervalSet: true, | ||
| wantMinInterval: nonTTYProgressInterval, | ||
| }, | ||
| { | ||
| name: "buffered channel is kept", | ||
| progress: make(chan types.ProgressProperties, 5), | ||
| progressInterval: 0, | ||
| wantProgressSet: false, | ||
| wantIntervalSet: false, | ||
| }, | ||
| { | ||
| name: "caller interval larger than default is respected", | ||
| progress: nil, | ||
| progressInterval: 2 * time.Second, | ||
| wantProgressSet: true, | ||
| wantIntervalSet: false, | ||
| wantMinInterval: 2 * time.Second, | ||
| }, | ||
| { | ||
| name: "caller interval smaller than default is kept", | ||
| progress: nil, | ||
| progressInterval: 100 * time.Millisecond, | ||
| wantProgressSet: true, | ||
| wantIntervalSet: false, | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| var buf bytes.Buffer | ||
| opts := &Options{ | ||
| Progress: tt.progress, | ||
| ProgressInterval: tt.progressInterval, | ||
| } | ||
| originalProgress := opts.Progress | ||
|
|
||
| setupNonTTYProgress(&buf, opts) | ||
| if tt.wantProgressSet { | ||
| assert.NotNil(t, opts.Progress) | ||
| assert.Greater(t, cap(opts.Progress), 0) | ||
| if originalProgress != nil { | ||
| assert.NotEqual(t, originalProgress, opts.Progress) | ||
| } | ||
| } else { | ||
| assert.Equal(t, originalProgress, opts.Progress) | ||
| } | ||
|
|
||
| if tt.wantIntervalSet { | ||
| assert.Equal(t, nonTTYProgressInterval, opts.ProgressInterval) | ||
| } | ||
|
|
||
| if tt.wantMinInterval > 0 { | ||
| assert.GreaterOrEqual(t, opts.ProgressInterval, tt.wantMinInterval) | ||
| } | ||
| }) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is not going to ever terminate, AFAICS