Skip to content

Commit 540947f

Browse files
committed
cmd/create: Don't block user interaction while fetching the image size
It takes 'skopeo inspect' a few seconds to fetch the image size from the remote registry, and while that happens the user can't interact with the image download prompt: $ toolbox create Image required to create toolbox container. <wait for a few seconds> Download registry.fedoraproject.org/fedora-toolbox:39 (359.8MB)? [y/N]: This feels awkward because it's not clear to the user what's going on during those few seconds. Moreover, while knowing the image size can be convenient at times, for example when disk space and network bandwidth are limited, it's not always important. It will be better if 'skopeo inspect' ran in the background, while waiting for the user to respond to the image download prompt, and once the image size has been fetched, the image download prompt can be updated to include it. So, initially: $ toolbox create Image required to create toolbox container. Download registry.fedoraproject.org/fedora-toolbox:39 ( ... MB)? [y/N]: ... and then once the size is available: $ toolbox create Image required to create toolbox container. Download registry.fedoraproject.org/fedora-toolbox:39 (359.8MB)? [y/N]: If skopeo(1) is missing or too old, then the prompt can continue without the size, as it did before: $ toolbox create Image required to create toolbox container. Download registry.fedoraproject.org/fedora-toolbox:39 [y/N]: The placeholder for the missing image size (ie., ' ... MB') was chosen to have seven characters, so that it matches the most common sizes. The human-readable representation of the image size is capped at four valid numbers [1]. Unless it's a perfect round number like 1KB or 1.2MB, it will likely use all four numbers and the decimal point, which is five characters. Then two more for the unit, because it's very unlikely that there will be an image that's less than 1KB in size and will be shown in bytes with a B. That makes it seven characters in total. Updating the image download prompt with the results of 'skopeo inspect' is vulnerable to races. At the same time as the terminal's cursor is being moved to the beginning of the current line to overwrite the earlier prompt with the new one, the user can keep typing and keep moving the cursor forward. This competition over the cursor can lead to awkward outcomes. For example, the prompt can overwrite the characters typed in by the user, leaving characters in the terminal's input buffer waiting for the user to hit ENTER, even though they are not visible on the screen. Another example is that hitting BACKSPACE can end up deleting parts of the prompt, instead of stopping at the edge. This is solved by putting the terminal device into non-canonical mode input and disabling the echoing of input characters, while the prompt is being updated. This prevents input from moving the terminal's cursor forward, and from accumulating in the terminal's input buffer even if it might not be visible. Any input during this interim period is discarded and replaced by '...', and a fresh new prompt is shown in the following line. In practice, this race shouldn't be too common. It can only happen if the user is typing right when the prompt is being updated, which is unlikely because it's only supposed to be a short 'yes' or 'no' input. The use of the context.Cause and context.WithCancelCause functions [2] requires Go >= 1.20. Bumping the Go version in src/go.mod then requires a 'go mod tidy'. Otherwise, it leads to: $ meson compile -C builddir --verbose ... /home/rishi/devel/containers/git/toolbox/src/go-build-wrapper /home/rishi/devel/containers/git/toolbox/src /home/rishi/devel/containers/git/toolbox/builddir src/toolbox 0.0.99.4 cc /lib64/ld-linux-x86-64.so.2 false go: updates to go.mod needed; to update it: go mod tidy ninja: build stopped: subcommand failed. [1] https://pkg.go.dev/github.com/docker/go-units#HumanSize [2] https://pkg.go.dev/context #752 #1263
1 parent 1ee33bd commit 540947f

File tree

4 files changed

+273
-21
lines changed

4 files changed

+273
-21
lines changed

.github/workflows/ubuntu-tests.yaml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ jobs:
4444
fish \
4545
gcc \
4646
go-md2man \
47-
golang \
47+
golang-1.20 \
4848
meson \
4949
ninja-build \
5050
openssl \
@@ -54,6 +54,10 @@ jobs:
5454
systemd \
5555
udisks2
5656
57+
- name: Set up PATH for Go 1.20
58+
run: |
59+
echo "PATH=/usr/lib/go-1.20/bin:$PATH" >> "$GITHUB_ENV"
60+
5761
- name: Checkout Bats
5862
uses: actions/checkout@v3
5963
with:

src/cmd/create.go

Lines changed: 245 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ import (
3737
"github.com/spf13/cobra"
3838
)
3939

40+
type promptForDownloadError struct {
41+
ImageSize string
42+
}
43+
4044
const (
4145
alpha = `abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ`
4246
num = `0123456789`
@@ -573,8 +577,7 @@ func getFullyQualifiedImageFromRepoTags(image string) (string, error) {
573577
return imageFull, nil
574578
}
575579

576-
func getImageSizeFromRegistry(imageFull string) (string, error) {
577-
ctx := context.Background()
580+
func getImageSizeFromRegistry(ctx context.Context, imageFull string) (string, error) {
578581
image, err := skopeo.Inspect(ctx, imageFull)
579582
if err != nil {
580583
return "", err
@@ -598,6 +601,23 @@ func getImageSizeFromRegistry(imageFull string) (string, error) {
598601
return imageSizeHuman, nil
599602
}
600603

604+
func getImageSizeFromRegistryAsync(ctx context.Context, imageFull string) (<-chan string, <-chan error) {
605+
retValCh := make(chan string)
606+
errCh := make(chan error)
607+
608+
go func() {
609+
imageSize, err := getImageSizeFromRegistry(ctx, imageFull)
610+
if err != nil {
611+
errCh <- err
612+
return
613+
}
614+
615+
retValCh <- imageSize
616+
}()
617+
618+
return retValCh, errCh
619+
}
620+
601621
func getServiceSocket(serviceName string, unitName string) (string, error) {
602622
logrus.Debugf("Resolving path to the %s socket", serviceName)
603623

@@ -710,18 +730,7 @@ func pullImage(image, release, authFile string) (bool, error) {
710730
}
711731

712732
if promptForDownload {
713-
fmt.Println("Image required to create toolbox container.")
714-
715-
var prompt string
716-
717-
if imageSize, err := getImageSizeFromRegistry(imageFull); err != nil {
718-
logrus.Debugf("Getting image size failed: %s", err)
719-
prompt = fmt.Sprintf("Download %s? [y/N]:", imageFull)
720-
} else {
721-
prompt = fmt.Sprintf("Download %s (%s)? [y/N]:", imageFull, imageSize)
722-
}
723-
724-
shouldPullImage = askForConfirmation(prompt)
733+
shouldPullImage = showPromptForDownload(imageFull)
725734
}
726735

727736
if !shouldPullImage {
@@ -751,6 +760,214 @@ func pullImage(image, release, authFile string) (bool, error) {
751760
return true, nil
752761
}
753762

763+
func createPromptForDownload(imageFull, imageSize string) string {
764+
var prompt string
765+
if imageSize == "" {
766+
prompt = fmt.Sprintf("Download %s? [y/N]:", imageFull)
767+
} else {
768+
prompt = fmt.Sprintf("Download %s (%s)? [y/N]:", imageFull, imageSize)
769+
}
770+
771+
return prompt
772+
}
773+
774+
func showPromptForDownloadFirst(imageFull string) (bool, error) {
775+
prompt := createPromptForDownload(imageFull, " ... MB")
776+
777+
parentCtx := context.Background()
778+
askCtx, askCancel := context.WithCancelCause(parentCtx)
779+
defer askCancel(errors.New("clean-up"))
780+
781+
askCh, askErrCh := askForConfirmationAsync(askCtx, prompt, nil)
782+
783+
imageSizeCtx, imageSizeCancel := context.WithCancelCause(parentCtx)
784+
defer imageSizeCancel(errors.New("clean-up"))
785+
786+
imageSizeCh, imageSizeErrCh := getImageSizeFromRegistryAsync(imageSizeCtx, imageFull)
787+
788+
var imageSize string
789+
var shouldPullImage bool
790+
791+
select {
792+
case val := <-askCh:
793+
shouldPullImage = val
794+
cause := fmt.Errorf("%w: received confirmation without image size", context.Canceled)
795+
imageSizeCancel(cause)
796+
case err := <-askErrCh:
797+
shouldPullImage = false
798+
cause := fmt.Errorf("failed to ask for confirmation without image size: %w", err)
799+
imageSizeCancel(cause)
800+
case val := <-imageSizeCh:
801+
imageSize = val
802+
cause := fmt.Errorf("%w: received image size", context.Canceled)
803+
askCancel(cause)
804+
case err := <-imageSizeErrCh:
805+
cause := fmt.Errorf("failed to get image size: %w", err)
806+
askCancel(cause)
807+
}
808+
809+
if imageSizeCtx.Err() != nil && askCtx.Err() == nil {
810+
cause := context.Cause(imageSizeCtx)
811+
logrus.Debugf("Show prompt for download: image size canceled: %s", cause)
812+
return shouldPullImage, nil
813+
}
814+
815+
var done bool
816+
817+
if imageSizeCtx.Err() == nil && askCtx.Err() != nil {
818+
select {
819+
case val := <-askCh:
820+
logrus.Debugf("Show prompt for download: received pending confirmation without image size")
821+
shouldPullImage = val
822+
done = true
823+
case err := <-askErrCh:
824+
logrus.Debugf("Show prompt for download: failed to ask for confirmation without image size: %s",
825+
err)
826+
}
827+
} else {
828+
panic("code should not be reached")
829+
}
830+
831+
cause := context.Cause(askCtx)
832+
logrus.Debugf("Show prompt for download: ask canceled: %s", cause)
833+
834+
if done {
835+
return shouldPullImage, nil
836+
}
837+
838+
return false, &promptForDownloadError{imageSize}
839+
}
840+
841+
func showPromptForDownloadSecond(imageFull string, errFirst *promptForDownloadError) bool {
842+
oldState, err := term.GetState(os.Stdin)
843+
if err != nil {
844+
logrus.Debugf("Show prompt for download: failed to get terminal state: %s", err)
845+
return false
846+
}
847+
848+
defer term.SetState(os.Stdin, oldState)
849+
850+
lockedState := term.NewStateFrom(oldState,
851+
term.WithVMIN(1),
852+
term.WithVTIME(0),
853+
term.WithoutECHO(),
854+
term.WithoutICANON())
855+
856+
if err := term.SetState(os.Stdin, lockedState); err != nil {
857+
logrus.Debugf("Show prompt for download: failed to set terminal state: %s", err)
858+
return false
859+
}
860+
861+
parentCtx := context.Background()
862+
discardCtx, discardCancel := context.WithCancelCause(parentCtx)
863+
defer discardCancel(errors.New("clean-up"))
864+
865+
discardCh, discardErrCh := discardInputAsync(discardCtx)
866+
867+
var prompt string
868+
if errors.Is(errFirst, context.Canceled) {
869+
prompt = createPromptForDownload(imageFull, errFirst.ImageSize)
870+
} else {
871+
prompt = createPromptForDownload(imageFull, "")
872+
}
873+
874+
fmt.Printf("\r")
875+
876+
askCtx, askCancel := context.WithCancelCause(parentCtx)
877+
defer askCancel(errors.New("clean-up"))
878+
879+
var askForConfirmationPreFnDone bool
880+
askForConfirmationPreFn := func() error {
881+
defer discardCancel(errors.New("clean-up"))
882+
if askForConfirmationPreFnDone {
883+
return nil
884+
}
885+
886+
// Erase to end of line
887+
fmt.Printf("\033[K")
888+
889+
// Save the cursor position.
890+
fmt.Printf("\033[s")
891+
892+
if err := term.SetState(os.Stdin, oldState); err != nil {
893+
return fmt.Errorf("failed to restore terminal state: %w", err)
894+
}
895+
896+
cause := errors.New("terminal restored")
897+
discardCancel(cause)
898+
899+
err := <-discardErrCh
900+
if !errors.Is(err, context.Canceled) {
901+
return fmt.Errorf("failed to discard input: %w", err)
902+
}
903+
904+
logrus.Debugf("Show prompt for download: stopped discarding input: %s", err)
905+
906+
discardTotal := <-discardCh
907+
logrus.Debugf("Show prompt for download: discarded input: %d bytes", discardTotal)
908+
909+
if discardTotal == 0 {
910+
askForConfirmationPreFnDone = true
911+
return nil
912+
}
913+
914+
if err := term.SetState(os.Stdin, lockedState); err != nil {
915+
return fmt.Errorf("failed to set terminal state: %w", err)
916+
}
917+
918+
discardCtx, discardCancel = context.WithCancelCause(parentCtx)
919+
// A deferred call can't be used for this CancelCauseFunc,
920+
// because the 'discard' operation needs to continue running
921+
// until the next invocation of this function. It relies on
922+
// the guarantee that AskForConfirmationAsync will always call
923+
// its askForConfirmationPreFunc as long as the function
924+
// returns errContinue.
925+
926+
discardCh, discardErrCh = discardInputAsync(discardCtx)
927+
928+
// Restore the cursor position
929+
fmt.Printf("\033[u")
930+
931+
// Erase to end of line
932+
fmt.Printf("\033[K")
933+
934+
fmt.Printf("...\n")
935+
return errContinue
936+
}
937+
938+
askCh, askErrCh := askForConfirmationAsync(askCtx, prompt, askForConfirmationPreFn)
939+
var shouldPullImage bool
940+
941+
select {
942+
case val := <-askCh:
943+
logrus.Debug("Show prompt for download: received confirmation with image size")
944+
shouldPullImage = val
945+
case err := <-askErrCh:
946+
logrus.Debugf("Show prompt for download: failed to ask for confirmation with image size: %s", err)
947+
shouldPullImage = false
948+
}
949+
950+
return shouldPullImage
951+
}
952+
953+
func showPromptForDownload(imageFull string) bool {
954+
fmt.Println("Image required to create toolbox container.")
955+
956+
shouldPullImage, err := showPromptForDownloadFirst(imageFull)
957+
if err == nil {
958+
return shouldPullImage
959+
}
960+
961+
var errPromptForDownload *promptForDownloadError
962+
if !errors.As(err, &errPromptForDownload) {
963+
panicMsg := fmt.Sprintf("unexpected %T: %s", err, err)
964+
panic(panicMsg)
965+
}
966+
967+
shouldPullImage = showPromptForDownloadSecond(imageFull, errPromptForDownload)
968+
return shouldPullImage
969+
}
970+
754971
// systemdNeedsEscape checks whether a byte in a potential dbus ObjectPath needs to be escaped
755972
func systemdNeedsEscape(i int, b byte) bool {
756973
// Escape everything that is not a-z-A-Z-0-9
@@ -778,3 +995,17 @@ func systemdPathBusEscape(path string) string {
778995
}
779996
return string(n)
780997
}
998+
999+
func (err *promptForDownloadError) Error() string {
1000+
innerErr := err.Unwrap()
1001+
errMsg := innerErr.Error()
1002+
return errMsg
1003+
}
1004+
1005+
func (err *promptForDownloadError) Unwrap() error {
1006+
if err.ImageSize == "" {
1007+
return errors.New("failed to get image size")
1008+
}
1009+
1010+
return context.Canceled
1011+
}

src/go.mod

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module github.com/containers/toolbox
22

3-
go 1.14
3+
go 1.20
44

55
require (
66
github.com/HarryMichal/go-version v1.0.1
@@ -15,3 +15,25 @@ require (
1515
github.com/stretchr/testify v1.7.0
1616
golang.org/x/sys v0.1.0
1717
)
18+
19+
require (
20+
github.com/davecgh/go-spew v1.1.1 // indirect
21+
github.com/fatih/color v1.13.0 // indirect
22+
github.com/hashicorp/hcl v1.0.0 // indirect
23+
github.com/inconshreveable/mousetrap v1.0.0 // indirect
24+
github.com/magiconair/properties v1.8.5 // indirect
25+
github.com/mattn/go-colorable v0.1.12 // indirect
26+
github.com/mattn/go-isatty v0.0.14 // indirect
27+
github.com/mitchellh/mapstructure v1.4.3 // indirect
28+
github.com/pelletier/go-toml v1.9.4 // indirect
29+
github.com/pmezard/go-difflib v1.0.0 // indirect
30+
github.com/spf13/afero v1.6.0 // indirect
31+
github.com/spf13/cast v1.4.1 // indirect
32+
github.com/spf13/jwalterweatherman v1.1.0 // indirect
33+
github.com/spf13/pflag v1.0.5 // indirect
34+
github.com/subosito/gotenv v1.2.0 // indirect
35+
golang.org/x/text v0.3.7 // indirect
36+
gopkg.in/ini.v1 v1.66.2 // indirect
37+
gopkg.in/yaml.v2 v2.4.0 // indirect
38+
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
39+
)

src/go.sum

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,6 @@ github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pf
203203
github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM=
204204
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
205205
github.com/hashicorp/consul/api v1.11.0/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M=
206-
github.com/hashicorp/consul/api v1.12.0/go.mod h1:6pVBMo0ebnYdt2S3H87XhekM/HHrUoTD2XXb/VrZVy0=
207206
github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms=
208207
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
209208
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
319318
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
320319
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
321320
github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig=
322-
github.com/sagikazarmark/crypt v0.4.0/go.mod h1:ALv2SRj7GxYV4HO9elxH9nS6M9gW+xDNxqmyJ6RfDFM=
323321
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
324322
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
325323
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
551549
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
552550
golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
553551
golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
554-
golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
555552
golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
556553
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
557554
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
658655
google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU=
659656
google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I=
660657
google.golang.org/api v0.62.0/go.mod h1:dKmwPCydfsad4qCH08MSdgWjfHOyfpd4VtDGgRFdavw=
661-
google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo=
662658
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
663659
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
664660
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
756752
google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
757753
google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
758754
google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
759-
google.golang.org/grpc v1.43.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
760755
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
761756
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
762757
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=

0 commit comments

Comments
 (0)