diff --git a/.github/workflows/ubuntu-tests.yaml b/.github/workflows/ubuntu-tests.yaml index ac8355de8..5088a4c23 100644 --- a/.github/workflows/ubuntu-tests.yaml +++ b/.github/workflows/ubuntu-tests.yaml @@ -44,7 +44,7 @@ jobs: fish \ gcc \ go-md2man \ - golang \ + golang-1.20 \ meson \ ninja-build \ openssl \ @@ -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: diff --git a/src/cmd/create.go b/src/cmd/create.go index 7b7849bbf..44858d809 100644 --- a/src/cmd/create.go +++ b/src/cmd/create.go @@ -37,6 +37,16 @@ import ( "github.com/spf13/cobra" ) +type promptForDownloadError struct { + ImageSize string +} + +type promptOptions struct { + UsePlaceholder bool +} + +type promptOption func(*promptOptions) + const ( alpha = `abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ` num = `0123456789` @@ -573,8 +583,7 @@ func getFullyQualifiedImageFromRepoTags(image string) (string, error) { return imageFull, nil } -func getImageSizeFromRegistry(imageFull string) (string, error) { - ctx := context.Background() +func getImageSizeFromRegistry(ctx context.Context, imageFull string) (string, error) { image, err := skopeo.Inspect(ctx, imageFull) if err != nil { return "", err @@ -598,6 +607,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) @@ -710,18 +736,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 { @@ -751,6 +766,248 @@ func pullImage(image, release, authFile string) (bool, error) { return true, nil } +func createPromptForDownload(imageFull, imageSize string, options ...promptOption) string { + o := &promptOptions{} + for _, option := range options { + option(o) + } + + if imageSize == "" { + if o.UsePlaceholder { + imageSize = " ... MB" + } else { + prompt := fmt.Sprintf("Download %s? [y/N]:", imageFull) + return prompt + } + } + + imageSizeLen := len(imageSize) + var padding1 int + var padding2 int + var padding3 int + var padding4 int + + if imageSizeLen < 7 { + padding4 = 7 - imageSizeLen + } + + if padding4 > 1 { + padding3 = padding4 - 1 + padding4 = 1 + } + + if padding3 > 1 { + padding2 = padding3 - 1 + padding3 = 1 + } + + if padding2 > 1 { + padding1 = padding2 - 1 + padding2 = 1 + } + + prompt := fmt.Sprintf("Download %s (%*s%s%*s)? %*s[y/N]:%*s", + imageFull, + padding1, "", + imageSize, + padding2, "", + padding3, "", + padding4, "") + + return prompt +} + +func showPromptForDownloadFirst(imageFull string) (bool, error) { + withPlaceholder := func(o *promptOptions) { + o.UsePlaceholder = true + } + + prompt := createPromptForDownload(imageFull, "", withPlaceholder) + + 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 + } + + // 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) + 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 @@ -778,3 +1035,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 +} diff --git a/src/go.mod b/src/go.mod index ac416b74f..6c50ada92 100644 --- a/src/go.mod +++ b/src/go.mod @@ -1,6 +1,6 @@ module github.com/containers/toolbox -go 1.14 +go 1.20 require ( github.com/HarryMichal/go-version v1.0.1 @@ -15,3 +15,25 @@ require ( github.com/stretchr/testify v1.7.0 golang.org/x/sys v0.1.0 ) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fatih/color v1.13.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.0.0 // indirect + github.com/magiconair/properties v1.8.5 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect + github.com/mitchellh/mapstructure v1.4.3 // indirect + github.com/pelletier/go-toml v1.9.4 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/afero v1.6.0 // indirect + github.com/spf13/cast v1.4.1 // indirect + github.com/spf13/jwalterweatherman v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.2.0 // indirect + golang.org/x/text v0.3.7 // indirect + gopkg.in/ini.v1 v1.66.2 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect +) diff --git a/src/go.sum b/src/go.sum index f4dea714f..3ff47add5 100644 --- a/src/go.sum +++ b/src/go.sum @@ -203,7 +203,6 @@ github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pf github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/hashicorp/consul/api v1.11.0/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M= -github.com/hashicorp/consul/api v1.12.0/go.mod h1:6pVBMo0ebnYdt2S3H87XhekM/HHrUoTD2XXb/VrZVy0= github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= @@ -319,7 +318,6 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig= -github.com/sagikazarmark/crypt v0.4.0/go.mod h1:ALv2SRj7GxYV4HO9elxH9nS6M9gW+xDNxqmyJ6RfDFM= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= @@ -551,7 +549,6 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -658,7 +655,6 @@ google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdr google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU= google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I= google.golang.org/api v0.62.0/go.mod h1:dKmwPCydfsad4qCH08MSdgWjfHOyfpd4VtDGgRFdavw= -google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -756,7 +752,6 @@ google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnD google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= -google.golang.org/grpc v1.43.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=