diff --git a/Makefile b/Makefile index 057ef9aaf..2261cebb0 100644 --- a/Makefile +++ b/Makefile @@ -33,7 +33,7 @@ LDFLAGS += -X $(PROJECT_PKG)/internal/version.GitTreeState=${GIT_DIRTY} .PHONY: test test: tidy vendor check-encoding ## tidy and run tests - $(GO_EXE) test -race -v -coverprofile=coverage.txt -covermode=atomic ./... + $(GO_EXE) test -race -v -coverprofile=coverage.txt -covermode=atomic -coverpkg=./... ./... .PHONY: teste2e teste2e: ## run end to end tests diff --git a/cmd/oras/internal/display/console/console.go b/cmd/oras/internal/display/console/console.go new file mode 100644 index 000000000..6a932d68a --- /dev/null +++ b/cmd/oras/internal/display/console/console.go @@ -0,0 +1,95 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package console + +import ( + "os" + + "github.com/containerd/console" + "github.com/morikuni/aec" +) + +const ( + // MinWidth is the minimal width of supported console. + MinWidth = 80 + // MinHeight is the minimal height of supported console. + MinHeight = 10 + // cannot use aec.Save since DEC has better compatilibity than SCO + Save = "\0337" + // cannot use aec.Restore since DEC has better compatilibity than SCO + Restore = "\0338" +) + +// Console is a wrapper around containerd's console.Console and ANSI escape +// codes. +type Console struct { + console.Console +} + +// Size returns the width and height of the console. +// If the console size cannot be determined, returns a default value of 80x10. +func (c *Console) Size() (width, height int) { + width = MinWidth + height = MinHeight + size, err := c.Console.Size() + if err == nil { + if size.Height > MinHeight { + height = int(size.Height) + } + if size.Width > MinWidth { + width = int(size.Width) + } + } + return +} + +// New generates a Console from a file. +func New(f *os.File) (*Console, error) { + c, err := console.ConsoleFromFile(f) + if err != nil { + return nil, err + } + return &Console{c}, nil +} + +// Save saves the current cursor position. +func (c *Console) Save() { + _, _ = c.Write([]byte(aec.Hide.Apply(Save))) +} + +// NewRow allocates a horizontal space to the output area with scroll if needed. +func (c *Console) NewRow() { + _, _ = c.Write([]byte(Restore)) + _, _ = c.Write([]byte("\n")) + _, _ = c.Write([]byte(Save)) +} + +// OutputTo outputs a string to a specific line. +func (c *Console) OutputTo(upCnt uint, str string) { + _, _ = c.Write([]byte(Restore)) + _, _ = c.Write([]byte(aec.PreviousLine(upCnt).Apply(str))) + _, _ = c.Write([]byte(" ")) + _, _ = c.Write([]byte(aec.EraseLine(aec.EraseModes.Tail).String())) +} + +// Restore restores the saved cursor position. +func (c *Console) Restore() { + // cannot use aec.Restore since DEC has better compatilibity than SCO + _, _ = c.Write([]byte(Restore)) + _, _ = c.Write([]byte(aec.Column(0). + With(aec.EraseLine(aec.EraseModes.All)). + With(aec.Show).String())) +} diff --git a/cmd/oras/internal/display/console/console_test.go b/cmd/oras/internal/display/console/console_test.go new file mode 100644 index 000000000..cf00a88ed --- /dev/null +++ b/cmd/oras/internal/display/console/console_test.go @@ -0,0 +1,63 @@ +//go:build darwin || freebsd || linux || netbsd || openbsd || solaris + +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package console + +import ( + "testing" + + "github.com/containerd/console" +) + +func validateSize(t *testing.T, gotWidth, gotHeight, wantWidth, wantHeight int) { + t.Helper() + if gotWidth != wantWidth { + t.Errorf("Console.Size() gotWidth = %v, want %v", gotWidth, wantWidth) + } + if gotHeight != wantHeight { + t.Errorf("Console.Size() gotHeight = %v, want %v", gotHeight, wantHeight) + } +} + +func TestConsole_Size(t *testing.T) { + pty, _, err := console.NewPty() + if err != nil { + t.Fatal(err) + } + c := &Console{ + Console: pty, + } + + // minimal width and height + gotWidth, gotHeight := c.Size() + validateSize(t, gotWidth, gotHeight, MinWidth, MinHeight) + + // zero width + _ = pty.Resize(console.WinSize{Width: 0, Height: MinHeight}) + gotWidth, gotHeight = c.Size() + validateSize(t, gotWidth, gotHeight, MinWidth, MinHeight) + + // zero height + _ = pty.Resize(console.WinSize{Width: MinWidth, Height: 0}) + gotWidth, gotHeight = c.Size() + validateSize(t, gotWidth, gotHeight, MinWidth, MinHeight) + + // valid zero and height + _ = pty.Resize(console.WinSize{Width: 200, Height: 100}) + gotWidth, gotHeight = c.Size() + validateSize(t, gotWidth, gotHeight, 200, 100) +} diff --git a/cmd/oras/internal/display/console/testutils/testutils.go b/cmd/oras/internal/display/console/testutils/testutils.go new file mode 100644 index 000000000..92d65a00c --- /dev/null +++ b/cmd/oras/internal/display/console/testutils/testutils.go @@ -0,0 +1,71 @@ +//go:build darwin || freebsd || linux || netbsd || openbsd || solaris + +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package testutils + +import ( + "bytes" + "fmt" + "io" + "os" + "strings" + "sync" + + "github.com/containerd/console" +) + +// NewPty creates a new pty pair for testing, caller is responsible for closing +// the returned device file if err is not nil. +func NewPty() (console.Console, *os.File, error) { + pty, devicePath, err := console.NewPty() + if err != nil { + return nil, nil, err + } + device, err := os.OpenFile(devicePath, os.O_RDWR, 0) + if err != nil { + return nil, nil, err + } + return pty, device, nil +} + +// MatchPty checks that the output matches the expected strings in specified +// order. +func MatchPty(pty console.Console, device *os.File, expected ...string) error { + var wg sync.WaitGroup + wg.Add(1) + var buffer bytes.Buffer + go func() { + defer wg.Done() + _, _ = io.Copy(&buffer, pty) + }() + device.Close() + wg.Wait() + + return OrderedMatch(buffer.String(), expected...) +} + +// OrderedMatch matches the got with the expected strings in order. +func OrderedMatch(got string, want ...string) error { + for _, e := range want { + i := strings.Index(got, e) + if i < 0 { + return fmt.Errorf("failed to find %q in %q", e, got) + } + got = got[i+len(e):] + } + return nil +} diff --git a/cmd/oras/internal/display/progress/humanize/bytes.go b/cmd/oras/internal/display/progress/humanize/bytes.go new file mode 100644 index 000000000..4558dbdf3 --- /dev/null +++ b/cmd/oras/internal/display/progress/humanize/bytes.go @@ -0,0 +1,60 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package humanize + +import ( + "fmt" + "math" +) + +const base = 1024.0 + +var units = []string{"B", "kB", "MB", "GB", "TB"} + +type Bytes struct { + Size float64 + Unit string +} + +// ToBytes converts size in bytes to human readable format. +func ToBytes(sizeInBytes int64) Bytes { + f := float64(sizeInBytes) + if f < base { + return Bytes{f, units[0]} + } + e := int(math.Floor(math.Log(f) / math.Log(base))) + if e >= len(units) { + // only support up to TB + e = len(units) - 1 + } + p := f / math.Pow(base, float64(e)) + return Bytes{RoundTo(p), units[e]} +} + +// String returns the string representation of Bytes. +func (b Bytes) String() string { + return fmt.Sprintf("%v %2s", b.Size, b.Unit) +} + +// RoundTo makes length of the size string to less than or equal to 4. +func RoundTo(size float64) float64 { + if size < 10 { + return math.Round(size*100) / 100 + } else if size < 100 { + return math.Round(size*10) / 10 + } + return math.Round(size) +} diff --git a/cmd/oras/internal/display/progress/humanize/bytes_test.go b/cmd/oras/internal/display/progress/humanize/bytes_test.go new file mode 100644 index 000000000..33545f480 --- /dev/null +++ b/cmd/oras/internal/display/progress/humanize/bytes_test.go @@ -0,0 +1,72 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package humanize + +import ( + "reflect" + "testing" +) + +func TestRoundTo(t *testing.T) { + type args struct { + quantity float64 + } + tests := []struct { + name string + args args + want float64 + }{ + {"round to 2 digit", args{1.223}, 1.22}, + {"round to 1 digit", args{12.23}, 12.2}, + {"round to no digit", args{122.6}, 123}, + {"round to no digit", args{1223.123}, 1223}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := RoundTo(tt.args.quantity); got != tt.want { + t.Errorf("RoundTo() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestToBytes(t *testing.T) { + type args struct { + sizeInBytes int64 + } + tests := []struct { + name string + args args + want Bytes + }{ + {"0 bytes", args{0}, Bytes{0, "B"}}, + {"1023 bytes", args{1023}, Bytes{1023, "B"}}, + {"1 kB", args{1024}, Bytes{1, "kB"}}, + {"1.5 kB", args{1024 + 512}, Bytes{1.5, "kB"}}, + {"12.5 kB", args{1024 * 12.5}, Bytes{12.5, "kB"}}, + {"512.5 kB", args{1024 * 512.5}, Bytes{513, "kB"}}, + {"1 MB", args{1024 * 1024}, Bytes{1, "MB"}}, + {"1 GB", args{1024 * 1024 * 1024}, Bytes{1, "GB"}}, + {"1 TB", args{1024 * 1024 * 1024 * 1024}, Bytes{1, "TB"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ToBytes(tt.args.sizeInBytes); !reflect.DeepEqual(got, tt.want) { + t.Errorf("ToBytes() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/cmd/oras/internal/display/progress/manager.go b/cmd/oras/internal/display/progress/manager.go new file mode 100644 index 000000000..0ee57cfc0 --- /dev/null +++ b/cmd/oras/internal/display/progress/manager.go @@ -0,0 +1,155 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package progress + +import ( + "errors" + "os" + "sync" + "time" + + "oras.land/oras/cmd/oras/internal/display/console" +) + +const ( + // BufferSize is the size of the status channel buffer. + BufferSize = 1 + bufFlushDuration = 200 * time.Millisecond +) + +var errManagerStopped = errors.New("progress output manage has already been stopped") + +// Status is print message channel +type Status chan *status + +// Manager is progress view master +type Manager interface { + Add() (Status, error) + Close() error +} + +type manager struct { + status []*status + statusLock sync.RWMutex + console *console.Console + updating sync.WaitGroup + renderDone chan struct{} + renderClosed chan struct{} +} + +// NewManager initialized a new progress manager. +func NewManager(f *os.File) (Manager, error) { + c, err := console.New(f) + if err != nil { + return nil, err + } + m := &manager{ + console: c, + renderDone: make(chan struct{}), + renderClosed: make(chan struct{}), + } + m.start() + return m, nil +} + +func (m *manager) start() { + m.console.Save() + renderTicker := time.NewTicker(bufFlushDuration) + go func() { + defer m.console.Restore() + defer renderTicker.Stop() + for { + select { + case <-m.renderDone: + m.render() + close(m.renderClosed) + return + case <-renderTicker.C: + m.render() + } + } + }() +} + +func (m *manager) render() { + m.statusLock.RLock() + defer m.statusLock.RUnlock() + // todo: update size in another routine + width, height := m.console.Size() + len := len(m.status) * 2 + offset := 0 + if len > height { + // skip statuses that cannot be rendered + offset = len - height + } + + for ; offset < len; offset += 2 { + status, progress := m.status[offset/2].String(width) + m.console.OutputTo(uint(len-offset), status) + m.console.OutputTo(uint(len-offset-1), progress) + } +} + +// Add appends a new status with 2-line space for rendering. +func (m *manager) Add() (Status, error) { + if m.closed() { + return nil, errManagerStopped + } + + s := newStatus() + m.statusLock.Lock() + m.status = append(m.status, s) + m.statusLock.Unlock() + + defer m.console.NewRow() + defer m.console.NewRow() + return m.statusChan(s), nil +} + +func (m *manager) statusChan(s *status) Status { + ch := make(chan *status, BufferSize) + m.updating.Add(1) + go func() { + defer m.updating.Done() + for newStatus := range ch { + s.Update(newStatus) + } + }() + return ch +} + +// Close stops all status and waits for updating and rendering. +func (m *manager) Close() error { + if m.closed() { + return errManagerStopped + } + // 1. wait for update to stop + m.updating.Wait() + // 2. stop periodic rendering + close(m.renderDone) + // 3. wait for the render stop + <-m.renderClosed + return nil +} + +func (m *manager) closed() bool { + select { + case <-m.renderClosed: + return true + default: + return false + } +} diff --git a/cmd/oras/internal/display/progress/spinner.go b/cmd/oras/internal/display/progress/spinner.go new file mode 100644 index 000000000..b874412f1 --- /dev/null +++ b/cmd/oras/internal/display/progress/spinner.go @@ -0,0 +1,27 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package progress + +var spinnerSymbols = []rune("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏") + +type spinner int + +// symbol returns the rune of status mark and shift to the next. +func (s *spinner) symbol() rune { + last := int(*s) + *s = spinner((last + 1) % len(spinnerSymbols)) + return spinnerSymbols[last] +} diff --git a/cmd/oras/internal/display/progress/spinner_test.go b/cmd/oras/internal/display/progress/spinner_test.go new file mode 100644 index 000000000..1a799b76d --- /dev/null +++ b/cmd/oras/internal/display/progress/spinner_test.go @@ -0,0 +1,30 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package progress + +import "testing" + +func Test_spinner_symbol(t *testing.T) { + var s spinner + for i := 0; i < len(spinnerSymbols); i++ { + if s.symbol() != spinnerSymbols[i] { + t.Errorf("symbol() = %v, want %v", s.symbol(), spinnerSymbols[i]) + } + } + if s.symbol() != spinnerSymbols[0] { + t.Errorf("symbol() = %v, want %v", s.symbol(), spinnerSymbols[0]) + } +} diff --git a/cmd/oras/internal/display/progress/status.go b/cmd/oras/internal/display/progress/status.go new file mode 100644 index 000000000..d8a63ecfb --- /dev/null +++ b/cmd/oras/internal/display/progress/status.go @@ -0,0 +1,213 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package progress + +import ( + "fmt" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/morikuni/aec" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras/cmd/oras/internal/display/progress/humanize" +) + +const ( + barLength = 20 + speedLength = 7 // speed_size(4) + space(1) + speed_unit(2) + zeroDuration = "0s" // default zero value of time.Duration.String() + zeroStatus = "loading status..." + zeroDigest = " └─ loading digest..." +) + +// status is used as message to update progress view. +type status struct { + done bool // done is true when the end time is set + prompt string + descriptor ocispec.Descriptor + offset int64 + total humanize.Bytes + lastOffset int64 + lastRenderTime time.Time + + startTime time.Time + endTime time.Time + mark spinner + lock sync.Mutex +} + +// newStatus generates a base empty status. +func newStatus() *status { + return &status{ + offset: -1, + lastRenderTime: time.Now(), + } +} + +// NewStatus generates a status. +func NewStatus(prompt string, descriptor ocispec.Descriptor, offset int64) *status { + return &status{ + prompt: prompt, + descriptor: descriptor, + offset: offset, + } +} + +// StartTiming starts timing. +func StartTiming() *status { + return &status{ + offset: -1, + startTime: time.Now(), + } +} + +// EndTiming ends timing and set status to done. +func EndTiming() *status { + return &status{ + offset: -1, + endTime: time.Now(), + } +} + +func (s *status) isZero() bool { + return s.offset < 0 && s.startTime.IsZero() && s.endTime.IsZero() +} + +// String returns human-readable TTY strings of the status. +func (s *status) String(width int) (string, string) { + s.lock.Lock() + defer s.lock.Unlock() + + if s.isZero() { + return zeroStatus, zeroDigest + } + // todo: doesn't support multiline prompt + total := uint64(s.descriptor.Size) + var percent float64 + if s.offset >= 0 { + percent = float64(s.offset) / float64(total) + } + + name := s.descriptor.Annotations["org.opencontainers.image.title"] + if name == "" { + name = s.descriptor.MediaType + } + + // format: [left--------------------------------------------][margin][right---------------------------------] + // mark(1) bar(22) speed(8) action(<=11) name(<=126) size_per_size(<=13) percent(8) time(>=6) + // └─ digest(72) + var offset string + switch percent { + case 1: // 100%, show exact size + offset = fmt.Sprint(s.total.Size) + default: // 0% ~ 99%, show 2-digit precision + offset = fmt.Sprintf("%.2f", humanize.RoundTo(s.total.Size*percent)) + } + right := fmt.Sprintf(" %s/%s %6.2f%% %6s", offset, s.total, percent*100, s.durationString()) + lenRight := utf8.RuneCountInString(right) + + var left string + lenLeft := 0 + if !s.done { + lenBar := int(percent * barLength) + bar := fmt.Sprintf("[%s%s]", aec.Inverse.Apply(strings.Repeat(" ", lenBar)), strings.Repeat(".", barLength-lenBar)) + speed := s.calculateSpeed() + left = fmt.Sprintf("%c %s(%*s/s) %s %s", s.mark.symbol(), bar, speedLength, speed, s.prompt, name) + // bar + wrapper(2) + space(1) + speed + "/s"(2) + wrapper(2) = len(bar) + len(speed) + 7 + lenLeft = barLength + speedLength + 7 + } else { + left = fmt.Sprintf("√ %s %s", s.prompt, name) + } + // mark(1) + space(1) + prompt + space(1) + name = len(prompt) + len(name) + 3 + lenLeft += utf8.RuneCountInString(s.prompt) + utf8.RuneCountInString(name) + 3 + + lenMargin := width - lenLeft - lenRight + if lenMargin < 0 { + // hide partial name with one space left + left = left[:len(left)+lenMargin-1] + "." + lenMargin = 0 + } + return fmt.Sprintf("%s%s%s", left, strings.Repeat(" ", lenMargin), right), fmt.Sprintf(" └─ %s", s.descriptor.Digest.String()) +} + +// calculateSpeed calculates the speed of the progress and update last status. +// caller must hold the lock. +func (s *status) calculateSpeed() humanize.Bytes { + now := time.Now() + if s.lastRenderTime.IsZero() { + s.lastRenderTime = s.startTime + } + secondsTaken := now.Sub(s.lastRenderTime).Seconds() + if secondsTaken == 0 { + secondsTaken = bufFlushDuration.Seconds() + } + bytes := float64(s.offset - s.lastOffset) + + s.lastOffset = s.offset + s.lastRenderTime = now + + return humanize.ToBytes(int64(bytes / secondsTaken)) +} + +// durationString returns a viewable TTY string of the status with duration. +func (s *status) durationString() string { + if s.startTime.IsZero() { + return zeroDuration + } + + var d time.Duration + if s.endTime.IsZero() { + d = time.Since(s.startTime) + } else { + d = s.endTime.Sub(s.startTime) + } + + switch { + case d > time.Second: + d = d.Round(time.Second) + case d > time.Millisecond: + d = d.Round(time.Millisecond) + default: + d = d.Round(time.Microsecond) + } + return d.String() +} + +// Update updates a status. +func (s *status) Update(n *status) { + s.lock.Lock() + defer s.lock.Unlock() + + if n.offset >= 0 { + s.offset = n.offset + if n.descriptor.Size != s.descriptor.Size { + s.total = humanize.ToBytes(n.descriptor.Size) + } + s.descriptor = n.descriptor + } + if n.prompt != "" { + s.prompt = n.prompt + } + if !n.startTime.IsZero() { + s.startTime = n.startTime + } + if !n.endTime.IsZero() { + s.endTime = n.endTime + s.done = true + } +} diff --git a/cmd/oras/internal/display/progress/status_test.go b/cmd/oras/internal/display/progress/status_test.go new file mode 100644 index 000000000..10c38f9ad --- /dev/null +++ b/cmd/oras/internal/display/progress/status_test.go @@ -0,0 +1,106 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package progress + +import ( + "testing" + "time" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras/cmd/oras/internal/display/console" + "oras.land/oras/cmd/oras/internal/display/console/testutils" + "oras.land/oras/cmd/oras/internal/display/progress/humanize" +) + +func Test_status_String(t *testing.T) { + // zero status and progress + s := newStatus() + if status, digest := s.String(console.MinWidth); status != zeroStatus || digest != zeroDigest { + t.Errorf("status.String() = %v, %v, want %v, %v", status, digest, zeroStatus, zeroDigest) + } + + // not done + s.Update(&status{ + prompt: "test", + descriptor: ocispec.Descriptor{ + MediaType: "application/vnd.oci.empty.oras.test.v1+json", + Size: 2, + Digest: "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + }, + startTime: time.Now().Add(-time.Minute), + offset: 0, + total: humanize.ToBytes(2), + }) + // full name + statusStr, digestStr := s.String(120) + if err := testutils.OrderedMatch(statusStr+digestStr, " [\x1b[7m\x1b[0m....................]", s.prompt, s.descriptor.MediaType, "0.00/2 B", "0.00%", s.descriptor.Digest.String()); err != nil { + t.Error(err) + } + // partial name + statusStr, digestStr = s.String(console.MinWidth) + if err := testutils.OrderedMatch(statusStr+digestStr, " [\x1b[7m\x1b[0m....................]", s.prompt, "application/v.", "0.00/2 B", "0.00%", s.descriptor.Digest.String()); err != nil { + t.Error(err) + } + + // done + s.Update(&status{ + endTime: time.Now(), + offset: s.descriptor.Size, + descriptor: s.descriptor, + }) + statusStr, digestStr = s.String(120) + if err := testutils.OrderedMatch(statusStr+digestStr, "√", s.prompt, s.descriptor.MediaType, "2/2 B", "100.00%", s.descriptor.Digest.String()); err != nil { + t.Error(err) + } +} + +func Test_status_durationString(t *testing.T) { + // zero duration + s := newStatus() + if d := s.durationString(); d != zeroDuration { + t.Errorf("status.durationString() = %v, want %v", d, zeroDuration) + } + + // not ended + s.startTime = time.Now().Add(-time.Second) + if d := s.durationString(); d == zeroDuration { + t.Errorf("status.durationString() = %v, want not %v", d, zeroDuration) + } + + // ended: 61 seconds + s.startTime = time.Now() + s.endTime = s.startTime.Add(61 * time.Second) + want := "1m1s" + if d := s.durationString(); d != want { + t.Errorf("status.durationString() = %v, want %v", d, want) + } + + // ended: 1001 Microsecond + s.startTime = time.Now() + s.endTime = s.startTime.Add(1001 * time.Microsecond) + want = "1ms" + if d := s.durationString(); d != want { + t.Errorf("status.durationString() = %v, want %v", d, want) + } + + // ended: 1001 Nanosecond + s.startTime = time.Now() + s.endTime = s.startTime.Add(1001 * time.Nanosecond) + want = "1µs" + if d := s.durationString(); d != want { + t.Errorf("status.durationString() = %v, want %v", d, want) + } +} diff --git a/cmd/oras/internal/display/track/reader.go b/cmd/oras/internal/display/track/reader.go new file mode 100644 index 000000000..97bb3883e --- /dev/null +++ b/cmd/oras/internal/display/track/reader.go @@ -0,0 +1,104 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package track + +import ( + "io" + "os" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras/cmd/oras/internal/display/progress" +) + +type reader struct { + base io.Reader + offset int64 + actionPrompt string + donePrompt string + descriptor ocispec.Descriptor + manager progress.Manager + status progress.Status +} + +// NewReader returns a new reader with tracked progress. +func NewReader(r io.Reader, descriptor ocispec.Descriptor, actionPrompt string, donePrompt string, tty *os.File) (*reader, error) { + manager, err := progress.NewManager(tty) + if err != nil { + return nil, err + } + return managedReader(r, descriptor, manager, actionPrompt, donePrompt) +} + +func managedReader(r io.Reader, descriptor ocispec.Descriptor, manager progress.Manager, actionPrompt string, donePrompt string) (*reader, error) { + ch, err := manager.Add() + if err != nil { + return nil, err + } + + return &reader{ + base: r, + descriptor: descriptor, + actionPrompt: actionPrompt, + donePrompt: donePrompt, + manager: manager, + status: ch, + }, nil +} + +// StopManager stops the status channel and related manager. +func (r *reader) StopManager() { + r.Close() + _ = r.manager.Close() +} + +// Done sends message to mark the tracked progress as complete. +func (r *reader) Done() { + r.status <- progress.NewStatus(r.donePrompt, r.descriptor, r.descriptor.Size) + r.status <- progress.EndTiming() +} + +// Close closes the update channel. +func (r *reader) Close() { + close(r.status) +} + +// Start sends the start timing to the status channel. +func (r *reader) Start() { + r.status <- progress.StartTiming() +} + +// Read reads from the underlying reader and updates the progress. +func (r *reader) Read(p []byte) (int, error) { + n, err := r.base.Read(p) + if err != nil && err != io.EOF { + return n, err + } + + r.offset = r.offset + int64(n) + if err == io.EOF { + if r.offset != r.descriptor.Size { + return n, io.ErrUnexpectedEOF + } + } + for { + select { + case r.status <- progress.NewStatus(r.actionPrompt, r.descriptor, r.offset): + // purge the channel until successfully pushed + return n, err + case <-r.status: + } + } +} diff --git a/cmd/oras/internal/option/common.go b/cmd/oras/internal/option/common.go index 6c5817967..0530ed056 100644 --- a/cmd/oras/internal/option/common.go +++ b/cmd/oras/internal/option/common.go @@ -17,9 +17,12 @@ package option import ( "context" + "errors" + "os" "github.com/sirupsen/logrus" "github.com/spf13/pflag" + "golang.org/x/term" "oras.land/oras/internal/trace" ) @@ -27,15 +30,36 @@ import ( type Common struct { Debug bool Verbose bool + TTY *os.File + + noTTY bool } // ApplyFlags applies flags to a command flag set. func (opts *Common) ApplyFlags(fs *pflag.FlagSet) { fs.BoolVarP(&opts.Debug, "debug", "d", false, "debug mode") fs.BoolVarP(&opts.Verbose, "verbose", "v", false, "verbose output") + fs.BoolVarP(&opts.noTTY, "no-tty", "", false, "[Preview] do not show progress output") } // WithContext returns a new FieldLogger and an associated Context derived from ctx. func (opts *Common) WithContext(ctx context.Context) (context.Context, logrus.FieldLogger) { return trace.NewLogger(ctx, opts.Debug, opts.Verbose) } + +// Parse gets target options from user input. +func (opts *Common) Parse() error { + // use STDERR as TTY output since STDOUT is reserved for pipeable output + return opts.parseTTY(os.Stderr) +} + +// parseTTY gets target options from user input. +func (opts *Common) parseTTY(f *os.File) error { + if !opts.noTTY && term.IsTerminal(int(f.Fd())) { + if opts.Debug { + return errors.New("cannot use --debug, add --no-tty to suppress terminal output") + } + opts.TTY = f + } + return nil +} diff --git a/cmd/oras/internal/option/common_unix_test.go b/cmd/oras/internal/option/common_unix_test.go new file mode 100644 index 000000000..2d4388385 --- /dev/null +++ b/cmd/oras/internal/option/common_unix_test.go @@ -0,0 +1,44 @@ +//go:build darwin || freebsd || linux || netbsd || openbsd || solaris + +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package option + +import ( + "testing" + + "oras.land/oras/cmd/oras/internal/display/console/testutils" +) + +func TestCommon_parseTTY(t *testing.T) { + _, device, err := testutils.NewPty() + if err != nil { + t.Fatal(err) + } + defer device.Close() + var opts Common + + // TTY output + if err := opts.parseTTY(device); err != nil { + t.Errorf("unexpected error with TTY output: %v", err) + } + + // --debug + opts.Debug = true + if err := opts.parseTTY(device); err == nil { + t.Error("expected error when debug is set with TTY output") + } +} diff --git a/cmd/oras/root/blob/fetch.go b/cmd/oras/root/blob/fetch.go index d43ae382d..7bae58092 100644 --- a/cmd/oras/root/blob/fetch.go +++ b/cmd/oras/root/blob/fetch.go @@ -28,6 +28,7 @@ import ( "oras.land/oras-go/v2" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/registry/remote" + "oras.land/oras/cmd/oras/internal/display/track" "oras.land/oras/cmd/oras/internal/option" ) @@ -108,61 +109,76 @@ func fetchBlob(ctx context.Context, opts fetchBlobOptions) (fetchErr error) { if err != nil { return err } + desc, err := opts.doFetch(ctx, src) + if err != nil { + return err + } - var desc ocispec.Descriptor - if opts.outputPath == "" { - // fetch blob descriptor only - desc, err = oras.Resolve(ctx, src, opts.Reference, oras.DefaultResolveOptions) + // outputs blob's descriptor if `--descriptor` is used + if opts.OutputDescriptor { + descJSON, err := opts.Marshal(desc) if err != nil { return err } - } else { - // fetch blob content - var rc io.ReadCloser - desc, rc, err = oras.Fetch(ctx, src, opts.Reference, oras.DefaultFetchOptions) - if err != nil { + if err := opts.Output(os.Stdout, descJSON); err != nil { return err } - defer rc.Close() - vr := content.NewVerifyReader(rc, desc) + } - // outputs blob content if "--output -" is used - if opts.outputPath == "-" { - if _, err := io.Copy(os.Stdout, vr); err != nil { - return err - } - return vr.Verify() - } + return nil +} +func (opts *fetchBlobOptions) doFetch(ctx context.Context, src oras.ReadOnlyTarget) (desc ocispec.Descriptor, fetchErr error) { + var err error + if opts.outputPath == "" { + // fetch blob descriptor only + return oras.Resolve(ctx, src, opts.Reference, oras.DefaultResolveOptions) + } + // fetch blob content + var rc io.ReadCloser + desc, rc, err = oras.Fetch(ctx, src, opts.Reference, oras.DefaultFetchOptions) + if err != nil { + return ocispec.Descriptor{}, err + } + defer rc.Close() + vr := content.NewVerifyReader(rc, desc) + + // outputs blob content if "--output -" is used + writer := os.Stdout + if opts.outputPath != "-" { // save blob content into the local file if the output path is provided file, err := os.Create(opts.outputPath) if err != nil { - return err + return ocispec.Descriptor{}, err } defer func() { if err := file.Close(); fetchErr == nil { fetchErr = err } }() - - if _, err := io.Copy(file, vr); err != nil { - return err - } - if err := vr.Verify(); err != nil { - return err - } + writer = file } - // outputs blob's descriptor if `--descriptor` is used - if opts.OutputDescriptor { - descJSON, err := opts.Marshal(desc) + if opts.TTY == nil { + // none tty output + if _, err = io.Copy(writer, vr); err != nil { + return ocispec.Descriptor{}, err + } + } else { + // tty output + trackedReader, err := track.NewReader(vr, desc, "Downloading", "Downloaded ", opts.TTY) if err != nil { - return err + return ocispec.Descriptor{}, err } - if err := opts.Output(os.Stdout, descJSON); err != nil { - return err + defer trackedReader.StopManager() + trackedReader.Start() + if _, err = io.Copy(writer, trackedReader); err != nil { + return ocispec.Descriptor{}, err } + trackedReader.Done() } - - return nil + if err := vr.Verify(); err != nil { + return ocispec.Descriptor{}, err + } + return desc, nil } diff --git a/cmd/oras/root/blob/fetch_test.go b/cmd/oras/root/blob/fetch_test.go new file mode 100644 index 000000000..ba3363850 --- /dev/null +++ b/cmd/oras/root/blob/fetch_test.go @@ -0,0 +1,67 @@ +//go:build darwin || freebsd || linux || netbsd || openbsd || solaris + +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package blob + +import ( + "bytes" + "context" + "testing" + + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras-go/v2/content/memory" + "oras.land/oras/cmd/oras/internal/display/console/testutils" +) + +func Test_fetchBlobOptions_doFetch(t *testing.T) { + // prepare + pty, device, err := testutils.NewPty() + if err != nil { + t.Fatal(err) + } + defer device.Close() + src := memory.New() + content := []byte("test") + r := bytes.NewReader(content) + desc := ocispec.Descriptor{ + MediaType: "application/octet-stream", + Digest: digest.FromBytes(content), + Size: int64(len(content)), + } + tag := "blob" + ctx := context.Background() + if err := src.Push(ctx, desc, r); err != nil { + t.Fatal(err) + } + if err := src.Tag(ctx, desc, tag); err != nil { + t.Fatal(err) + } + var opts fetchBlobOptions + opts.Reference = tag + opts.Common.TTY = device + opts.outputPath = t.TempDir() + "/test" + // test + _, err = opts.doFetch(ctx, src) + if err != nil { + t.Fatal(err) + } + // validate + if err = testutils.MatchPty(pty, device, "Downloaded ", desc.MediaType, "100.00%", desc.Digest.String()); err != nil { + t.Fatal(err) + } +} diff --git a/cmd/oras/root/blob/push.go b/cmd/oras/root/blob/push.go index ea3bffaee..dd36cdf43 100644 --- a/cmd/oras/root/blob/push.go +++ b/cmd/oras/root/blob/push.go @@ -19,11 +19,14 @@ import ( "context" "errors" "fmt" + "io" "os" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/spf13/cobra" + "oras.land/oras-go/v2" "oras.land/oras/cmd/oras/internal/display" + "oras.land/oras/cmd/oras/internal/display/track" "oras.land/oras/cmd/oras/internal/option" "oras.land/oras/internal/file" ) @@ -98,7 +101,7 @@ Example - Push blob 'hi.txt' into an OCI image layout folder 'layout-dir': func pushBlob(ctx context.Context, opts pushBlobOptions) (err error) { ctx, logger := opts.WithContext(ctx) - repo, err := opts.NewTarget(opts.Common, logger) + target, err := opts.NewTarget(opts.Common, logger) if err != nil { return err } @@ -110,27 +113,19 @@ func pushBlob(ctx context.Context, opts pushBlobOptions) (err error) { } defer rc.Close() - exists, err := repo.Exists(ctx, desc) + exists, err := target.Exists(ctx, desc) if err != nil { return err } verbose := opts.Verbose && !opts.OutputDescriptor if exists { - if err := display.PrintStatus(desc, "Exists", verbose); err != nil { - return err - } + err = display.PrintStatus(desc, "Exists", verbose) } else { - if err := display.PrintStatus(desc, "Uploading", verbose); err != nil { - return err - } - if err = repo.Push(ctx, desc, rc); err != nil { - return err - } - if err := display.PrintStatus(desc, "Uploaded ", verbose); err != nil { - return err - } + err = opts.doPush(ctx, target, desc, rc) + } + if err != nil { + return err } - // outputs blob's descriptor if opts.OutputDescriptor { descJSON, err := opts.Marshal(desc) @@ -145,3 +140,29 @@ func pushBlob(ctx context.Context, opts pushBlobOptions) (err error) { return nil } +func (opts *pushBlobOptions) doPush(ctx context.Context, t oras.Target, desc ocispec.Descriptor, r io.Reader) error { + if opts.TTY == nil { + // none tty output + if err := display.PrintStatus(desc, "Uploading", opts.Verbose); err != nil { + return err + } + if err := t.Push(ctx, desc, r); err != nil { + return err + } + return display.PrintStatus(desc, "Uploaded ", opts.Verbose) + } + + // tty output + trackedReader, err := track.NewReader(r, desc, "Uploading", "Uploaded ", opts.TTY) + if err != nil { + return err + } + defer trackedReader.StopManager() + trackedReader.Start() + r = trackedReader + if err := t.Push(ctx, desc, r); err != nil { + return err + } + trackedReader.Done() + return nil +} diff --git a/cmd/oras/root/blob/push_test.go b/cmd/oras/root/blob/push_test.go new file mode 100644 index 000000000..b47bcfbee --- /dev/null +++ b/cmd/oras/root/blob/push_test.go @@ -0,0 +1,57 @@ +//go:build darwin || freebsd || linux || netbsd || openbsd || solaris + +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package blob + +import ( + "bytes" + "context" + "testing" + + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras-go/v2/content/memory" + "oras.land/oras/cmd/oras/internal/display/console/testutils" +) + +func Test_pushBlobOptions_doPush(t *testing.T) { + // prepare + pty, device, err := testutils.NewPty() + if err != nil { + t.Fatal(err) + } + defer device.Close() + src := memory.New() + content := []byte("test") + r := bytes.NewReader(content) + desc := ocispec.Descriptor{ + MediaType: "application/octet-stream", + Digest: digest.FromBytes(content), + Size: int64(len(content)), + } + var opts pushBlobOptions + opts.Common.TTY = device + // test + err = opts.doPush(context.Background(), src, desc, r) + if err != nil { + t.Fatal(err) + } + // validate + if err = testutils.MatchPty(pty, device, "Uploaded", desc.MediaType, "100.00%", desc.Digest.String()); err != nil { + t.Fatal(err) + } +} diff --git a/go.mod b/go.mod index f4cfe6689..dcd8cdf22 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module oras.land/oras go 1.21 require ( + github.com/containerd/console v1.0.3 + github.com/morikuni/aec v1.0.0 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.0-rc4 github.com/oras-project/oras-credentials-go v0.2.0 diff --git a/go.sum b/go.sum index c4d59b2e1..9982f94c0 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,13 @@ +github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= +github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0-rc4 h1:oOxKUJWnFC4YGHCCMNql1x4YaDfYBTS5Y4x/Cgeo1E0= @@ -24,6 +28,7 @@ github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5Cc github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=