Skip to content
Open
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
7 changes: 4 additions & 3 deletions image/copy/copy.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,12 +255,13 @@ func Image(ctx context.Context, policyContext *signature.PolicyContext, destRef,
rawSource := imagesource.FromPublic(publicRawSource)
defer safeClose("src", rawSource)

// If reportWriter is not a TTY (e.g., when piping to a file), do not
// print the progress bars to avoid long and hard to parse output.
// Instead use printCopyInfo() to print single line "Copying ..." messages.
// If reportWriter is not a TTY (e.g., when piping to a file),
// Set text-based aggregate progress bar.
progressOutput := reportWriter
if !isTTY(reportWriter) {
progressOutput = io.Discard

setupNonTTYProgress(reportWriter, options)
}

c := &copier{
Expand Down
89 changes: 89 additions & 0 deletions image/copy/progress_nontty.go
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 {
Copy link
Contributor

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

switch props.Event {
case types.ProgressEventNewArtifact:
// New blob starting - add its size to total
w.totalSize += props.Artifact.Size
Copy link
Contributor

Choose a reason for hiding this comment

The 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 ProgressEventDone, it might never reach 100%.

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.)

Copy link
Author

@oshe-gi oshe-gi Mar 8, 2026

Choose a reason for hiding this comment

The 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.
What we can do is to wait when all parallel copying have started, and then start computing the progress.

Do you want to track per-blob progress?
Currently It will stop when copying is done, but I can work on processing ProgressEventDone events as well.
I have tested with golang image, there is an example in PR description. Is it a valid test?

Copy link
Author

Choose a reason for hiding this comment

The 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 | cat

Could you provide better/more ways to test?

Copy link
Contributor

@mtrmac mtrmac Mar 9, 2026

Choose a reason for hiding this comment

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

What we can do is to wait when all parallel copying have started, and then start computing the progress.

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 maxParallelDownloads.

We read the manifest before starting copies, and that usually (not always!) contains all of the layers’ sizes for the per-platform instance.

Copy link
Contributor

Choose a reason for hiding this comment

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

docker://docker.io/library/golang:1.24-bookworm is a reasonable image, but try copy --all


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()
}
}
}
}
179 changes: 179 additions & 0 deletions image/copy/progress_nontty_test.go
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)
}
})
}
}