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
18 changes: 18 additions & 0 deletions cmd/skopeo/copy.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"io"
"os"
"strings"
"time"

encconfig "github.com/containers/ocicrypt/config"
enchelpers "github.com/containers/ocicrypt/helpers"
Expand All @@ -17,6 +18,7 @@ import (
"go.podman.io/image/v5/manifest"
"go.podman.io/image/v5/transports"
"go.podman.io/image/v5/transports/alltransports"
"go.podman.io/image/v5/types"
)

type copyOptions struct {
Expand All @@ -36,6 +38,7 @@ type copyOptions struct {
encryptionKeys []string // Keys needed to encrypt the image
decryptionKeys []string // Keys needed to decrypt the image
imageParallelCopies uint // Maximum number of parallel requests when copying images
progressInterval time.Duration // interval for CI progress output; 0 = auto-detect
}

func copyCmd(global *globalOptions) *cobra.Command {
Expand Down Expand Up @@ -85,6 +88,7 @@ See skopeo(1) section "IMAGE NAMES" for the expected format
flags.IntSliceVar(&opts.encryptLayer, "encrypt-layer", []int{}, "*Experimental* the 0-indexed layer indices, with support for negative indexing (e.g. 0 is the first layer, -1 is the last layer)")
flags.StringSliceVar(&opts.decryptionKeys, "decryption-key", []string{}, "*Experimental* key needed to decrypt the image")
flags.UintVar(&opts.imageParallelCopies, "image-parallel-copies", 0, "Maximum number of image layers to be copied (pulled/pushed) simultaneously. Not setting this field will fall back to containers/image defaults.")
flags.DurationVar(&opts.progressInterval, "progress-interval", 0, "Interval for periodic progress lines when stderr is not a TTY (default: 30s; auto-detected; suppressed by --quiet)")
return cmd
}

Expand Down Expand Up @@ -181,6 +185,10 @@ func (opts *copyOptions) run(args []string, stdout io.Writer) (retErr error) {
stdout = nil
}

if !opts.quiet {
opts.progressInterval = resolveProgressInterval(opts.progressInterval)
}

imageListSelection := copy.CopySystemImage
var instancePlatforms []copy.InstancePlatformFilter
if opts.multiArch.Present() && opts.all {
Expand Down Expand Up @@ -258,7 +266,17 @@ func (opts *copyOptions) run(args []string, stdout io.Writer) (retErr error) {
copyOpts.MaxParallelDownloads = opts.imageParallelCopies
copyOpts.ForceCompressionFormat = opts.destImage.forceCompressionFormat

if opts.progressInterval > 0 {
copyOpts.ProgressInterval = opts.progressInterval
copyOpts.ReportWriter = nil // suppress TTY bar when logging to stderr
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don’t see where stderr figures in this logic at all. Is that just due to resolveProgressInterval deciding based on that?


ReportWriter contains quite a bit of other information, not just blob progress, so completely turning it off is not great.

}

return retry.IfNecessary(ctx, func() error {
if opts.progressInterval > 0 {
ch := make(chan types.ProgressProperties, 16)
copyOpts.Progress = ch
go runProgressConsumer(ch, opts.progressInterval, os.Stderr)
}
manifestBytes, err := copy.Image(ctx, policyContext, destRef, srcRef, copyOpts)
if err != nil {
return err
Expand Down
60 changes: 60 additions & 0 deletions cmd/skopeo/copy_progress.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package main

import (
"fmt"
"io"
"os"
"time"

units "github.com/docker/go-units"
"go.podman.io/image/v5/types"
"golang.org/x/term"
)

func runProgressConsumer(ch <-chan types.ProgressProperties, interval time.Duration, out io.Writer) {
type blobState struct {
total int64
}
blobs := map[string]*blobState{}

for props := range ch {
dig := props.Artifact.Digest.Encoded()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Encoded does not include the algorithm , that’s inaccurate as a map key.


switch props.Event {
case types.ProgressEventNewArtifact:
blobs[dig] = &blobState{total: props.Artifact.Size}

case types.ProgressEventRead:
b := blobs[dig]
bytesPerSec := float64(props.OffsetUpdate) / interval.Seconds()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

??? interval.Seconds is a constant throughout the life of the function.

totalStr := "?"
if b != nil && b.total > 0 {
totalStr = units.BytesSize(float64(b.total))
}
// TODO: Make this prettier.
fmt.Fprintf(out, "%s %s %s / %s (%.1f MB/s)\n",
time.Now().UTC().Format(time.RFC3339), dig[:12], units.BytesSize(float64(props.Offset)), totalStr, bytesPerSec/1e6)
case types.ProgressEventDone:
if b, ok := blobs[dig]; ok {
fmt.Fprintf(out, "%s %s done (%s)\n",
time.Now().UTC().Format(time.RFC3339),
dig[:12],
units.BytesSize(float64(b.total)),
)
delete(blobs, dig)
}
case types.ProgressEventSkipped:
delete(blobs, dig)
}
}
}

func resolveProgressInterval(explicit time.Duration) time.Duration {
if explicit != 0 {
return explicit
}
if !term.IsTerminal(int(os.Stderr.Fd())) {
return 30 * time.Second
}
return 0 // Suppress for TTY
}
105 changes: 105 additions & 0 deletions cmd/skopeo/copy_progress_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package main

import (
"bytes"
"strings"
"testing"
"time"

digest "github.com/opencontainers/go-digest"
"github.com/stretchr/testify/assert"
"go.podman.io/image/v5/types"
)

func TestProgressConsumerDone(t *testing.T) {
ch := make(chan types.ProgressProperties, 8)
var out bytes.Buffer

fakeDigest := digest.FromString("test-blob")
fakeSize := int64(1024 * 1024 * 10) // 10 MB

ch <- types.ProgressProperties{
Event: types.ProgressEventNewArtifact,
Artifact: types.BlobInfo{Digest: fakeDigest, Size: fakeSize},
}
ch <- types.ProgressProperties{
Event: types.ProgressEventRead,
Artifact: types.BlobInfo{
Digest: fakeDigest,
Size: fakeSize,
},
Offset: uint64(fakeSize / 2),
OffsetUpdate: uint64(fakeSize / 2),
}
ch <- types.ProgressProperties{
Event: types.ProgressEventDone,
Artifact: types.BlobInfo{
Digest: fakeDigest,
Size: fakeSize,
},
Offset: uint64(fakeSize),
}
close(ch) // simulate close on copy finish

runProgressConsumer(ch, 30*time.Second, &out)

output := out.String()

assert.Contains(t, output, "done")
assert.Contains(t, output, fakeDigest.Encoded()[:12])
assert.Equal(t, 2, strings.Count(output, "\n"), "expected 2 lines: one read, one Done")
assert.Contains(t, output, "5MiB", "progress line should show current offset, not total")
}

func TestProgressConsumerSkipped(t *testing.T) {
ch := make(chan types.ProgressProperties, 4)
var out bytes.Buffer

fakeDigest := digest.FromString("cached-blob")

ch <- types.ProgressProperties{
Event: types.ProgressEventNewArtifact,
Artifact: types.BlobInfo{
Digest: fakeDigest,
Size: 1024,
},
}
ch <- types.ProgressProperties{
Event: types.ProgressEventSkipped,
Artifact: types.BlobInfo{Digest: fakeDigest},
}
close(ch)

runProgressConsumer(ch, 30*time.Second, &out)

assert.Empty(t, out.String(), "skipped blobs should produce no output")
}

func TestProgressConsumerUnknownSize(t *testing.T) {
ch := make(chan types.ProgressProperties, 4)
var out bytes.Buffer

fakeDigest := digest.FromString("mystery-blob")

ch <- types.ProgressProperties{
Event: types.ProgressEventNewArtifact,
Artifact: types.BlobInfo{
Digest: fakeDigest,
Size: -1,
},
}
ch <- types.ProgressProperties{
Event: types.ProgressEventRead,
Artifact: types.BlobInfo{
Digest: fakeDigest,
Size: -1,
},
Offset: 512,
OffsetUpdate: 512,
}
close(ch)

runProgressConsumer(ch, 30*time.Second, &out)

assert.Contains(t, out.String(), "?", "unknown size should render as '?'")
}
4 changes: 4 additions & 0 deletions docs/skopeo-copy.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,10 @@ The password to access the destination registry.

Maximum number of image layers to be copied (pulled/pushed) simultaneously. Not setting this field will fall back to containers/image defaults.

**--progress-interval** _duration_

Interval between progress log lines when stderr is not a TTY. Accepts Go duration strings such as `30s` or `1m`. Not setting this field defaults to 30s in non-TTY environments, and disabled in TTY environments.

## EXAMPLES

To just copy an image from one registry to another:
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ require (
github.com/Masterminds/semver/v3 v3.5.0
github.com/containers/ocicrypt v1.3.0
github.com/docker/distribution v2.8.3+incompatible
github.com/docker/go-units v0.5.0
github.com/moby/sys/capability v0.4.0
github.com/opencontainers/go-digest v1.0.0
github.com/opencontainers/image-spec v1.1.2-0.20260226102121-a4c6ade7bb82
Expand Down Expand Up @@ -44,7 +45,6 @@ require (
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/docker-credential-helpers v0.9.6 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
Expand Down