+12








fcd7c979df
Co-authored-by: Artur <145776561+arturict@users.noreply.github.com> Co-authored-by: arturict <arturict@users.noreply.github.com> Co-authored-by: Thomas <thomas@ajnart.fr> Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com> Co-authored-by: Danura30082 <danura30082+github@gmail.com> Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com> Co-authored-by: ajnart <thomas@ajnart.fr> Co-authored-by: ajnart <thomas@ajnart.dev> Co-authored-by: chenjun <chenjun@foxmail.com> Co-authored-by: Wikibear <wikibear@users.noreply.github.com> Co-authored-by: Bergasha <bergasha@gmail.com> Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com> Co-authored-by: Lucas Araujo <lucas.lra@gmail.com> Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com> Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social> Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com> Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com> Co-authored-by: thrawnx <tight.bulb9384@fastmail.com> Co-authored-by: Chris <58918656+nordada@users.noreply.github.com> Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com> Co-authored-by: Yanis <naamaneyanis@gmail.com> Co-authored-by: Wikibear <me@sej.de> Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
411 lines
11 KiB
Go
411 lines
11 KiB
Go
package docker
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/x/ansi"
|
|
)
|
|
|
|
// Layer is the download and extraction state of a single image layer.
|
|
type Layer struct {
|
|
ID string
|
|
Status string
|
|
Current int64
|
|
Total int64
|
|
}
|
|
|
|
// Fraction reports how far this layer is towards being usable, treating
|
|
// download and extraction as two halves of the work. Docker restarts the byte
|
|
// counters between the two phases, so they are weighted rather than summed.
|
|
func (l Layer) Fraction() float64 {
|
|
switch normalizeLayerStatus(l.Status) {
|
|
case "already exists", "pull complete":
|
|
return 1
|
|
case "downloading":
|
|
return 0.5 * ratio(l.Current, l.Total)
|
|
case "verifying checksum", "download complete":
|
|
return 0.5
|
|
case "extracting":
|
|
return 0.5 + 0.5*ratio(l.Current, l.Total)
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func (l Layer) Done() bool {
|
|
status := normalizeLayerStatus(l.Status)
|
|
return status == "pull complete" || status == "already exists"
|
|
}
|
|
|
|
func ratio(current, total int64) float64 {
|
|
if total <= 0 {
|
|
return 0
|
|
}
|
|
if current >= total {
|
|
return 1
|
|
}
|
|
return float64(current) / float64(total)
|
|
}
|
|
|
|
func normalizeLayerStatus(status string) string {
|
|
return strings.ToLower(strings.TrimSpace(status))
|
|
}
|
|
|
|
// PullTracker folds a pull progress stream into stable, ordered per-layer
|
|
// state so the UI can redraw it every frame without flicker.
|
|
type PullTracker struct {
|
|
order []string
|
|
layers map[string]*Layer
|
|
status string
|
|
started time.Time
|
|
sampleAt time.Time
|
|
sampled int64
|
|
rate float64
|
|
}
|
|
|
|
func NewPullTracker() *PullTracker {
|
|
now := time.Now()
|
|
return &PullTracker{layers: make(map[string]*Layer), started: now, sampleAt: now}
|
|
}
|
|
|
|
// Apply folds one Engine API record into the tracker.
|
|
func (t *PullTracker) Apply(message PullMessage) {
|
|
if message.ID == "" {
|
|
if message.Status != "" {
|
|
t.status = message.Status
|
|
}
|
|
return
|
|
}
|
|
// Digest and manifest records reuse the ID field but are not layers.
|
|
if strings.HasPrefix(message.ID, "sha256:") || len(message.ID) > 24 {
|
|
t.status = message.Status
|
|
return
|
|
}
|
|
layer, exists := t.layers[message.ID]
|
|
if !exists {
|
|
layer = &Layer{ID: message.ID}
|
|
t.layers[message.ID] = layer
|
|
t.order = append(t.order, message.ID)
|
|
}
|
|
if message.Status != "" {
|
|
layer.Status = message.Status
|
|
}
|
|
if message.Detail.Total > 0 {
|
|
layer.Total = message.Detail.Total
|
|
}
|
|
if message.Detail.Current > 0 || message.Detail.Total > 0 {
|
|
layer.Current = message.Detail.Current
|
|
}
|
|
if layer.Done() {
|
|
layer.Current = layer.Total
|
|
}
|
|
t.sample()
|
|
}
|
|
|
|
var plainPullPattern = regexp.MustCompile(`^([0-9a-zA-Z_-]{4,64}):\s+(.+)$`)
|
|
|
|
// ApplyLine folds a line of plain `docker pull` output into the tracker. It is
|
|
// the fallback used when the Engine API is unreachable; the resulting progress
|
|
// is coarser because the CLI omits byte counters when stdout is not a TTY.
|
|
func (t *PullTracker) ApplyLine(line string) {
|
|
line = strings.TrimSpace(ansi.Strip(line))
|
|
if line == "" {
|
|
return
|
|
}
|
|
match := plainPullPattern.FindStringSubmatch(line)
|
|
if match == nil {
|
|
t.status = line
|
|
return
|
|
}
|
|
status, current, total := parsePlainProgress(match[2])
|
|
t.Apply(PullMessage{
|
|
ID: match[1],
|
|
Status: status,
|
|
Detail: struct {
|
|
Current int64 `json:"current"`
|
|
Total int64 `json:"total"`
|
|
}{Current: current, Total: total},
|
|
})
|
|
}
|
|
|
|
var plainSizePattern = regexp.MustCompile(`([0-9.]+\s*[A-Za-z]*B)\s*/\s*([0-9.]+\s*[A-Za-z]*B)`)
|
|
|
|
func parsePlainProgress(rest string) (status string, current, total int64) {
|
|
status = rest
|
|
if bracket := strings.Index(rest, "["); bracket >= 0 {
|
|
status = strings.TrimSpace(rest[:bracket])
|
|
}
|
|
if match := plainSizePattern.FindStringSubmatch(rest); match != nil {
|
|
current = ParseSize(match[1])
|
|
total = ParseSize(match[2])
|
|
}
|
|
return status, current, total
|
|
}
|
|
|
|
var sizeUnits = map[string]int64{
|
|
"b": 1,
|
|
"kb": 1000, "mb": 1000 * 1000, "gb": 1000 * 1000 * 1000, "tb": 1000 * 1000 * 1000 * 1000,
|
|
"kib": 1024, "mib": 1024 * 1024, "gib": 1024 * 1024 * 1024, "tib": 1024 * 1024 * 1024 * 1024,
|
|
}
|
|
|
|
// ParseSize converts a human readable Docker size such as "12.3MB" into bytes.
|
|
func ParseSize(value string) int64 {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
index := 0
|
|
for index < len(value) && (value[index] >= '0' && value[index] <= '9' || value[index] == '.') {
|
|
index++
|
|
}
|
|
number, err := strconv.ParseFloat(strings.TrimSpace(value[:index]), 64)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
unit := strings.TrimSpace(value[index:])
|
|
if unit == "" {
|
|
return int64(number)
|
|
}
|
|
multiplier, known := sizeUnits[unit]
|
|
if !known {
|
|
return int64(number)
|
|
}
|
|
return int64(number * float64(multiplier))
|
|
}
|
|
|
|
// FormatSize renders a byte count the way the Docker CLI does.
|
|
func FormatSize(bytes int64) string {
|
|
const unit = 1000
|
|
if bytes < unit {
|
|
return fmt.Sprintf("%dB", bytes)
|
|
}
|
|
value := float64(bytes)
|
|
for _, suffix := range []string{"kB", "MB", "GB", "TB"} {
|
|
value /= unit
|
|
if value < unit {
|
|
return fmt.Sprintf("%.1f%s", value, suffix)
|
|
}
|
|
}
|
|
return fmt.Sprintf("%.1fPB", value/unit)
|
|
}
|
|
|
|
func (t *PullTracker) sample() {
|
|
now := time.Now()
|
|
elapsed := now.Sub(t.sampleAt)
|
|
if elapsed < 500*time.Millisecond {
|
|
return
|
|
}
|
|
current, _ := t.Bytes()
|
|
if delta := current - t.sampled; delta > 0 {
|
|
t.rate = float64(delta) / elapsed.Seconds()
|
|
}
|
|
t.sampled = current
|
|
t.sampleAt = now
|
|
}
|
|
|
|
// Layers returns the layers in the order Docker announced them.
|
|
func (t *PullTracker) Layers() []Layer {
|
|
layers := make([]Layer, 0, len(t.order))
|
|
for _, id := range t.order {
|
|
layers = append(layers, *t.layers[id])
|
|
}
|
|
return layers
|
|
}
|
|
|
|
// Bytes reports downloaded and total bytes across every layer that announced a
|
|
// size.
|
|
func (t *PullTracker) Bytes() (current, total int64) {
|
|
for _, layer := range t.layers {
|
|
if layer.Total <= 0 {
|
|
continue
|
|
}
|
|
total += layer.Total
|
|
if layer.Done() {
|
|
current += layer.Total
|
|
continue
|
|
}
|
|
current += min(layer.Current, layer.Total)
|
|
}
|
|
return current, total
|
|
}
|
|
|
|
// Complete reports how many layers are fully pulled out of how many are known.
|
|
func (t *PullTracker) Complete() (complete, total int) {
|
|
for _, layer := range t.layers {
|
|
total++
|
|
if layer.Done() {
|
|
complete++
|
|
}
|
|
}
|
|
return complete, total
|
|
}
|
|
|
|
// Percent is the overall progress of the pull in the range [0,1].
|
|
func (t *PullTracker) Percent() float64 {
|
|
if len(t.order) == 0 {
|
|
return 0
|
|
}
|
|
sum := 0.0
|
|
for _, id := range t.order {
|
|
sum += t.layers[id].Fraction()
|
|
}
|
|
return sum / float64(len(t.order))
|
|
}
|
|
|
|
// Rate is the recent download throughput in bytes per second.
|
|
func (t *PullTracker) Rate() float64 { return t.rate }
|
|
|
|
// Status is the most recent message that was not tied to a single layer.
|
|
func (t *PullTracker) Status() string { return t.status }
|
|
|
|
// Elapsed reports how long the pull has been running.
|
|
func (t *PullTracker) Elapsed() time.Duration { return time.Since(t.started) }
|
|
|
|
// BuildStep is one BuildKit vertex, such as a Dockerfile instruction.
|
|
type BuildStep struct {
|
|
ID string
|
|
Label string
|
|
Status string
|
|
Output []string
|
|
Done bool
|
|
Cached bool
|
|
Failed bool
|
|
Canceled bool
|
|
Elapsed string
|
|
}
|
|
|
|
// BuildTracker folds `docker build --progress=plain` output into ordered steps.
|
|
type BuildTracker struct {
|
|
order []string
|
|
steps map[string]*BuildStep
|
|
total int
|
|
started time.Time
|
|
last string
|
|
}
|
|
|
|
func NewBuildTracker() *BuildTracker {
|
|
return &BuildTracker{steps: make(map[string]*BuildStep), started: time.Now()}
|
|
}
|
|
|
|
var (
|
|
buildLinePattern = regexp.MustCompile(`^#(\d+)\s*(.*)$`)
|
|
buildStagePattern = regexp.MustCompile(`^\[[^\]]*?(\d+)/(\d+)\]`)
|
|
buildTimePattern = regexp.MustCompile(`^\d+\.\d+\s`)
|
|
)
|
|
|
|
// ApplyLine folds one line of BuildKit plain output into the tracker.
|
|
func (t *BuildTracker) ApplyLine(line string) {
|
|
line = strings.TrimRight(line, "\r\n")
|
|
trimmed := strings.TrimSpace(ansi.Strip(line))
|
|
if trimmed == "" {
|
|
return
|
|
}
|
|
match := buildLinePattern.FindStringSubmatch(trimmed)
|
|
if match == nil {
|
|
t.last = trimmed
|
|
return
|
|
}
|
|
id, rest := match[1], strings.TrimSpace(match[2])
|
|
// BuildKit reserves #0 for its own preamble ("building with ... driver").
|
|
// It never completes, so counting it would leave every finished build one
|
|
// step short of done.
|
|
if id == "0" {
|
|
t.last = rest
|
|
return
|
|
}
|
|
step, exists := t.steps[id]
|
|
if !exists {
|
|
step = &BuildStep{ID: id}
|
|
t.steps[id] = step
|
|
t.order = append(t.order, id)
|
|
}
|
|
|
|
switch {
|
|
case strings.HasPrefix(rest, "DONE"):
|
|
step.Done = true
|
|
step.Status = "done"
|
|
step.Elapsed = strings.TrimSpace(strings.TrimPrefix(rest, "DONE"))
|
|
case rest == "CACHED":
|
|
step.Done, step.Cached, step.Status = true, true, "cached"
|
|
case strings.HasPrefix(rest, "ERROR"):
|
|
step.Failed, step.Done, step.Status = true, true, "failed"
|
|
step.Output = appendStepOutput(step.Output, rest)
|
|
t.last = rest
|
|
case strings.HasPrefix(rest, "CANCELED"):
|
|
step.Done, step.Status, step.Canceled = true, "canceled", true
|
|
case buildTimePattern.MatchString(rest):
|
|
// `#12 1.234 <output>` — timestamped output from inside the step.
|
|
_, output, _ := strings.Cut(rest, " ")
|
|
if output = strings.TrimSpace(output); output != "" {
|
|
step.Output = appendStepOutput(step.Output, output)
|
|
t.last = output
|
|
}
|
|
case step.Label == "":
|
|
step.Label = rest
|
|
if stage := buildStagePattern.FindStringSubmatch(rest); stage != nil {
|
|
if total, err := strconv.Atoi(stage[2]); err == nil && total > t.total {
|
|
t.total = total
|
|
}
|
|
}
|
|
default:
|
|
step.Output = appendStepOutput(step.Output, rest)
|
|
}
|
|
}
|
|
|
|
func appendStepOutput(output []string, line string) []string {
|
|
const retained = 200
|
|
output = append(output, line)
|
|
if len(output) > retained {
|
|
output = output[len(output)-retained:]
|
|
}
|
|
return output
|
|
}
|
|
|
|
// Steps returns every announced step in declaration order.
|
|
func (t *BuildTracker) Steps() []BuildStep {
|
|
steps := make([]BuildStep, 0, len(t.order))
|
|
for _, id := range t.order {
|
|
steps = append(steps, *t.steps[id])
|
|
}
|
|
return steps
|
|
}
|
|
|
|
// Active returns the step BuildKit is currently working on, if any.
|
|
func (t *BuildTracker) Active() (BuildStep, bool) {
|
|
for index := len(t.order) - 1; index >= 0; index-- {
|
|
if step := t.steps[t.order[index]]; !step.Done && step.Label != "" {
|
|
return *step, true
|
|
}
|
|
}
|
|
return BuildStep{}, false
|
|
}
|
|
|
|
// Complete reports finished steps out of the best known total. The total grows
|
|
// as BuildKit announces stages, so it is never allowed to shrink below what has
|
|
// already been seen. A step BuildKit reported as canceled or failed is finished
|
|
// but not complete — counting it would report a killed build as fully built.
|
|
func (t *BuildTracker) Complete() (complete, total int) {
|
|
for _, step := range t.steps {
|
|
if step.Done && !step.Canceled && !step.Failed {
|
|
complete++
|
|
}
|
|
}
|
|
return complete, max(t.total, len(t.order))
|
|
}
|
|
|
|
// Percent is the overall build progress in the range [0,1].
|
|
func (t *BuildTracker) Percent() float64 {
|
|
complete, total := t.Complete()
|
|
if total == 0 {
|
|
return 0
|
|
}
|
|
return ratio(int64(complete), int64(total))
|
|
}
|
|
|
|
// LastMessage is the most recent human readable line, used for error context.
|
|
func (t *BuildTracker) LastMessage() string { return t.last }
|
|
|
|
// Elapsed reports how long the build has been running.
|
|
func (t *BuildTracker) Elapsed() time.Duration { return time.Since(t.started) }
|