From 373de5c8fd62b6f578af776bb6b02bb7c398abc4 Mon Sep 17 00:00:00 2001 From: Dominik Schubert Date: Mon, 28 Sep 2026 12:27:16 +0200 Subject: [PATCH 1/2] Keep the image pull progress bar moving forward and honest (DEVX-1114) Co-Authored-By: Claude Opus 5.5 --- internal/ui/components/pull_progress.go | 247 ++++++++++++++---- .../components/pull_progress_replay_test.go | 95 +++++++ internal/ui/components/pull_progress_test.go | 215 ++++++++++++++- .../testdata/pull_containerd_python.jsonl | 70 +++++ .../testdata/pull_overlay2_synthetic.jsonl | 30 +++ test/integration/pull_progress_test.go | 139 ++++++++++ .../testdata/pull_containerd_python.jsonl | 70 +++++ 7 files changed, 814 insertions(+), 52 deletions(-) create mode 100644 internal/ui/components/pull_progress_replay_test.go create mode 100644 internal/ui/components/testdata/pull_containerd_python.jsonl create mode 100644 internal/ui/components/testdata/pull_overlay2_synthetic.jsonl create mode 100644 test/integration/pull_progress_test.go create mode 100644 test/integration/testdata/pull_containerd_python.jsonl diff --git a/internal/ui/components/pull_progress.go b/internal/ui/components/pull_progress.go index 4654a695..f2f4619c 100644 --- a/internal/ui/components/pull_progress.go +++ b/internal/ui/components/pull_progress.go @@ -2,6 +2,7 @@ package components import ( "fmt" + "strings" "github.com/charmbracelet/bubbles/progress" tea "github.com/charmbracelet/bubbletea" @@ -9,32 +10,81 @@ import ( "github.com/localstack/lstk/internal/ui/styles" ) +// Docker pull statuses the bar reacts to. The overlay2 and containerd image +// stores share these strings but differ in progressDetail; see testdata/. +const ( + statusPullingFsLayer = "Pulling fs layer" + statusWaiting = "Waiting" + statusDownloading = "Downloading" + statusDownloadComplete = "Download complete" + statusExtracting = "Extracting" + statusPullComplete = "Pull complete" + statusAlreadyExists = "Already exists" + statusRetryingPrefix = "Retrying in " + statusHeaderPrefix = "Pulling from " +) + +// Share of a layer's weight per phase. containerd reports no extraction +// progress, so its share only fills on "Pull complete". +const ( + downloadWeight = 0.8 + extractWeight = 1 - downloadWeight +) + +// epsilon absorbs float noise from summing layers in map order. +const epsilon = 1e-9 + +// layerState tracks one blob. Byte counters only grow: Docker sends +// status-only events without counts and restarts counts for extraction. type layerState struct { - status string - current int64 - total int64 - maxTotal int64 - complete bool + status string + // counted blobs appear in the bar and the layer counter. containerd's + // config/index blobs are never announced as layers and stay uncounted. + counted bool + unannounced bool // counted before Docker announced any layer + cached bool // "Already exists": no work in this pull + total int64 + downloaded int64 + extracted int64 + downloadDone bool + complete bool } type PullProgress struct { - bar progress.Model - layers map[string]*layerState - visible bool + bar progress.Model + layers map[string]*layerState + // announced is set once Docker announces any layer. Until then every blob + // that reports bytes is counted, for runtimes that never announce layers. + announced bool + // estimate is the last raw progress estimate; percent is what the bar + // shows. See advance. + estimate float64 + percent float64 + visible bool } -func NewPullProgress() PullProgress { +func newPullBar() progress.Model { bar := progress.New( progress.WithGradient(styles.NimboDarkColor, styles.NimboMidColor), progress.WithWidth(30), progress.WithFillCharacters('━', '─'), ) bar.EmptyColor = "#3A3A3A" - return PullProgress{bar: bar} + return bar } +func NewPullProgress() PullProgress { + return PullProgress{bar: newPullBar()} +} + +// Show starts a new pull. The bar is rebuilt so it does not animate down from +// the previous pull's value, and stale animation frames are dropped. func (p PullProgress) Show(imageName string) PullProgress { + p.bar = newPullBar() p.layers = make(map[string]*layerState) + p.announced = false + p.estimate = 0 + p.percent = 0 p.visible = true return p } @@ -46,7 +96,8 @@ func (p PullProgress) Hide() PullProgress { } func (p PullProgress) SetProgress(e output.ProgressEvent) (PullProgress, tea.Cmd) { - if e.LayerID == "" { + // Header ("Pulling from", id is the tag) and trailer lines are not blobs. + if e.LayerID == "" || strings.HasPrefix(e.Status, statusHeaderPrefix) { return p, nil } @@ -56,36 +107,135 @@ func (p PullProgress) SetProgress(e output.ProgressEvent) (PullProgress, tea.Cmd p.layers[e.LayerID] = layer } layer.status = e.Status - layer.current = e.Current - layer.total = e.Total - if e.Total > layer.maxTotal { - layer.maxTotal = e.Total - } - if e.Status == "Pull complete" || e.Status == "Already exists" { + + switch { + case e.Status == statusPullingFsLayer, e.Status == statusWaiting: + p.announce(layer) + case e.Status == statusDownloading: + layer.total = max(layer.total, e.Total) + layer.downloaded = max(layer.downloaded, min(e.Current, layer.total)) + if !p.announced { + layer.counted = true + layer.unannounced = true + } + case e.Status == statusDownloadComplete: + layer.downloadDone = true + // Without announcements there is no "Pull complete" to wait for. + layer.complete = layer.complete || layer.unannounced + case strings.HasPrefix(e.Status, statusRetryingPrefix): + layer.downloaded = 0 + layer.downloadDone = false + case e.Status == statusExtracting: + p.announce(layer) + layer.downloadDone = true + // overlay2 reports bytes; containerd reports seconds without a total. + if e.Total > 0 { + layer.total = max(layer.total, e.Total) + layer.extracted = max(layer.extracted, min(e.Current, layer.total)) + } + case e.Status == statusPullComplete: + p.announce(layer) + layer.downloadDone = true + layer.complete = true + case e.Status == statusAlreadyExists: + p.announce(layer) + layer.cached = true layer.complete = true } - pct := p.aggregatePercent() - cmd := p.bar.SetPercent(pct) - return p, cmd + prev := p.percent + p.advance(p.aggregatePercent()) + if p.percent <= prev { + return p, nil + } + return p, p.bar.SetPercent(p.percent) +} + +func (p *PullProgress) announce(l *layerState) { + l.counted = true + l.unannounced = false + p.announced = true +} + +// advance moves the shown percentage towards the new estimate without ever +// going backward or freezing. When a newly learned layer size drops the +// estimate below what is shown, later gains close the remaining distance +// proportionally, so the bar keeps moving and reaches 100% with the pull. +func (p *PullProgress) advance(estimate float64) { + if gain := estimate - p.estimate; gain > epsilon && p.estimate < 1 { + p.percent += (1 - p.percent) * gain / (1 - p.estimate) + } + p.percent = min(1, max(p.percent, estimate)) + p.estimate = estimate } +// aggregatePercent estimates progress over the counted, non-cached layers. +// Docker reports most sizes only once a download starts, so unknown sizes are +// guessed: a layer that finished without a size was tiny (weight 0), a pending +// one may be large (largest known size). func (p PullProgress) aggregatePercent() float64 { - var totalBytes, currentBytes int64 - for _, l := range p.layers { - if l.maxTotal > 0 { - totalBytes += l.maxTotal + var largest int64 + for _, l := range p.pending() { + largest = max(largest, l.total) + } + + var totalWeight, doneWeight float64 + for _, l := range p.pending() { + if largest == 0 { // no size known yet: only finished layers count + totalWeight++ if l.complete { - currentBytes += l.maxTotal - } else { - currentBytes += l.current + doneWeight++ } + continue + } + weight := float64(l.total) + switch { + case l.total > 0: + case l.downloadDone: + weight = 0 + default: + weight = float64(largest) + } + totalWeight += weight + doneWeight += weight * (downloadWeight*l.downloadFraction() + extractWeight*l.extractFraction()) + } + if totalWeight == 0 { + return 0 + } + return doneWeight / totalWeight +} + +// pending returns the layers this pull has to fetch. +func (p PullProgress) pending() []*layerState { + var out []*layerState + for _, l := range p.layers { + if l.counted && !l.cached { + out = append(out, l) } } - if totalBytes == 0 { + return out +} + +func (l *layerState) downloadFraction() float64 { + switch { + case l.downloadDone: + return 1 + case l.total > 0: + return float64(l.downloaded) / float64(l.total) + default: + return 0 + } +} + +func (l *layerState) extractFraction() float64 { + switch { + case l.complete: + return 1 + case l.total > 0: + return float64(l.extracted) / float64(l.total) + default: return 0 } - return float64(currentBytes) / float64(totalBytes) } func (p PullProgress) Update(msg tea.Msg) (PullProgress, tea.Cmd) { @@ -111,18 +261,18 @@ func (p PullProgress) View() string { return "" } - phase := p.dominantPhase() // Fixed-width: "Downloading" is the longest phase (11 chars), counts padded to "XX/XX" - label := fmt.Sprintf("%-11s %2d/%-2d layers", phase, done, total) + label := fmt.Sprintf("%-11s %2d/%-2d layers", p.phase(), done, total) return fmt.Sprintf(" %s %s", label, p.bar.View(), ) } +// layerCounts counts the same layers as the bar, so both agree. func (p PullProgress) layerCounts() (total, done int) { - total = len(p.layers) - for _, l := range p.layers { + for _, l := range p.pending() { + total++ if l.complete { done++ } @@ -130,21 +280,26 @@ func (p PullProgress) layerCounts() (total, done int) { return total, done } -func (p PullProgress) dominantPhase() string { - downloading, extracting := 0, 0 - for _, l := range p.layers { - switch l.status { - case "Downloading": - downloading++ - case "Extracting": - extracting++ +// phase names the furthest stage all layers have reached, so the label moves +// forward only: Pulling, Downloading, Extracting, Complete. +func (p PullProgress) phase() string { + started, downloading, extracting := false, false, false + for _, l := range p.pending() { + started = started || l.downloaded > 0 || l.downloadDone + if !l.downloadDone { + downloading = true + } else if !l.complete { + extracting = true } } - if extracting > downloading { - return "Extracting" - } - if downloading > 0 { + switch { + case !started: + return "Pulling" + case downloading: return "Downloading" + case extracting: + return "Extracting" + default: + return "Complete" } - return "Pulling" } diff --git a/internal/ui/components/pull_progress_replay_test.go b/internal/ui/components/pull_progress_replay_test.go new file mode 100644 index 00000000..5e00a0a6 --- /dev/null +++ b/internal/ui/components/pull_progress_replay_test.go @@ -0,0 +1,95 @@ +package components + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "strings" + "testing" + + "github.com/localstack/lstk/internal/output" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Fixtures: pull_containerd_python.jsonl is a real Docker Desktop 4.92 +// (containerd store) pull of python:3.13-slim; pull_overlay2_synthetic.jsonl +// is hand-written in the classic overlay2 format. +func pullStreamFixtures() []string { + return []string{ + "testdata/pull_containerd_python.jsonl", + "testdata/pull_overlay2_synthetic.jsonl", + } +} + +// loadPullStream decodes a Docker /images/create stream like +// runtime.DockerRuntime.PullImage does. +func loadPullStream(t *testing.T, path string) []output.ProgressEvent { + t.Helper() + f, err := os.Open(path) + require.NoError(t, err) + defer func() { _ = f.Close() }() + + var events []output.ProgressEvent + sc := bufio.NewScanner(f) + for sc.Scan() { + var msg struct { + Status string `json:"status"` + ID string `json:"id"` + ProgressDetail struct { + Current int64 `json:"current"` + Total int64 `json:"total"` + } `json:"progressDetail"` + } + require.NoError(t, json.Unmarshal(sc.Bytes(), &msg)) + events = append(events, output.ProgressEvent{ + LayerID: msg.ID, + Status: msg.Status, + Current: msg.ProgressDetail.Current, + Total: msg.ProgressDetail.Total, + }) + } + require.NoError(t, sc.Err()) + return events +} + +func TestPullProgress_ReplayBarNeverMovesBackwardOrFreezes(t *testing.T) { + for _, fixture := range pullStreamFixtures() { + t.Run(fixture, func(t *testing.T) { + p := NewPullProgress().Show("image") + shown, estimate := 0.0, 0.0 + var trajectory []string + for i, e := range loadPullStream(t, fixture) { + p, _ = p.SetProgress(e) + target := p.bar.Percent() // what the bar animates towards + if target < shown { + t.Errorf("event %d (%s %s): bar moved backward %.1f%% -> %.1f%%", + i, e.Status, e.LayerID, shown*100, target*100) + } + if next := p.aggregatePercent(); next > estimate+epsilon && target <= shown && shown < 1 { + t.Errorf("event %d (%s %s): estimate rose but bar stayed at %.1f%%", + i, e.Status, e.LayerID, shown*100) + } + shown, estimate = target, p.aggregatePercent() + trajectory = append(trajectory, fmt.Sprintf("%.0f", target*100)) + } + t.Logf("bar %%: %s", strings.Join(trajectory, " ")) + }) + } +} + +func TestPullProgress_ReplayEndsComplete(t *testing.T) { + for _, fixture := range pullStreamFixtures() { + t.Run(fixture, func(t *testing.T) { + p := NewPullProgress().Show("image") + for _, e := range loadPullStream(t, fixture) { + p, _ = p.SetProgress(e) + } + assert.InDelta(t, 1.0, p.bar.Percent(), 0.001) + total, done := p.layerCounts() + assert.Equal(t, total, done, "all counted layers should be done") + assert.Contains(t, p.View(), "Complete") + }) + } +} diff --git a/internal/ui/components/pull_progress_test.go b/internal/ui/components/pull_progress_test.go index 2ed0c7ef..3728c3eb 100644 --- a/internal/ui/components/pull_progress_test.go +++ b/internal/ui/components/pull_progress_test.go @@ -4,8 +4,10 @@ import ( "strings" "testing" + tea "github.com/charmbracelet/bubbletea" "github.com/localstack/lstk/internal/output" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestPullProgress_InitiallyHidden(t *testing.T) { @@ -32,17 +34,21 @@ func TestPullProgress_AggregatesMultipleLayers(t *testing.T) { p := NewPullProgress() p = p.Show("image") + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "aaa", Status: "Pulling fs layer"}) + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "bbb", Status: "Pulling fs layer"}) p, _ = p.SetProgress(output.ProgressEvent{LayerID: "aaa", Status: "Downloading", Current: 50, Total: 100}) p, _ = p.SetProgress(output.ProgressEvent{LayerID: "bbb", Status: "Downloading", Current: 25, Total: 100}) - pct := p.aggregatePercent() - assert.InDelta(t, 0.375, pct, 0.001) + // 75 of 200 bytes downloaded, nothing extracted yet. + assert.InDelta(t, downloadWeight*0.375, p.aggregatePercent(), 0.001) } func TestPullProgress_ViewShowsLayerCounts(t *testing.T) { p := NewPullProgress() p = p.Show("image") + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "bbb", Status: "Pulling fs layer"}) + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "ccc", Status: "Pulling fs layer"}) p, _ = p.SetProgress(output.ProgressEvent{LayerID: "aaa", Status: "Pull complete", Current: 0, Total: 0}) p, _ = p.SetProgress(output.ProgressEvent{LayerID: "bbb", Status: "Downloading", Current: 50, Total: 100}) p, _ = p.SetProgress(output.ProgressEvent{LayerID: "ccc", Status: "Downloading", Current: 10, Total: 100}) @@ -70,16 +76,36 @@ func TestPullProgress_DominantPhase(t *testing.T) { assert.True(t, strings.Contains(p.View(), "Extracting")) } -func TestPullProgress_AlreadyExistsCountsAsDone(t *testing.T) { +func TestPullProgress_PhaseOnlyMovesForward(t *testing.T) { + p := NewPullProgress().Show("image") + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "small", Status: "Pulling fs layer"}) + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "big", Status: "Pulling fs layer"}) + assert.Contains(t, p.View(), "Pulling") + + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "big", Status: "Downloading", Current: 1, Total: 1000}) + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "small", Status: "Download complete"}) + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "small", Status: "Extracting", Current: 1}) + assert.Contains(t, p.View(), "Downloading", "a big layer is still downloading") + + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "big", Status: "Download complete"}) + assert.Contains(t, p.View(), "Extracting") + + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "small", Status: "Pull complete"}) + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "big", Status: "Pull complete"}) + assert.Contains(t, p.View(), "Complete") +} + +func TestPullProgress_AlreadyExistsLayersAreNotCounted(t *testing.T) { p := NewPullProgress() p = p.Show("image") p, _ = p.SetProgress(output.ProgressEvent{LayerID: "aaa", Status: "Already exists"}) + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "bbb", Status: "Pulling fs layer"}) p, _ = p.SetProgress(output.ProgressEvent{LayerID: "bbb", Status: "Downloading", Current: 50, Total: 100}) - view := p.View() - assert.Contains(t, view, "1/2") - assert.Contains(t, view, "layers") + // The counter and the bar cover the same layers: only what is pulled. + assert.Contains(t, p.View(), "0/1") + assert.InDelta(t, downloadWeight*0.5, p.aggregatePercent(), 0.001) } func TestPullProgress_ShowResetsLayers(t *testing.T) { @@ -99,3 +125,180 @@ func TestPullProgress_IgnoresEmptyLayerID(t *testing.T) { p, _ = p.SetProgress(output.ProgressEvent{LayerID: "", Status: "Pulling from library/localstack"}) assert.Equal(t, 0, len(p.layers)) } + +// The tests below check the raw estimate, not the clamped display, so they +// fail if the per-layer handling regresses. + +func TestPullProgress_StatusOnlyEventsDoNotResetBytes(t *testing.T) { + p := NewPullProgress().Show("image") + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "aaa", Status: "Pulling fs layer"}) + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "aaa", Status: "Downloading", Current: 100, Total: 100}) + before := p.aggregatePercent() + + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "aaa", Status: "Verifying Checksum"}) + assert.InDelta(t, before, p.aggregatePercent(), 0.001) + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "aaa", Status: "Download complete"}) + assert.InDelta(t, downloadWeight, p.aggregatePercent(), 0.001, "download done, extraction pending") +} + +func TestPullProgress_ExtractionRestartDoesNotMoveBackward(t *testing.T) { + p := NewPullProgress().Show("image") + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "aaa", Status: "Pulling fs layer"}) + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "aaa", Status: "Downloading", Current: 100, Total: 100}) + before := p.aggregatePercent() + + // overlay2: extraction bytes restart at 0. + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "aaa", Status: "Extracting", Current: 10, Total: 100}) + assert.GreaterOrEqual(t, p.aggregatePercent(), before) + // containerd: extraction reports elapsed seconds without a total. + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "aaa", Status: "Extracting", Current: 1}) + assert.GreaterOrEqual(t, p.aggregatePercent(), before) + + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "aaa", Status: "Pull complete"}) + assert.InDelta(t, 1.0, p.aggregatePercent(), 0.001) +} + +func TestPullProgress_ExtractionUsesItsOwnTotal(t *testing.T) { + p := NewPullProgress().Show("image") + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "aaa", Status: "Pulling fs layer"}) + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "aaa", Status: "Download complete"}) + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "aaa", Status: "Extracting", Current: 5, Total: 10}) + + assert.InDelta(t, downloadWeight+extractWeight*0.5, p.aggregatePercent(), 0.001) +} + +func TestPullProgress_RetryRestartsLayerDownload(t *testing.T) { + p := NewPullProgress().Show("image") + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "aaa", Status: "Pulling fs layer"}) + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "aaa", Status: "Downloading", Current: 80, Total: 100}) + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "aaa", Status: "Retrying in 5 seconds"}) + assert.InDelta(t, 0.0, p.aggregatePercent(), 0.001) + + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "aaa", Status: "Downloading", Current: 10, Total: 100}) + assert.InDelta(t, downloadWeight*0.1, p.aggregatePercent(), 0.001) +} + +func TestPullProgress_SmallUnsizedLayerDoesNotInflateProgress(t *testing.T) { + p := NewPullProgress().Show("image") + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "small", Status: "Pulling fs layer"}) + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "big", Status: "Pulling fs layer"}) + // containerd finishes small blobs without ever reporting their size. + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "small", Status: "Download complete"}) + assert.InDelta(t, 0.0, p.percent, 0.001, "nothing is known about the pull size yet") + + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "big", Status: "Downloading", Current: 1, Total: 1000}) + assert.Less(t, p.percent, 0.01) +} + +func TestPullProgress_BarKeepsMovingAfterLargeLayerSizeAppears(t *testing.T) { + p := NewPullProgress().Show("image") + for _, id := range []string{"a", "b", "c", "big"} { + p, _ = p.SetProgress(output.ProgressEvent{LayerID: id, Status: "Pulling fs layer"}) + } + for _, id := range []string{"a", "b", "c"} { + p, _ = p.SetProgress(output.ProgressEvent{LayerID: id, Status: "Downloading", Current: 1, Total: 1}) + p, _ = p.SetProgress(output.ProgressEvent{LayerID: id, Status: "Pull complete"}) + } + + // big's size is learned only now and dwarfs the rest: the estimate drops. + prev := p.percent + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "big", Status: "Downloading", Current: 1, Total: 1000}) + assert.Less(t, p.aggregatePercent(), prev, "raw estimate drops") + assert.GreaterOrEqual(t, p.percent, prev, "display never moves backward") + + for current := int64(100); current <= 1000; current += 100 { + prev = p.percent + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "big", Status: "Downloading", Current: current, Total: 1000}) + assert.Greater(t, p.percent, prev, "display keeps moving at %d/1000", current) + } + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "big", Status: "Pull complete"}) + assert.InDelta(t, 1.0, p.percent, 0.001) +} + +func TestPullProgress_HeaderAndConfigBlobsAreNotLayers(t *testing.T) { + p := NewPullProgress().Show("image") + // The header line carries the tag as its id. + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "latest", Status: "Pulling from localstack/localstack-pro"}) + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "aaa", Status: "Pulling fs layer"}) + // containerd config blob: downloaded, never announced or extracted. + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "cfg", Status: "Downloading", Current: 5, Total: 10}) + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "cfg", Status: "Download complete"}) + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "aaa", Status: "Pull complete"}) + + assert.Contains(t, p.View(), "1/1") + assert.InDelta(t, 1.0, p.percent, 0.001) +} + +func TestPullProgress_UnannouncedBlobsCountWhenNoLayerIsAnnounced(t *testing.T) { + p := NewPullProgress().Show("image") + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "aaa", Status: "Downloading", Current: 50, Total: 100}) + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "bbb", Status: "Downloading", Current: 50, Total: 100}) + assert.Contains(t, p.View(), "0/2") + assert.InDelta(t, downloadWeight*0.5, p.aggregatePercent(), 0.001) + + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "aaa", Status: "Download complete"}) + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "bbb", Status: "Download complete"}) + assert.Contains(t, p.View(), "2/2") + assert.InDelta(t, 1.0, p.percent, 0.001) +} + +func TestPullProgress_LateAnnouncementKeepsCountedBlobs(t *testing.T) { + p := NewPullProgress().Show("image") + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "aaa", Status: "Downloading", Current: 10, Total: 100}) + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "bbb", Status: "Downloading", Current: 10, Total: 100}) + // A later event announces layers; already counted blobs must stay counted, + // or the denominator shrinks and the bar jumps ahead. + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "ccc", Status: "Already exists"}) + + assert.Contains(t, p.View(), "0/2") + assert.InDelta(t, downloadWeight*0.1, p.aggregatePercent(), 0.001) +} + +func TestPullProgress_SizelessBlobBeforeAnnouncementIsIgnored(t *testing.T) { + p := NewPullProgress().Show("image") + // containerd may report the index before announcing layers. + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "idx", Status: "Download complete"}) + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "aaa", Status: "Pulling fs layer"}) + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "aaa", Status: "Downloading", Current: 40, Total: 100}) + + assert.Contains(t, p.View(), "0/1") + assert.InDelta(t, downloadWeight*0.4, p.percent, 0.001) +} + +func TestPullProgress_ShowResetsPercent(t *testing.T) { + p := NewPullProgress().Show("image-a") + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "aaa", Status: "Pulling fs layer"}) + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "aaa", Status: "Downloading", Current: 50, Total: 100}) + assert.Greater(t, p.percent, 0.0) + + p = p.Show("image-b") + assert.InDelta(t, 0.0, p.percent, 0.001) +} + +// runFrames plays the bar's animation until cmd stops scheduling frames. +func runFrames(t *testing.T, p PullProgress, cmd tea.Cmd) PullProgress { + t.Helper() + for i := 0; cmd != nil && i < 200; i++ { + p, cmd = p.Update(cmd()) + } + require.Nil(t, cmd, "animation did not settle") + return p +} + +func TestPullProgress_SecondPullStartsBarFromZero(t *testing.T) { + p := NewPullProgress().Show("image-a") + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "aaa", Status: "Pulling fs layer"}) + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "aaa", Status: "Downloading", Current: 100, Total: 100}) + p, cmd := p.SetProgress(output.ProgressEvent{LayerID: "aaa", Status: "Pull complete"}) + p = runFrames(t, p, cmd) + require.Contains(t, p.bar.View(), "100%") + p = p.Hide() + + p = p.Show("image-b") + p, _ = p.SetProgress(output.ProgressEvent{LayerID: "bbb", Status: "Pulling fs layer"}) + p, cmd = p.SetProgress(output.ProgressEvent{LayerID: "bbb", Status: "Downloading", Current: 10, Total: 100}) + // The first frames of the new pull must not animate down from 100%. + p, _ = p.Update(cmd()) + assert.NotContains(t, p.bar.View(), "100%") + assert.Contains(t, p.bar.View(), " 0%") +} diff --git a/internal/ui/components/testdata/pull_containerd_python.jsonl b/internal/ui/components/testdata/pull_containerd_python.jsonl new file mode 100644 index 00000000..bbd16af6 --- /dev/null +++ b/internal/ui/components/testdata/pull_containerd_python.jsonl @@ -0,0 +1,70 @@ +{"status":"Pulling from library/python","id":"3.13-slim"} +{"status":"Pulling fs layer","progressDetail":{},"id":"0fb3db4aef9a"} +{"status":"Pulling fs layer","progressDetail":{},"id":"123abebbace7"} +{"status":"Pulling fs layer","progressDetail":{},"id":"bd36565c0fde"} +{"status":"Pulling fs layer","progressDetail":{},"id":"4b83fbae47dd"} +{"status":"Downloading","progressDetail":{"current":1048576,"total":2165861},"id":"d3624ea90579"} +{"status":"Download complete","progressDetail":{"hidecounts":true},"id":"55e5012f3959"} +{"status":"Download complete","progressDetail":{"hidecounts":true},"id":"d3624ea90579"} +{"status":"Download complete","progressDetail":{"hidecounts":true},"id":"0fb3db4aef9a"} +{"status":"Downloading","progressDetail":{"current":1048576,"total":11833485},"id":"4b83fbae47dd"} +{"status":"Downloading","progressDetail":{"current":1048576,"total":11833485},"id":"4b83fbae47dd"} +{"status":"Download complete","progressDetail":{"hidecounts":true},"id":"123abebbace7"} +{"status":"Downloading","progressDetail":{"current":2097152,"total":11833485},"id":"4b83fbae47dd"} +{"status":"Downloading","progressDetail":{"current":3145728,"total":11833485},"id":"4b83fbae47dd"} +{"status":"Downloading","progressDetail":{"current":4194304,"total":11833485},"id":"4b83fbae47dd"} +{"status":"Downloading","progressDetail":{"current":5242880,"total":11833485},"id":"4b83fbae47dd"} +{"status":"Downloading","progressDetail":{"current":6291456,"total":11833485},"id":"4b83fbae47dd"} +{"status":"Downloading","progressDetail":{"current":7340032,"total":11833485},"id":"4b83fbae47dd"} +{"status":"Downloading","progressDetail":{"current":7340032,"total":11833485},"id":"4b83fbae47dd"} +{"status":"Downloading","progressDetail":{"current":1048576,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":1048576,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":8388608,"total":11833485},"id":"4b83fbae47dd"} +{"status":"Downloading","progressDetail":{"current":10485760,"total":11833485},"id":"4b83fbae47dd"} +{"status":"Downloading","progressDetail":{"current":1048576,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":10485760,"total":11833485},"id":"4b83fbae47dd"} +{"status":"Downloading","progressDetail":{"current":1048576,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":1048576,"total":30189691},"id":"bd36565c0fde"} +{"status":"Download complete","progressDetail":{"hidecounts":true},"id":"4b83fbae47dd"} +{"status":"Downloading","progressDetail":{"current":2097152,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":2097152,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":2097152,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":3145728,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":3145728,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":4194304,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":4194304,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":5242880,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":6291456,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":7340032,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":8388608,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":9437184,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":10485760,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":11534336,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":12582912,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":13631488,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":14680064,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":15728640,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":16777216,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":17825792,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":18874368,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":19922944,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":20971520,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":22020096,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":23068672,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":24117248,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":26214400,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":27262976,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":28311552,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":29360128,"total":30189691},"id":"bd36565c0fde"} +{"status":"Download complete","progressDetail":{"hidecounts":true},"id":"bd36565c0fde"} +{"status":"Extracting","progressDetail":{"current":1,"units":"s"},"id":"bd36565c0fde"} +{"status":"Extracting","progressDetail":{"current":1,"units":"s"},"id":"bd36565c0fde"} +{"status":"Extracting","progressDetail":{"current":1,"units":"s"},"id":"bd36565c0fde"} +{"status":"Extracting","progressDetail":{"current":1,"units":"s"},"id":"123abebbace7"} +{"status":"Pull complete","progressDetail":{"hidecounts":true},"id":"bd36565c0fde"} +{"status":"Pull complete","progressDetail":{"hidecounts":true},"id":"123abebbace7"} +{"status":"Extracting","progressDetail":{"current":1,"units":"s"},"id":"4b83fbae47dd"} +{"status":"Pull complete","progressDetail":{"hidecounts":true},"id":"0fb3db4aef9a"} +{"status":"Pull complete","progressDetail":{"hidecounts":true},"id":"4b83fbae47dd"} +{"status":"Digest: sha256:7c61056e61ac89e852de05f3dc6fa51a6dd2181797bceed46aa725dd7cb2cd3b"} +{"status":"Status: Downloaded newer image for python:3.13-slim"} diff --git a/internal/ui/components/testdata/pull_overlay2_synthetic.jsonl b/internal/ui/components/testdata/pull_overlay2_synthetic.jsonl new file mode 100644 index 00000000..fd5f25e5 --- /dev/null +++ b/internal/ui/components/testdata/pull_overlay2_synthetic.jsonl @@ -0,0 +1,30 @@ +{"status":"Pulling from localstack/localstack-pro","id":"latest"} +{"status":"Already exists","progressDetail":{},"id":"aaaa00000001"} +{"status":"Pulling fs layer","progressDetail":{},"id":"bbbb00000002"} +{"status":"Pulling fs layer","progressDetail":{},"id":"cccc00000003"} +{"status":"Pulling fs layer","progressDetail":{},"id":"dddd00000004"} +{"status":"Waiting","progressDetail":{},"id":"cccc00000003"} +{"status":"Waiting","progressDetail":{},"id":"dddd00000004"} +{"status":"Downloading","progressDetail":{"current":1000,"total":10000},"progress":"","id":"bbbb00000002"} +{"status":"Downloading","progressDetail":{"current":5000,"total":10000},"progress":"","id":"bbbb00000002"} +{"status":"Downloading","progressDetail":{"current":10000,"total":10000},"progress":"","id":"bbbb00000002"} +{"status":"Verifying Checksum","progressDetail":{},"id":"bbbb00000002"} +{"status":"Download complete","progressDetail":{},"id":"bbbb00000002"} +{"status":"Downloading","progressDetail":{"current":2000,"total":100000},"progress":"","id":"cccc00000003"} +{"status":"Extracting","progressDetail":{"current":1000,"total":10000},"progress":"","id":"bbbb00000002"} +{"status":"Downloading","progressDetail":{"current":40000,"total":100000},"progress":"","id":"cccc00000003"} +{"status":"Extracting","progressDetail":{"current":10000,"total":10000},"progress":"","id":"bbbb00000002"} +{"status":"Pull complete","progressDetail":{},"id":"bbbb00000002"} +{"status":"Downloading","progressDetail":{"current":100000,"total":100000},"progress":"","id":"cccc00000003"} +{"status":"Verifying Checksum","progressDetail":{},"id":"cccc00000003"} +{"status":"Download complete","progressDetail":{},"id":"cccc00000003"} +{"status":"Downloading","progressDetail":{"current":500,"total":5000},"progress":"","id":"dddd00000004"} +{"status":"Extracting","progressDetail":{"current":10000,"total":100000},"progress":"","id":"cccc00000003"} +{"status":"Downloading","progressDetail":{"current":5000,"total":5000},"progress":"","id":"dddd00000004"} +{"status":"Download complete","progressDetail":{},"id":"dddd00000004"} +{"status":"Extracting","progressDetail":{"current":100000,"total":100000},"progress":"","id":"cccc00000003"} +{"status":"Pull complete","progressDetail":{},"id":"cccc00000003"} +{"status":"Extracting","progressDetail":{"current":5000,"total":5000},"progress":"","id":"dddd00000004"} +{"status":"Pull complete","progressDetail":{},"id":"dddd00000004"} +{"status":"Digest: sha256:0000000000000000000000000000000000000000000000000000000000000000"} +{"status":"Status: Downloaded newer image for localstack/localstack-pro:latest"} diff --git a/test/integration/pull_progress_test.go b/test/integration/pull_progress_test.go new file mode 100644 index 00000000..60ab9f8a --- /dev/null +++ b/test/integration/pull_progress_test.go @@ -0,0 +1,139 @@ +package integration_test + +import ( + "bufio" + "bytes" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/localstack/lstk/test/integration/env" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeDockerPullServer is a minimal Docker Engine API that reports no +// containers or images and answers POST /images/create by streaming a +// recorded pull, so the TUI pull progress can be tested without a daemon. +type fakeDockerPullServer struct { + *httptest.Server + mu sync.Mutex + pulled bool +} + +var apiVersionPrefix = regexp.MustCompile(`^/v[0-9.]+`) + +func newFakeDockerPullServer(t *testing.T, stream []byte, delay time.Duration) *fakeDockerPullServer { + t.Helper() + f := &fakeDockerPullServer{} + f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := apiVersionPrefix.ReplaceAllString(r.URL.Path, "") + w.Header().Set("API-Version", "1.52") + w.Header().Set("OSType", "linux") + switch { + case path == "/_ping": + _, _ = w.Write([]byte("OK")) + case path == "/version": + _, _ = w.Write([]byte(`{"ApiVersion":"1.52","MinAPIVersion":"1.24","Version":"29.0.0","Os":"linux","Arch":"arm64"}`)) + case path == "/info": + _, _ = w.Write([]byte(`{"Name":"fake","OSType":"linux","OperatingSystem":"fake"}`)) + case path == "/containers/json", path == "/images/json": + _, _ = w.Write([]byte(`[]`)) + case path == "/images/create" && r.Method == http.MethodPost: + f.mu.Lock() + f.pulled = true + f.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + flusher, _ := w.(http.Flusher) + sc := bufio.NewScanner(bytes.NewReader(stream)) + for sc.Scan() { + _, _ = w.Write(append(sc.Bytes(), '\n')) + if flusher != nil { + flusher.Flush() + } + time.Sleep(delay) + } + default: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = fmt.Fprintf(w, `{"message":"fake docker: no such object: %s"}`, path) + } + })) + t.Cleanup(f.Close) + return f +} + +func (f *fakeDockerPullServer) wasPulled() bool { + f.mu.Lock() + defer f.mu.Unlock() + return f.pulled +} + +func freeTCPPort(t *testing.T) string { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer func() { _ = l.Close() }() + return strconv.Itoa(l.Addr().(*net.TCPAddr).Port) +} + +// renderedPullBar matches one rendered frame of the pull progress line, e.g. +// "Downloading 2/4 layers ━━━━─── 42%". +var renderedPullBar = regexp.MustCompile(`(\d+)/(\d+)\s+layers\s+[━─]+\s+(\d+)%`) + +// TestStartPullProgressNeverMovesBackward replays a real Docker Desktop pull +// through `lstk start` and checks every rendered frame: the bar must never +// move backward and the layer counter must only count real layers (DEVX-1114). +func TestStartPullProgressNeverMovesBackward(t *testing.T) { + t.Parallel() + + stream, err := os.ReadFile(filepath.Join("testdata", "pull_containerd_python.jsonl")) + require.NoError(t, err) + docker := newFakeDockerPullServer(t, stream, 30*time.Millisecond) + + configFile := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configFile, []byte(fmt.Sprintf(` +[[containers]] +type = "aws" +tag = "latest" +port = %q +image = "lstk-pull-progress-fixture" +`, freeTCPPort(t))), 0644)) + + // The "latest" tag defers the license check until after the pull; the + // unreachable API endpoint then ends the run once the pull has rendered. + e := env.Environ(testEnvWithHome(t.TempDir(), "")). + With(env.AuthToken, "dummy-token"). + With(env.APIEndpoint, "http://127.0.0.1:1"). + With(env.DisableEvents, "1") + e = append(e, "DOCKER_HOST=tcp://"+strings.TrimPrefix(docker.URL, "http://")) + + out, _ := runLstkInPTY(t, testContext(t), e, "start", "--config", configFile) + require.True(t, docker.wasPulled(), "lstk never requested the pull; output:\n%s", out) + + frames := renderedPullBar.FindAllStringSubmatch(out, -1) + require.GreaterOrEqual(t, len(frames), 5, "expected several rendered progress frames; output:\n%s", out) + + // The fixture announces 4 layers; its config/index blobs must not count. + prev := 0 + for i, f := range frames { + layers, err := strconv.Atoi(f[2]) + require.NoError(t, err) + assert.LessOrEqual(t, layers, 4, "frame %d counts non-layer blobs: %q", i, f[0]) + pct, err := strconv.Atoi(f[3]) + require.NoError(t, err) + assert.GreaterOrEqual(t, pct, prev, "frame %d moved backward: %q", i, f[0]) + prev = pct + } + last := frames[len(frames)-1] + assert.Equal(t, "4/4", last[1]+"/"+last[2], "pull should end with all layers done: %q", last[0]) +} diff --git a/test/integration/testdata/pull_containerd_python.jsonl b/test/integration/testdata/pull_containerd_python.jsonl new file mode 100644 index 00000000..bbd16af6 --- /dev/null +++ b/test/integration/testdata/pull_containerd_python.jsonl @@ -0,0 +1,70 @@ +{"status":"Pulling from library/python","id":"3.13-slim"} +{"status":"Pulling fs layer","progressDetail":{},"id":"0fb3db4aef9a"} +{"status":"Pulling fs layer","progressDetail":{},"id":"123abebbace7"} +{"status":"Pulling fs layer","progressDetail":{},"id":"bd36565c0fde"} +{"status":"Pulling fs layer","progressDetail":{},"id":"4b83fbae47dd"} +{"status":"Downloading","progressDetail":{"current":1048576,"total":2165861},"id":"d3624ea90579"} +{"status":"Download complete","progressDetail":{"hidecounts":true},"id":"55e5012f3959"} +{"status":"Download complete","progressDetail":{"hidecounts":true},"id":"d3624ea90579"} +{"status":"Download complete","progressDetail":{"hidecounts":true},"id":"0fb3db4aef9a"} +{"status":"Downloading","progressDetail":{"current":1048576,"total":11833485},"id":"4b83fbae47dd"} +{"status":"Downloading","progressDetail":{"current":1048576,"total":11833485},"id":"4b83fbae47dd"} +{"status":"Download complete","progressDetail":{"hidecounts":true},"id":"123abebbace7"} +{"status":"Downloading","progressDetail":{"current":2097152,"total":11833485},"id":"4b83fbae47dd"} +{"status":"Downloading","progressDetail":{"current":3145728,"total":11833485},"id":"4b83fbae47dd"} +{"status":"Downloading","progressDetail":{"current":4194304,"total":11833485},"id":"4b83fbae47dd"} +{"status":"Downloading","progressDetail":{"current":5242880,"total":11833485},"id":"4b83fbae47dd"} +{"status":"Downloading","progressDetail":{"current":6291456,"total":11833485},"id":"4b83fbae47dd"} +{"status":"Downloading","progressDetail":{"current":7340032,"total":11833485},"id":"4b83fbae47dd"} +{"status":"Downloading","progressDetail":{"current":7340032,"total":11833485},"id":"4b83fbae47dd"} +{"status":"Downloading","progressDetail":{"current":1048576,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":1048576,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":8388608,"total":11833485},"id":"4b83fbae47dd"} +{"status":"Downloading","progressDetail":{"current":10485760,"total":11833485},"id":"4b83fbae47dd"} +{"status":"Downloading","progressDetail":{"current":1048576,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":10485760,"total":11833485},"id":"4b83fbae47dd"} +{"status":"Downloading","progressDetail":{"current":1048576,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":1048576,"total":30189691},"id":"bd36565c0fde"} +{"status":"Download complete","progressDetail":{"hidecounts":true},"id":"4b83fbae47dd"} +{"status":"Downloading","progressDetail":{"current":2097152,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":2097152,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":2097152,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":3145728,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":3145728,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":4194304,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":4194304,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":5242880,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":6291456,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":7340032,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":8388608,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":9437184,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":10485760,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":11534336,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":12582912,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":13631488,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":14680064,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":15728640,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":16777216,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":17825792,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":18874368,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":19922944,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":20971520,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":22020096,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":23068672,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":24117248,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":26214400,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":27262976,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":28311552,"total":30189691},"id":"bd36565c0fde"} +{"status":"Downloading","progressDetail":{"current":29360128,"total":30189691},"id":"bd36565c0fde"} +{"status":"Download complete","progressDetail":{"hidecounts":true},"id":"bd36565c0fde"} +{"status":"Extracting","progressDetail":{"current":1,"units":"s"},"id":"bd36565c0fde"} +{"status":"Extracting","progressDetail":{"current":1,"units":"s"},"id":"bd36565c0fde"} +{"status":"Extracting","progressDetail":{"current":1,"units":"s"},"id":"bd36565c0fde"} +{"status":"Extracting","progressDetail":{"current":1,"units":"s"},"id":"123abebbace7"} +{"status":"Pull complete","progressDetail":{"hidecounts":true},"id":"bd36565c0fde"} +{"status":"Pull complete","progressDetail":{"hidecounts":true},"id":"123abebbace7"} +{"status":"Extracting","progressDetail":{"current":1,"units":"s"},"id":"4b83fbae47dd"} +{"status":"Pull complete","progressDetail":{"hidecounts":true},"id":"0fb3db4aef9a"} +{"status":"Pull complete","progressDetail":{"hidecounts":true},"id":"4b83fbae47dd"} +{"status":"Digest: sha256:7c61056e61ac89e852de05f3dc6fa51a6dd2181797bceed46aa725dd7cb2cd3b"} +{"status":"Status: Downloaded newer image for python:3.13-slim"} From 9a623fca8795fca75e7f7bdf6828dd593b1d22d1 Mon Sep 17 00:00:00 2001 From: Dominik Schubert Date: Wed, 30 Sep 2026 20:57:46 +0200 Subject: [PATCH 2/2] Run the pull progress integration test serially Co-Authored-By: Claude --- test/integration/pull_progress_test.go | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/test/integration/pull_progress_test.go b/test/integration/pull_progress_test.go index 60ab9f8a..a2026249 100644 --- a/test/integration/pull_progress_test.go +++ b/test/integration/pull_progress_test.go @@ -86,6 +86,19 @@ func freeTCPPort(t *testing.T) string { return strconv.Itoa(l.Addr().(*net.TCPAddr).Port) } +// requireServicePortsFree fails early when LocalStack's service port range is +// taken, which would otherwise stop `lstk start` before it ever pulls. +func requireServicePortsFree(t *testing.T) { + t.Helper() + for p := 4510; p <= 4559; p++ { + conn, err := net.DialTimeout("tcp", "localhost:"+strconv.Itoa(p), time.Second) + if err == nil { + _ = conn.Close() + t.Fatalf("port %d is in use (is LocalStack running?); stop it before running this test", p) + } + } +} + // renderedPullBar matches one rendered frame of the pull progress line, e.g. // "Downloading 2/4 layers ━━━━─── 42%". var renderedPullBar = regexp.MustCompile(`(\d+)/(\d+)\s+layers\s+[━─]+\s+(\d+)%`) @@ -93,8 +106,11 @@ var renderedPullBar = regexp.MustCompile(`(\d+)/(\d+)\s+layers\s+[━─]+\s+(\d // TestStartPullProgressNeverMovesBackward replays a real Docker Desktop pull // through `lstk start` and checks every rendered frame: the bar must never // move backward and the layer counter must only count real layers (DEVX-1114). +// +// Not parallel: `lstk start` requires the fixed service port range 4510-4559, +// which the Docker-backed tests' LocalStack containers also bind. func TestStartPullProgressNeverMovesBackward(t *testing.T) { - t.Parallel() + requireServicePortsFree(t) stream, err := os.ReadFile(filepath.Join("testdata", "pull_containerd_python.jsonl")) require.NoError(t, err)