Skip to content
12 changes: 12 additions & 0 deletions pkg/cli/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,18 @@ func push(cmd *cobra.Command, args []string) error {

pushErr := resolver.Push(ctx, m, model.PushOptions{
ImageProgressFn: func(prog model.PushProgress) {
// Phase transitions: use console.Info for pretty CLI formatting
if prog.Phase != "" {
switch prog.Phase {
case model.PushPhaseExporting:
console.Infof("Exporting image from Docker daemon...")
case model.PushPhasePushing:
console.Infof("Pushing layers...")
}
return
}

// Byte progress: show per-layer progress bars
// Truncate digest for display: "sha256:abc123..." → "abc123..."
displayDigest := prog.LayerDigest
if len(displayDigest) > 7+12 { // "sha256:" + 12 hex chars
Expand Down
15 changes: 13 additions & 2 deletions pkg/model/image_pusher.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ func (p *ImagePusher) canOCIPush() bool {
func (p *ImagePusher) ociPush(ctx context.Context, imageRef string, opt imagePushOptions) error {
console.Debugf("Exporting image %s from Docker daemon...", imageRef)

if opt.progressFn != nil {
opt.progressFn(PushProgress{Phase: PushPhaseExporting})
}

ref, err := name.ParseReference(imageRef, name.Insecure)
if err != nil {
return fmt.Errorf("parse image reference %q: %w", imageRef, err)
Expand All @@ -129,8 +133,11 @@ func (p *ImagePusher) ociPush(ctx context.Context, imageRef string, opt imagePus
if err != nil {
return fmt.Errorf("create temp tar file: %w", err)
}
defer func() { _ = os.Remove(tmpTar.Name()) }() //nolint:gosec // G703: path from os.CreateTemp, not user input
defer tmpTar.Close() //nolint:errcheck

defer func() {
_ = tmpTar.Close()
_ = os.Remove(tmpTar.Name()) //nolint:gosec // G703: path from os.CreateTemp, not user input
}()

if _, err := io.Copy(tmpTar, rc); err != nil {
return fmt.Errorf("write image tar: %w", err)
Expand All @@ -151,6 +158,10 @@ func (p *ImagePusher) ociPush(ctx context.Context, imageRef string, opt imagePus
return fmt.Errorf("load image from tar: %w", err)
}

if opt.progressFn != nil {
opt.progressFn(PushProgress{Phase: PushPhasePushing})
}

return p.pushImage(ctx, imageRef, img, opt)
}

Expand Down
15 changes: 15 additions & 0 deletions pkg/model/image_pusher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,22 @@ func TestImagePusher_Push(t *testing.T) {
mu.Lock()
defer mu.Unlock()
assert.NotEmpty(t, progressUpdates)

// Verify phase transitions were reported
var phases []PushPhase
var byteUpdates []PushProgress
for _, p := range progressUpdates {
if p.Phase != "" {
phases = append(phases, p.Phase)
} else {
byteUpdates = append(byteUpdates, p)
}
}
assert.Equal(t, []PushPhase{PushPhaseExporting, PushPhasePushing}, phases)

// Verify byte progress was reported for layers
assert.NotEmpty(t, byteUpdates)
for _, p := range byteUpdates {
assert.NotEmpty(t, p.LayerDigest)
assert.True(t, p.Complete > 0)
assert.True(t, p.Total > 0)
Expand Down
30 changes: 28 additions & 2 deletions pkg/model/push_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,37 @@ func GetPushConcurrency() int {
return DefaultPushConcurrency
}

// PushProgress reports progress for a layer or blob upload.
// PushPhase represents a phase of the push process.
// The image pusher reports phase transitions so the CLI can display appropriate
// progress indicators (e.g., a status line during export, progress bars during push).
type PushPhase string

const (
// PushPhaseExporting indicates the image is being exported from the Docker
// daemon to a local tarball. This phase has no granular progress — the
// caller typically shows an indeterminate status indicator.
PushPhaseExporting PushPhase = "exporting"

// PushPhasePushing indicates layers are being pushed to the registry.
// During this phase, per-layer progress is reported via PushProgress callbacks.
PushPhasePushing PushPhase = "pushing"
)

// PushProgress reports progress for a push operation.
//
// There are two kinds of updates:
// - Phase transitions: Phase is set, byte fields are zero. Indicates the push
// has moved to a new phase (e.g., exporting image, pushing layers).
// - Byte progress: Phase is empty, Complete/Total track upload progress for
// a specific layer or blob identified by LayerDigest.
//
// Used by both ImagePusher (container image layers) and WeightPusher (weight blobs).
type PushProgress struct {
// Phase indicates a push phase transition. When set, this is a phase-only
// update and the byte progress fields should be ignored.
Phase PushPhase
// LayerDigest identifies which layer this progress is for.
// Empty for single-layer pushes (e.g., weight uploads).
// Empty for phase transitions and single-layer pushes (e.g., weight uploads).
LayerDigest string
// Complete is the number of bytes uploaded so far.
Complete int64
Expand Down
6 changes: 3 additions & 3 deletions pkg/model/pusher.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ type PushOptions struct {
// Default: linux/amd64
Platform *Platform

// ImageProgressFn is an optional callback for reporting per-layer upload progress
// during OCI chunked image push. Each call includes the layer digest, bytes
// completed, and total bytes.
// ImageProgressFn is an optional callback for reporting push progress.
// It receives both phase transitions (Phase set, byte fields zero) and
// per-layer byte progress (Phase empty, Complete/Total set).
ImageProgressFn func(PushProgress)

// OnFallback is called when OCI push fails and the push is about to fall
Expand Down
Loading