Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Don't block user interaction while fetching the image size #1415

Merged
6 changes: 5 additions & 1 deletion .github/workflows/ubuntu-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ jobs:
fish \
gcc \
go-md2man \
golang \
golang-1.20 \
meson \
ninja-build \
openssl \
Expand All @@ -54,6 +54,10 @@ jobs:
systemd \
udisks2

- name: Set up PATH for Go 1.20
run: |
echo "PATH=/usr/lib/go-1.20/bin:$PATH" >> "$GITHUB_ENV"

- name: Checkout Bats
uses: actions/checkout@v3
with:
Expand Down
272 changes: 250 additions & 22 deletions src/cmd/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package cmd

import (
"context"
"errors"
"fmt"
"os"
Expand All @@ -28,14 +29,18 @@ import (
"github.com/containers/toolbox/pkg/podman"
"github.com/containers/toolbox/pkg/shell"
"github.com/containers/toolbox/pkg/skopeo"
"github.com/containers/toolbox/pkg/term"
"github.com/containers/toolbox/pkg/utils"
"github.com/docker/go-units"
"github.com/godbus/dbus/v5"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"golang.org/x/term"
)

type promptForDownloadError struct {
ImageSize string
}

const (
alpha = `abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ`
num = `0123456789`
Expand Down Expand Up @@ -449,10 +454,7 @@ func createContainer(container, image, release, authFile string, showCommandToEn
}

s := spinner.New(spinner.CharSets[9], 500*time.Millisecond)

stdoutFd := os.Stdout.Fd()
stdoutFdInt := int(stdoutFd)
if logLevel := logrus.GetLevel(); logLevel < logrus.DebugLevel && term.IsTerminal(stdoutFdInt) {
if logLevel := logrus.GetLevel(); logLevel < logrus.DebugLevel && term.IsTerminal(os.Stdout) {
s.Prefix = fmt.Sprintf("Creating container %s: ", container)
s.Writer = os.Stdout
s.Start()
Expand Down Expand Up @@ -575,8 +577,8 @@ func getFullyQualifiedImageFromRepoTags(image string) (string, error) {
return imageFull, nil
}

func getImageSizeFromRegistry(imageFull string) (string, error) {
image, err := skopeo.Inspect(imageFull)
func getImageSizeFromRegistry(ctx context.Context, imageFull string) (string, error) {
image, err := skopeo.Inspect(ctx, imageFull)
if err != nil {
return "", err
}
Expand All @@ -599,6 +601,23 @@ func getImageSizeFromRegistry(imageFull string) (string, error) {
return imageSizeHuman, nil
}

func getImageSizeFromRegistryAsync(ctx context.Context, imageFull string) (<-chan string, <-chan error) {
retValCh := make(chan string)
errCh := make(chan error)

go func() {
imageSize, err := getImageSizeFromRegistry(ctx, imageFull)
if err != nil {
errCh <- err
return
}

retValCh <- imageSize
}()

return retValCh, errCh
}

func getServiceSocket(serviceName string, unitName string) (string, error) {
logrus.Debugf("Resolving path to the %s socket", serviceName)

Expand Down Expand Up @@ -711,18 +730,7 @@ func pullImage(image, release, authFile string) (bool, error) {
}

if promptForDownload {
fmt.Println("Image required to create toolbox container.")

var prompt string

if imageSize, err := getImageSizeFromRegistry(imageFull); err != nil {
logrus.Debugf("Getting image size failed: %s", err)
prompt = fmt.Sprintf("Download %s? [y/N]:", imageFull)
} else {
prompt = fmt.Sprintf("Download %s (%s)? [y/N]:", imageFull, imageSize)
}

shouldPullImage = askForConfirmation(prompt)
shouldPullImage = showPromptForDownload(imageFull)
}

if !shouldPullImage {
Expand All @@ -731,9 +739,7 @@ func pullImage(image, release, authFile string) (bool, error) {

logrus.Debugf("Pulling image %s", imageFull)

stdoutFd := os.Stdout.Fd()
stdoutFdInt := int(stdoutFd)
if logLevel := logrus.GetLevel(); logLevel < logrus.DebugLevel && term.IsTerminal(stdoutFdInt) {
if logLevel := logrus.GetLevel(); logLevel < logrus.DebugLevel && term.IsTerminal(os.Stdout) {
s := spinner.New(spinner.CharSets[9], 500*time.Millisecond)
s.Prefix = fmt.Sprintf("Pulling %s: ", imageFull)
s.Writer = os.Stdout
Expand All @@ -754,6 +760,214 @@ func pullImage(image, release, authFile string) (bool, error) {
return true, nil
}

func createPromptForDownload(imageFull, imageSize string) string {
var prompt string
if imageSize == "" {
prompt = fmt.Sprintf("Download %s? [y/N]:", imageFull)
} else {
prompt = fmt.Sprintf("Download %s (%s)? [y/N]:", imageFull, imageSize)
}

return prompt
}

func showPromptForDownloadFirst(imageFull string) (bool, error) {
prompt := createPromptForDownload(imageFull, " ... MB")

parentCtx := context.Background()
askCtx, askCancel := context.WithCancelCause(parentCtx)
defer askCancel(errors.New("clean-up"))

askCh, askErrCh := askForConfirmationAsync(askCtx, prompt, nil)

imageSizeCtx, imageSizeCancel := context.WithCancelCause(parentCtx)
defer imageSizeCancel(errors.New("clean-up"))

imageSizeCh, imageSizeErrCh := getImageSizeFromRegistryAsync(imageSizeCtx, imageFull)

var imageSize string
var shouldPullImage bool

select {
case val := <-askCh:
shouldPullImage = val
cause := fmt.Errorf("%w: received confirmation without image size", context.Canceled)
imageSizeCancel(cause)
case err := <-askErrCh:
shouldPullImage = false
cause := fmt.Errorf("failed to ask for confirmation without image size: %w", err)
imageSizeCancel(cause)
case val := <-imageSizeCh:
imageSize = val
cause := fmt.Errorf("%w: received image size", context.Canceled)
askCancel(cause)
case err := <-imageSizeErrCh:
cause := fmt.Errorf("failed to get image size: %w", err)
askCancel(cause)
}

if imageSizeCtx.Err() != nil && askCtx.Err() == nil {
cause := context.Cause(imageSizeCtx)
logrus.Debugf("Show prompt for download: image size canceled: %s", cause)
return shouldPullImage, nil
}

var done bool

if imageSizeCtx.Err() == nil && askCtx.Err() != nil {
select {
case val := <-askCh:
logrus.Debugf("Show prompt for download: received pending confirmation without image size")
shouldPullImage = val
done = true
case err := <-askErrCh:
logrus.Debugf("Show prompt for download: failed to ask for confirmation without image size: %s",
err)
}
} else {
panic("code should not be reached")
}

cause := context.Cause(askCtx)
logrus.Debugf("Show prompt for download: ask canceled: %s", cause)

if done {
return shouldPullImage, nil
}

return false, &promptForDownloadError{imageSize}
}

func showPromptForDownloadSecond(imageFull string, errFirst *promptForDownloadError) bool {
oldState, err := term.GetState(os.Stdin)
if err != nil {
logrus.Debugf("Show prompt for download: failed to get terminal state: %s", err)
return false
}

defer term.SetState(os.Stdin, oldState)

lockedState := term.NewStateFrom(oldState,
term.WithVMIN(1),
term.WithVTIME(0),
term.WithoutECHO(),
term.WithoutICANON())

if err := term.SetState(os.Stdin, lockedState); err != nil {
logrus.Debugf("Show prompt for download: failed to set terminal state: %s", err)
return false
}

parentCtx := context.Background()
discardCtx, discardCancel := context.WithCancelCause(parentCtx)
defer discardCancel(errors.New("clean-up"))

discardCh, discardErrCh := discardInputAsync(discardCtx)

var prompt string
if errors.Is(errFirst, context.Canceled) {
prompt = createPromptForDownload(imageFull, errFirst.ImageSize)
} else {
prompt = createPromptForDownload(imageFull, "")
}

fmt.Printf("\r")

askCtx, askCancel := context.WithCancelCause(parentCtx)
defer askCancel(errors.New("clean-up"))

var askForConfirmationPreFnDone bool
askForConfirmationPreFn := func() error {
defer discardCancel(errors.New("clean-up"))
if askForConfirmationPreFnDone {
return nil
}

// Erase to end of line
fmt.Printf("\033[K")

// Save the cursor position.
fmt.Printf("\033[s")

if err := term.SetState(os.Stdin, oldState); err != nil {
return fmt.Errorf("failed to restore terminal state: %w", err)
}

cause := errors.New("terminal restored")
discardCancel(cause)

err := <-discardErrCh
if !errors.Is(err, context.Canceled) {
return fmt.Errorf("failed to discard input: %w", err)
}

logrus.Debugf("Show prompt for download: stopped discarding input: %s", err)

discardTotal := <-discardCh
logrus.Debugf("Show prompt for download: discarded input: %d bytes", discardTotal)

if discardTotal == 0 {
askForConfirmationPreFnDone = true
return nil
}

if err := term.SetState(os.Stdin, lockedState); err != nil {
return fmt.Errorf("failed to set terminal state: %w", err)
}

discardCtx, discardCancel = context.WithCancelCause(parentCtx)
// A deferred call can't be used for this CancelCauseFunc,
// because the 'discard' operation needs to continue running
// until the next invocation of this function. It relies on
// the guarantee that AskForConfirmationAsync will always call
// its askForConfirmationPreFunc as long as the function
// returns errContinue.

discardCh, discardErrCh = discardInputAsync(discardCtx)

// Restore the cursor position
fmt.Printf("\033[u")

// Erase to end of line
fmt.Printf("\033[K")

fmt.Printf("...\n")
return errContinue
}

askCh, askErrCh := askForConfirmationAsync(askCtx, prompt, askForConfirmationPreFn)
var shouldPullImage bool

select {
case val := <-askCh:
logrus.Debug("Show prompt for download: received confirmation with image size")
shouldPullImage = val
case err := <-askErrCh:
logrus.Debugf("Show prompt for download: failed to ask for confirmation with image size: %s", err)
shouldPullImage = false
}

return shouldPullImage
}

func showPromptForDownload(imageFull string) bool {
fmt.Println("Image required to create toolbox container.")

shouldPullImage, err := showPromptForDownloadFirst(imageFull)
if err == nil {
return shouldPullImage
}

var errPromptForDownload *promptForDownloadError
if !errors.As(err, &errPromptForDownload) {
panicMsg := fmt.Sprintf("unexpected %T: %s", err, err)
panic(panicMsg)
}

shouldPullImage = showPromptForDownloadSecond(imageFull, errPromptForDownload)
return shouldPullImage
}

// systemdNeedsEscape checks whether a byte in a potential dbus ObjectPath needs to be escaped
func systemdNeedsEscape(i int, b byte) bool {
// Escape everything that is not a-z-A-Z-0-9
Expand Down Expand Up @@ -781,3 +995,17 @@ func systemdPathBusEscape(path string) string {
}
return string(n)
}

func (err *promptForDownloadError) Error() string {
innerErr := err.Unwrap()
errMsg := innerErr.Error()
return errMsg
}

func (err *promptForDownloadError) Unwrap() error {
if err.ImageSize == "" {
return errors.New("failed to get image size")
}

return context.Canceled
}
Loading