diff --git a/cmd/microcloud/ask.go b/cmd/microcloud/ask.go index e710458e1..9ea08086c 100644 --- a/cmd/microcloud/ask.go +++ b/cmd/microcloud/ask.go @@ -146,7 +146,7 @@ func (c *initConfig) askRetry(question string, f func() error) error { return err } - fmt.Printf("%s %s\n", tui.ErrorSymbol(), tui.ErrorColor(err.Error(), true)) + tui.PrintError(err.Error()) retry, err = c.asker.AskBool(question, true) if err != nil { return err @@ -645,7 +645,7 @@ func (c *initConfig) askRemotePool(sh *service.Handler) error { } if availableDiskCount == 0 { - fmt.Println(tui.WarningColor("Warning: No disks available for distributed storage. Skipping configuration", false)) + tui.PrintWarning("No disks available for distributed storage. Skipping configuration") return nil } @@ -1125,7 +1125,7 @@ func (c *initConfig) askOVNNetwork(sh *service.Handler) error { canOVNUnderlay := true for peer, system := range c.systems { if len(c.state[system.ServerInfo.Name].AvailableOVNInterfaces) == 0 { - fmt.Println(tui.WarningColor(fmt.Sprintf("Not enough interfaces available on %s to create an underlay network, skipping", peer), false)) + tui.PrintWarning(fmt.Sprintf("Not enough interfaces available on %s to create an underlay network. Skipping configuration", peer)) canOVNUnderlay = false break } @@ -1323,7 +1323,7 @@ func (c *initConfig) askCephNetwork(sh *service.Handler) error { availableCephNetworkInterfaces := map[string]map[string]service.DedicatedInterface{} for name, state := range c.state { if len(state.AvailableCephInterfaces) == 0 { - fmt.Println(tui.WarningColor(fmt.Sprintf("No network interfaces found with IPs on %q to set a dedicated Ceph network, skipping Ceph network setup", name), false)) + tui.PrintWarning(fmt.Sprintf("No network interfaces found with IPs on %q to set up a dedicated Ceph network. Skipping Ceph network setup", name)) return nil } @@ -1657,7 +1657,7 @@ func (c *initConfig) askJoinConfirmation(gw *cloudClient.WebsocketGateway, servi fmt.Println(tui.SummarizeResult("Received confirmation from system %s", session.Intent.Name)) fmt.Println("") fmt.Println(tui.Note(tui.Yellow, tui.WarningSymbol()+tui.SetColor(tui.Bright, " Do not exit out to keep the session alive", true)) + "\n") - fmt.Println(tui.Printf(tui.Fmt{Arg: "Complete the remaining configuration on %s ..."}, tui.Fmt{Arg: session.Intent.Name, Color: tui.Yellow, Bold: true})) + fmt.Println(tui.Printf(tui.Fmt{Arg: "Complete the remaining configuration on %s ..."}, tui.Fmt{Arg: session.Intent.Name, Bold: true})) } err = gw.ReceiveWithContext(gw.Context(), &session) diff --git a/cmd/microcloud/preseed.go b/cmd/microcloud/preseed.go index dd1586bd6..b26c0e092 100644 --- a/cmd/microcloud/preseed.go +++ b/cmd/microcloud/preseed.go @@ -24,6 +24,7 @@ import ( "github.com/canonical/microcloud/microcloud/api" "github.com/canonical/microcloud/microcloud/api/types" cloudClient "github.com/canonical/microcloud/microcloud/client" + "github.com/canonical/microcloud/microcloud/cmd/tui" "github.com/canonical/microcloud/microcloud/multicast" "github.com/canonical/microcloud/microcloud/service" ) @@ -695,7 +696,7 @@ func (p *Preseed) Parse(s *service.Handler, c *initConfig, installedServices map for serviceType, cluster := range existingClusters { if len(cluster) > 0 { - fmt.Printf("Existing %s cluster is incompatible with MicroCloud, skipping %s setup\n", serviceType, serviceType) + tui.PrintWarning(fmt.Sprintf("Existing %s cluster is incompatible with MicroCloud. Skipping %s setup", serviceType, serviceType)) delete(s.Services, serviceType) } diff --git a/cmd/microcloud/session.go b/cmd/microcloud/session.go index 51f7b8deb..b7d5a349e 100644 --- a/cmd/microcloud/session.go +++ b/cmd/microcloud/session.go @@ -201,8 +201,8 @@ func (c *initConfig) joiningSession(gw *cloudClient.WebsocketGateway, sh *servic fmt.Printf("\n%s %s\n\n", tmpl, fingerprintArg) tmplArg := tui.Fmt{Arg: "Select %s on %s to let it join the cluster"} - localArg := tui.Fmt{Arg: sh.Name, Color: tui.Yellow, Bold: true} - remoteArg := tui.Fmt{Arg: session.InitiatorName, Color: tui.Yellow, Bold: true} + localArg := tui.Fmt{Arg: sh.Name, Bold: true} + remoteArg := tui.Fmt{Arg: session.InitiatorName, Bold: true} fmt.Println(tui.Printf(tmplArg, localArg, remoteArg)) } diff --git a/cmd/tui/handler.go b/cmd/tui/handler.go index a3e3a933e..01df497c3 100644 --- a/cmd/tui/handler.go +++ b/cmd/tui/handler.go @@ -4,16 +4,19 @@ import ( "bufio" "fmt" "os" + "slices" "strings" "sync" - "github.com/canonical/lxd/shared/cmd" tea "github.com/charmbracelet/bubbletea" ) // ContextError is the charmbracelet representation of a context cancellation error. var ContextError error = tea.ErrProgramKilled +// InvalidInputError is used to indicate false input to an asked question. +var InvalidInputError func() = func() { PrintError("Invalid input, try again") } + // InputHandler handles input dialogs. type InputHandler struct { input *os.File @@ -58,11 +61,6 @@ func (i *InputHandler) countAllRows() int { return i.table.countRawRows() } -// printWarning prints the given warning with "!" appended to the front of the message. -func (i *InputHandler) printWarning(warning string) { - fmt.Printf("%s %s\n", WarningSymbol(), warning) -} - // formatQuestion enriches the plain question string with default and accepted answers. func (i *InputHandler) formatQuestion(question string, defaultAnswer string, acceptedAnswers []string) string { var acceptedAnswersBlock string @@ -78,9 +76,27 @@ func (i *InputHandler) formatQuestion(question string, defaultAnswer string, acc return fmt.Sprintf("%s%s%s: ", question, acceptedAnswersBlock, defaultAnswerBlock) } +// Ask a question on the output stream and read the answer from the input stream. +func (i *InputHandler) askQuestion(question, defaultAnswer string) (string, error) { + fmt.Print(question) + + return i.readAnswer(defaultAnswer) +} + +// Read the user's answer from the input stream, trimming newline and providing a default. +func (i *InputHandler) readAnswer(defaultAnswer string) (string, error) { + answer, err := bufio.NewReader(i.input).ReadString('\n') + answer = strings.TrimSpace(strings.TrimSuffix(answer, "\n")) + if answer == "" { + answer = defaultAnswer + } + + return answer, err +} + // AskBoolWarn is the same as AskBool but it prints the given warning before asking. func (i *InputHandler) AskBoolWarn(warning string, question string, defaultAnswer bool) (bool, error) { - i.printWarning(warning) + PrintWarning(warning) return i.AskBool(question, defaultAnswer) } @@ -93,13 +109,25 @@ func (i *InputHandler) AskBool(question string, defaultAnswer bool) (bool, error defaultAnswerStr = "yes" } - asker := cmd.NewAsker(bufio.NewReader(i.input), nil) - return asker.AskBool(i.formatQuestion(question, defaultAnswerStr, []string{"yes", "no"}), defaultAnswerStr) + for { + answer, err := i.askQuestion(i.formatQuestion(question, defaultAnswerStr, []string{"yes", "no"}), defaultAnswerStr) + if err != nil { + return false, err + } + + if slices.Contains([]string{"yes", "y"}, strings.ToLower(answer)) { + return true, nil + } else if slices.Contains([]string{"no", "n"}, strings.ToLower(answer)) { + return false, nil + } + + InvalidInputError() + } } // AskStringWarn is the same as AskString but it prints the given warning before asking. func (i *InputHandler) AskStringWarn(warning string, question string, defaultAnswer string, validator func(string) error) (string, error) { - i.printWarning(warning) + PrintWarning(warning) return i.AskString(question, defaultAnswer, validator) } @@ -108,11 +136,26 @@ func (i *InputHandler) AskString(question string, defaultAnswer string, validato i.setActive(true) defer i.setActive(false) - asker := cmd.NewAsker(bufio.NewReader(i.input), nil) - result, err := asker.AskString(i.formatQuestion(question, defaultAnswer, nil), defaultAnswer, validator) - if err != nil { - return "", err - } + for { + answer, err := i.askQuestion(i.formatQuestion(question, defaultAnswer, nil), defaultAnswer) + if err != nil { + return "", err + } - return result, nil + if validator != nil { + err = validator(answer) + if err != nil { + InvalidInputError() + continue + } + + return answer, err + } + + if len(answer) != 0 { + return answer, err + } + + InvalidInputError() + } } diff --git a/cmd/tui/selectable_table.go b/cmd/tui/selectable_table.go index ca4efc4dd..fd3b88634 100644 --- a/cmd/tui/selectable_table.go +++ b/cmd/tui/selectable_table.go @@ -90,7 +90,7 @@ type selectableTable struct { func SummarizeResult(tmpl string, args ...any) string { fmtArgs := []Fmt{} for _, arg := range args { - fmtArgs = append(fmtArgs, Fmt{Arg: arg, Color: Yellow, Bold: true}) + fmtArgs = append(fmtArgs, Fmt{Arg: arg, Bold: true}) } return Printf(Fmt{Arg: " " + tmpl, Color: White}, fmtArgs...) diff --git a/cmd/tui/style.go b/cmd/tui/style.go index 0616bf1fa..2231d7d74 100644 --- a/cmd/tui/style.go +++ b/cmd/tui/style.go @@ -106,12 +106,30 @@ type ColorErr struct{} // Write colors the given error red and sends it to os.Stderr. func (*ColorErr) Write(p []byte) (n int, err error) { - return os.Stderr.WriteString(ErrorColor(strings.TrimSpace(string(p)), false) + "\n") + trimmedErr := strings.TrimSpace(string(p)) + + // Cobra allows setting the error prefix using SetErrPrefix() func but + // it ignores the setting when assigning an empty string. + // Therefore we can only trim the prefix to be able to set our own + // customized error prefix using tui styling. + withoutPrefixErr := strings.TrimPrefix(trimmedErr, "Error: ") + + return os.Stderr.WriteString(SprintError(withoutPrefixErr)) } // PrintWarning calls Println but it appends "! Warning:" to the front of the message. func PrintWarning(s string) { - fmt.Printf("%s %s: %s\n", WarningSymbol(), WarningColor("Warning", true), s) + fmt.Println(WarningSymbol(), WarningColor("Warning:", true), WarningColor(s, false)) +} + +// SprintError crafts the error string without writing it to any output yet. +func SprintError(s string) string { + return fmt.Sprintln(ErrorSymbol(), ErrorColor("Error:", true), ErrorColor(s, false)) +} + +// PrintError calls Println but it appends "тип Error:" to the front of the message. +func PrintError(s string) { + fmt.Print(SprintError(s)) } // Fmt represents the data supplied to ColorPrintf. In particular, it takes a color to apply to the text, and the text itself. diff --git a/service/lxd.go b/service/lxd.go index 5a4197943..e5ffaaa5c 100644 --- a/service/lxd.go +++ b/service/lxd.go @@ -643,7 +643,7 @@ func (s *LXDService) ValidateCephInterfaces(cephNetworkSubnetStr string, peerInt } if len(data) == 0 { - fmt.Println(tui.WarningColor("No network interfaces found with IPs in the specified subnet, skipping Ceph network setup", false)) + tui.PrintWarning("No network interfaces found with IPs in the specified subnet. Skipping Ceph network setup") } return data, nil diff --git a/service/version.go b/service/version.go index bbfe94e43..36ab4773e 100644 --- a/service/version.go +++ b/service/version.go @@ -37,7 +37,7 @@ func validateVersion(serviceType types.ServiceType, daemonVersion string) error // Print a warning in case a non-LTS version of LXD is used. if semver.Compare(semver.MajorMinor(lxdVersion), semver.MajorMinor(expectedVersion)) == 1 { - fmt.Printf("%s Discovered non-LTS version %q of LXD\n", tui.WarningSymbol(), daemonVersion) + tui.PrintWarning(fmt.Sprintf("Discovered non-LTS version %q of LXD", daemonVersion)) } case types.MicroOVN: