From bdbd76162c8bbe158d8c90db81825cf95ade173d Mon Sep 17 00:00:00 2001 From: Maxim Kondratenko Date: Mon, 27 Jan 2025 22:13:29 +0200 Subject: [PATCH 01/30] EVEREST-1815 Replase AlecAivazis/survey lib with charmbracelet/bubbletea --- commands/accounts/create.go | 39 +- commands/accounts/delete.go | 22 +- commands/accounts/initial_admin_password.go | 5 +- commands/accounts/list.go | 5 +- commands/accounts/reset_jwt_keys.go | 5 +- commands/accounts/set_password.go | 39 +- commands/install.go | 87 ++- commands/namespaces/add.go | 58 +- commands/namespaces/remove.go | 18 +- commands/namespaces/update.go | 51 +- commands/settings/oidc/configure.go | 32 +- commands/settings/rbac/can.go | 5 +- commands/uninstall.go | 2 +- commands/upgrade.go | 2 +- go.mod | 30 +- go.sum | 44 +- .../server/handlers/k8s/database_engine.go | 2 +- pkg/accounts/cli/accounts.go | 208 ++---- pkg/accounts/cli/accounts_test.go | 59 +- pkg/accounts/cli/utils.go | 122 ++++ pkg/cli/helm/installer.go | 99 +-- pkg/cli/helm/values.go | 2 +- pkg/cli/install/install.go | 297 ++++---- pkg/cli/install/install_test.go | 4 +- pkg/cli/install/steps.go | 18 +- pkg/cli/namespaces/add.go | 632 ++++++++---------- pkg/cli/namespaces/add_test.go | 118 +++- pkg/cli/namespaces/errors.go | 84 +++ pkg/cli/namespaces/remove.go | 165 +++-- pkg/cli/namespaces/utils.go | 133 ++++ pkg/cli/steps/steps.go | 40 +- pkg/cli/tui/common.go | 92 +++ pkg/cli/tui/confirm.go | 165 +++++ pkg/cli/tui/input.go | 206 ++++++ pkg/cli/tui/input_password.go | 191 ++++++ pkg/cli/tui/multi_select.go | 241 +++++++ pkg/cli/tui/spinner.go | 244 +++++++ pkg/cli/uninstall/uninstall.go | 77 +-- pkg/cli/upgrade/upgrade.go | 4 +- pkg/common/constants.go | 11 + pkg/oidc/configure.go | 155 +++-- pkg/output/command.go | 59 +- 42 files changed, 2810 insertions(+), 1062 deletions(-) create mode 100644 pkg/accounts/cli/utils.go create mode 100644 pkg/cli/namespaces/errors.go create mode 100644 pkg/cli/namespaces/utils.go create mode 100644 pkg/cli/tui/common.go create mode 100644 pkg/cli/tui/confirm.go create mode 100644 pkg/cli/tui/input.go create mode 100644 pkg/cli/tui/input_password.go create mode 100644 pkg/cli/tui/multi_select.go create mode 100644 pkg/cli/tui/spinner.go diff --git a/commands/accounts/create.go b/commands/accounts/create.go index d7c276ffb..85f3fb11c 100644 --- a/commands/accounts/create.go +++ b/commands/accounts/create.go @@ -26,6 +26,7 @@ import ( accountscli "github.com/percona/everest/pkg/accounts/cli" "github.com/percona/everest/pkg/cli" "github.com/percona/everest/pkg/logger" + "github.com/percona/everest/pkg/output" ) var ( @@ -53,17 +54,51 @@ func accountsCreatePreRun(cmd *cobra.Command, _ []string) { //nolint:revive // Copy global flags to config accountsCreateCfg.Pretty = !(cmd.Flag(cli.FlagVerbose).Changed || cmd.Flag(cli.FlagJSON).Changed) accountsCreateCfg.KubeconfigPath = cmd.Flag(cli.FlagKubeconfig).Value.String() + + // Check username + if accountsCreateOpts.Username != "" { + // Validate provided username for new account. + if err := accountscli.ValidateUsername(accountsCreateOpts.Username); err != nil { + output.PrintError(err, logger.GetLogger(), accountsCreateCfg.Pretty) + os.Exit(1) + } + } else { + // Ask user in interactive mode to provide username for new account. + if username, err := accountscli.PopulateUsername(cmd.Context()); err != nil { + output.PrintError(err, logger.GetLogger(), accountsCreateCfg.Pretty) + os.Exit(1) + } else { + accountsCreateOpts.Username = username + } + } + + // Check password + if accountsCreateOpts.Password != "" { + // Validate provided password for new account. + if err := accountscli.ValidatePassword(accountsCreateOpts.Password); err != nil { + output.PrintError(err, logger.GetLogger(), accountsCreateCfg.Pretty) + os.Exit(1) + } + } else { + // Ask user in interactive mode to provide password for new account. + if password, err := accountscli.PopulatePassword(cmd.Context()); err != nil { + output.PrintError(err, logger.GetLogger(), accountsCreateCfg.Pretty) + os.Exit(1) + } else { + accountsCreateOpts.Password = password + } + } } func accountsCreateRun(cmd *cobra.Command, _ []string) { //nolint:revive cliA, err := accountscli.NewAccounts(*accountsCreateCfg, logger.GetLogger()) if err != nil { - logger.GetLogger().Error(err) + output.PrintError(err, logger.GetLogger(), accountsCreateCfg.Pretty) os.Exit(1) } if err := cliA.Create(cmd.Context(), *accountsCreateOpts); err != nil { - logger.GetLogger().Error(err) + output.PrintError(err, logger.GetLogger(), accountsCreateCfg.Pretty) os.Exit(1) } } diff --git a/commands/accounts/delete.go b/commands/accounts/delete.go index 6c2929630..bc1716d3c 100644 --- a/commands/accounts/delete.go +++ b/commands/accounts/delete.go @@ -24,6 +24,7 @@ import ( accountscli "github.com/percona/everest/pkg/accounts/cli" "github.com/percona/everest/pkg/cli" "github.com/percona/everest/pkg/logger" + "github.com/percona/everest/pkg/output" ) var ( @@ -49,17 +50,34 @@ func accountsDeletePreRun(cmd *cobra.Command, _ []string) { //nolint:revive // Copy global flags to config accountsDeleteCfg.Pretty = !(cmd.Flag(cli.FlagVerbose).Changed || cmd.Flag(cli.FlagJSON).Changed) accountsDeleteCfg.KubeconfigPath = cmd.Flag(cli.FlagKubeconfig).Value.String() + + // Check username + if accountsDeleteUsername != "" { + // Validate provided username for new account. + if err := accountscli.ValidateUsername(accountsDeleteUsername); err != nil { + output.PrintError(err, logger.GetLogger(), accountsDeleteCfg.Pretty) + os.Exit(1) + } + } else { + // Ask user in interactive mode to provide username for new account. + if username, err := accountscli.PopulateUsername(cmd.Context()); err != nil { + output.PrintError(err, logger.GetLogger(), accountsDeleteCfg.Pretty) + os.Exit(1) + } else { + accountsDeleteUsername = username + } + } } func accountsDeleteRun(cmd *cobra.Command, _ []string) { //nolint:revive cliA, err := accountscli.NewAccounts(*accountsDeleteCfg, logger.GetLogger()) if err != nil { - logger.GetLogger().Error(err) + output.PrintError(err, logger.GetLogger(), accountsDeleteCfg.Pretty) os.Exit(1) } if err := cliA.Delete(cmd.Context(), accountsDeleteUsername); err != nil { - logger.GetLogger().Error(err) + output.PrintError(err, logger.GetLogger(), accountsDeleteCfg.Pretty) os.Exit(1) } } diff --git a/commands/accounts/initial_admin_password.go b/commands/accounts/initial_admin_password.go index c57039be0..5313b4386 100644 --- a/commands/accounts/initial_admin_password.go +++ b/commands/accounts/initial_admin_password.go @@ -25,6 +25,7 @@ import ( accountscli "github.com/percona/everest/pkg/accounts/cli" "github.com/percona/everest/pkg/cli" "github.com/percona/everest/pkg/logger" + "github.com/percona/everest/pkg/output" ) var ( @@ -49,13 +50,13 @@ func accountsInitAdminPasswdPreRun(cmd *cobra.Command, _ []string) { //nolint:re func accountsInitAdminPasswdRun(cmd *cobra.Command, _ []string) { //nolint:revive cliA, err := accountscli.NewAccounts(*accountsInitAdminPasswdCfg, logger.GetLogger()) if err != nil { - logger.GetLogger().Error(err) + output.PrintError(err, logger.GetLogger(), accountsInitAdminPasswdCfg.Pretty) os.Exit(1) } passwordHash, err := cliA.GetInitAdminPassword(cmd.Context()) if err != nil { - logger.GetLogger().Error(err) + output.PrintError(err, logger.GetLogger(), accountsInitAdminPasswdCfg.Pretty) os.Exit(1) } diff --git a/commands/accounts/list.go b/commands/accounts/list.go index 475b53a6a..098bd591d 100644 --- a/commands/accounts/list.go +++ b/commands/accounts/list.go @@ -25,6 +25,7 @@ import ( accountscli "github.com/percona/everest/pkg/accounts/cli" "github.com/percona/everest/pkg/cli" "github.com/percona/everest/pkg/logger" + "github.com/percona/everest/pkg/output" ) var ( @@ -60,12 +61,12 @@ func accountsListPreRun(cmd *cobra.Command, _ []string) { //nolint:revive func accountsListRun(cmd *cobra.Command, _ []string) { //nolint:revive cliA, err := accountscli.NewAccounts(*accountsListCfg, logger.GetLogger()) if err != nil { - logger.GetLogger().Error(err) + output.PrintError(err, logger.GetLogger(), accountsListCfg.Pretty) os.Exit(1) } if err := cliA.List(cmd.Context(), *accountsListOpts); err != nil { - logger.GetLogger().Error(err) + output.PrintError(err, logger.GetLogger(), accountsListCfg.Pretty) os.Exit(1) } } diff --git a/commands/accounts/reset_jwt_keys.go b/commands/accounts/reset_jwt_keys.go index 1cf94be3c..4f1acc9a7 100644 --- a/commands/accounts/reset_jwt_keys.go +++ b/commands/accounts/reset_jwt_keys.go @@ -24,6 +24,7 @@ import ( accountscli "github.com/percona/everest/pkg/accounts/cli" "github.com/percona/everest/pkg/cli" "github.com/percona/everest/pkg/logger" + "github.com/percona/everest/pkg/output" ) var ( @@ -48,12 +49,12 @@ func accountsResetJWTKeysPreRun(cmd *cobra.Command, _ []string) { //nolint:reviv func accountsResetJWTKeysRun(cmd *cobra.Command, _ []string) { //nolint:revive cliA, err := accountscli.NewAccounts(*accountsResetJWTKeysCfg, logger.GetLogger()) if err != nil { - logger.GetLogger().Error(err) + output.PrintError(err, logger.GetLogger(), accountsResetJWTKeysCfg.Pretty) os.Exit(1) } if err := cliA.CreateRSAKeyPair(cmd.Context()); err != nil { - logger.GetLogger().Error(err) + output.PrintError(err, logger.GetLogger(), accountsResetJWTKeysCfg.Pretty) os.Exit(1) } } diff --git a/commands/accounts/set_password.go b/commands/accounts/set_password.go index 05ac7a500..9c5e08141 100644 --- a/commands/accounts/set_password.go +++ b/commands/accounts/set_password.go @@ -26,6 +26,7 @@ import ( accountscli "github.com/percona/everest/pkg/accounts/cli" "github.com/percona/everest/pkg/cli" "github.com/percona/everest/pkg/logger" + "github.com/percona/everest/pkg/output" ) var ( @@ -52,17 +53,51 @@ func accountsSetPasswordPreRun(cmd *cobra.Command, _ []string) { //nolint:revive // Copy global flags to config accountsSetPasswordCfg.Pretty = !(cmd.Flag(cli.FlagVerbose).Changed || cmd.Flag(cli.FlagJSON).Changed) accountsSetPasswordCfg.KubeconfigPath = cmd.Flag(cli.FlagKubeconfig).Value.String() + + // Check username + if accountsSetPasswordOpts.Username != "" { + // Validate provided username for new account. + if err := accountscli.ValidateUsername(accountsSetPasswordOpts.Username); err != nil { + output.PrintError(err, logger.GetLogger(), accountsSetPasswordCfg.Pretty) + os.Exit(1) + } + } else { + // Ask user in interactive mode to provide username for new account. + if username, err := accountscli.PopulateUsername(cmd.Context()); err != nil { + output.PrintError(err, logger.GetLogger(), accountsSetPasswordCfg.Pretty) + os.Exit(1) + } else { + accountsSetPasswordOpts.Username = username + } + } + + // Check password + if accountsSetPasswordOpts.NewPassword != "" { + // Validate provided password for new account. + if err := accountscli.ValidatePassword(accountsSetPasswordOpts.NewPassword); err != nil { + output.PrintError(err, logger.GetLogger(), accountsSetPasswordCfg.Pretty) + os.Exit(1) + } + } else { + // Ask user in interactive mode to provide password for new account. + if password, err := accountscli.PopulateNewPassword(cmd.Context()); err != nil { + output.PrintError(err, logger.GetLogger(), accountsSetPasswordCfg.Pretty) + os.Exit(1) + } else { + accountsSetPasswordOpts.NewPassword = password + } + } } func accountsSetPasswordRun(cmd *cobra.Command, _ []string) { //nolint:revive cliA, err := accountscli.NewAccounts(*accountsSetPasswordCfg, logger.GetLogger()) if err != nil { - logger.GetLogger().Error(err) + output.PrintError(err, logger.GetLogger(), accountsSetPasswordCfg.Pretty) os.Exit(1) } if err := cliA.SetPassword(cmd.Context(), *accountsSetPasswordOpts); err != nil { - logger.GetLogger().Error(err) + output.PrintError(err, logger.GetLogger(), accountsSetPasswordCfg.Pretty) os.Exit(1) } } diff --git a/commands/install.go b/commands/install.go index 90a10bbc4..20ec482f1 100644 --- a/commands/install.go +++ b/commands/install.go @@ -24,6 +24,8 @@ import ( "github.com/percona/everest/pkg/cli" "github.com/percona/everest/pkg/cli/helm" "github.com/percona/everest/pkg/cli/install" + "github.com/percona/everest/pkg/cli/namespaces" + "github.com/percona/everest/pkg/common" "github.com/percona/everest/pkg/logger" "github.com/percona/everest/pkg/output" ) @@ -40,18 +42,19 @@ var ( Example: "everestctl install --namespaces dev,staging,prod --operator.mongodb=true --operator.postgresql=false --operator.xtradb-cluster=false --skip-wizard", Long: "Install Percona Everest using Helm", Short: "Install Percona Everest using Helm", - PreRunE: installPreRunE, + PreRun: installPreRun, Run: installRun, } - installCfg = &install.Config{} + installCfg = install.NewInstallConfig() + namespacesToAdd string ) func init() { rootCmd.AddCommand(installCmd) // local command flags - installCmd.Flags().StringVar(&installCfg.Namespaces, cli.FlagNamespaces, install.DefaultDBNamespaceName, "Comma-separated namespaces list Percona Everest can manage") - installCmd.Flags().BoolVar(&installCfg.SkipWizard, cli.FlagSkipWizard, false, "Skip installation wizard") + installCmd.Flags().StringVar(&namespacesToAdd, cli.FlagNamespaces, common.DefaultDBNamespaceName, "Comma-separated namespaces list Percona Everest can manage") + installCmd.Flags().BoolVar(&installCfg.NamespaceAddConfig.SkipWizard, cli.FlagSkipWizard, false, "Skip installation wizard") installCmd.Flags().StringVar(&installCfg.VersionMetadataURL, cli.FlagVersionMetadataURL, "https://check.percona.com", "URL to retrieve version metadata information from") installCmd.Flags().StringVar(&installCfg.Version, cli.FlagVersion, "", "Everest version to install. By default the latest version is installed") installCmd.Flags().BoolVar(&installCfg.DisableTelemetry, cli.FlagDisableTelemetry, false, "Disable telemetry") @@ -63,43 +66,85 @@ func init() { installCmd.MarkFlagsMutuallyExclusive(cli.FlagNamespaces, cli.FlagInstallSkipDBNamespace) // --helm.* flags - installCmd.Flags().StringVar(&installCfg.ChartDir, helm.FlagChartDir, "", "Path to the chart directory. If not set, the chart will be downloaded from the repository") + installCmd.Flags().StringVar(&installCfg.HelmConfig.ChartDir, helm.FlagChartDir, "", "Path to the chart directory. If not set, the chart will be downloaded from the repository") _ = installCmd.Flags().MarkHidden(helm.FlagChartDir) - installCmd.Flags().StringVar(&installCfg.RepoURL, helm.FlagRepository, helm.DefaultHelmRepoURL, "Helm chart repository to download the Everest charts from") - installCmd.Flags().StringSliceVar(&installCfg.Values.Values, helm.FlagHelmSet, []string{}, "Set helm values on the command line (can specify multiple values with commas: key1=val1,key2=val2)") - installCmd.Flags().StringSliceVarP(&installCfg.Values.ValueFiles, helm.FlagHelmValues, "f", []string{}, "Specify values in a YAML file or a URL (can specify multiple)") + installCmd.Flags().StringVar(&installCfg.HelmConfig.RepoURL, helm.FlagRepository, helm.DefaultHelmRepoURL, "Helm chart repository to download the Everest charts from") + installCmd.Flags().StringSliceVar(&installCfg.HelmConfig.Values.Values, helm.FlagHelmSet, []string{}, "Set helm values on the command line (can specify multiple values with commas: key1=val1,key2=val2)") + installCmd.Flags().StringSliceVarP(&installCfg.HelmConfig.Values.ValueFiles, helm.FlagHelmValues, "f", []string{}, "Specify values in a YAML file or a URL (can specify multiple)") // --operator.* flags - installCmd.Flags().BoolVar(&installCfg.Operator.PSMDB, cli.FlagOperatorMongoDB, true, "Install MongoDB operator") - installCmd.Flags().BoolVar(&installCfg.Operator.PG, cli.FlagOperatorPostgresql, true, "Install PostgreSQL operator") - installCmd.Flags().BoolVar(&installCfg.Operator.PXC, cli.FlagOperatorXtraDBCluster, true, "Install XtraDB Cluster operator") + installCmd.Flags().BoolVar(&installCfg.NamespaceAddConfig.Operators.PSMDB, cli.FlagOperatorMongoDB, true, "Install MongoDB operator") + installCmd.Flags().BoolVar(&installCfg.NamespaceAddConfig.Operators.PG, cli.FlagOperatorPostgresql, true, "Install PostgreSQL operator") + installCmd.Flags().BoolVar(&installCfg.NamespaceAddConfig.Operators.PXC, cli.FlagOperatorXtraDBCluster, true, "Install XtraDB Cluster operator") } -func installPreRunE(cmd *cobra.Command, _ []string) error { //nolint:revive - if installCfg.SkipDBNamespace { - installCfg.Namespaces = "" - } - +func installPreRun(cmd *cobra.Command, _ []string) { //nolint:revive // Copy global flags to config installCfg.Pretty = rootCmdFlags.Pretty installCfg.KubeconfigPath = rootCmdFlags.KubeconfigPath + installCfg.NamespaceAddConfig.KubeconfigPath = rootCmdFlags.KubeconfigPath + // Check if Everest is already installed. + if err := install.CheckEverestAlreadyinstalled(cmd.Context(), logger.GetLogger(), installCfg.KubeconfigPath); err != nil { + output.PrintError(err, logger.GetLogger(), installCfg.Pretty) + os.Exit(1) + } + + if !installCfg.SkipDBNamespace { + if err := checkDBNamespaceParameters(cmd); err != nil { + output.PrintError(err, logger.GetLogger(), installCfg.Pretty) + os.Exit(1) + } + } +} + +// checkDBNamespaceParameters checks, validates and sets the database namespace parameters into installCfg. +// If the user doesn't pass '--namespaces' or '--operators.*' flags, +// it will ask the user to provide them in interactive mode (if it is enabled). +func checkDBNamespaceParameters(cmd *cobra.Command) error { + // Check DB namespaces parameters // If user doesn't pass --namespaces flag - need to ask explicitly. - installCfg.AskNamespaces = !(cmd.Flags().Lookup(cli.FlagNamespaces).Changed || installCfg.SkipDBNamespace) + askNamespaces := !(cmd.Flags().Lookup(cli.FlagNamespaces).Changed || + installCfg.NamespaceAddConfig.SkipWizard) + + // Note: there are the following cases possible: + // - user doesn't provide '--namespaces' flag -> namespacesToAdd="everest" (default). + // - user provides '--namespaces' flag -> namespacesToAdd contains the user provided value. + if askNamespaces { + // need to ask user in interactive mode to provide database namespaces to be created. + if err := installCfg.NamespaceAddConfig.PopulateNamespaces(cmd.Context()); err != nil { + return err + } + } else { + // Parse and validate user provided namespaces. + nsList := namespaces.ParseNamespaceNames(namespacesToAdd) + if err := installCfg.NamespaceAddConfig.ValidateNamespaces(cmd.Context(), nsList); err != nil { + return err + } + + installCfg.NamespaceAddConfig.NamespaceList = nsList + } // If user doesn't pass any --operator.* flags - need to ask explicitly. - installCfg.AskOperators = !(cmd.Flags().Lookup(cli.FlagOperatorMongoDB).Changed || + askOperators := !(cmd.Flags().Lookup(cli.FlagOperatorMongoDB).Changed || cmd.Flags().Lookup(cli.FlagOperatorPostgresql).Changed || cmd.Flags().Lookup(cli.FlagOperatorXtraDBCluster).Changed || - installCfg.SkipDBNamespace) + installCfg.NamespaceAddConfig.SkipWizard) + + if askOperators { + // need to ask user to provide operators to be installed in interactive mode. + if err := installCfg.NamespaceAddConfig.PopulateOperators(cmd.Context()); err != nil { + return err + } + } return nil } func installRun(cmd *cobra.Command, _ []string) { //nolint:revive - op, err := install.NewInstall(*installCfg, logger.GetLogger()) + op, err := install.NewInstall(installCfg, logger.GetLogger()) if err != nil { - logger.GetLogger().Error(err) + output.PrintError(err, logger.GetLogger(), installCfg.Pretty) os.Exit(1) } diff --git a/commands/namespaces/add.go b/commands/namespaces/add.go index 5f57e8bfe..8679d70e9 100644 --- a/commands/namespaces/add.go +++ b/commands/namespaces/add.go @@ -43,7 +43,7 @@ var ( PreRun: namespacesAddPreRun, Run: namespacesAddRun, } - namespacesAddCfg = &namespaces.NamespaceAddConfig{} + namespacesAddCfg = namespaces.NewNamespaceAddConfig() ) func init() { @@ -55,45 +55,63 @@ func init() { namespacesAddCmd.Flags().BoolVar(&namespacesAddCfg.SkipEnvDetection, cli.FlagSkipEnvDetection, false, "Skip detecting Kubernetes environment where Everest is installed") // --helm.* flags - namespacesAddCmd.Flags().StringVar(&namespacesAddCfg.CLIOptions.ChartDir, helm.FlagChartDir, "", "Path to the chart directory. If not set, the chart will be downloaded from the repository") + namespacesAddCmd.Flags().StringVar(&namespacesAddCfg.HelmConfig.ChartDir, helm.FlagChartDir, "", "Path to the chart directory. If not set, the chart will be downloaded from the repository") _ = namespacesAddCmd.Flags().MarkHidden(helm.FlagChartDir) //nolint:errcheck,gosec - namespacesAddCmd.Flags().StringVar(&namespacesAddCfg.CLIOptions.RepoURL, helm.FlagRepository, helm.DefaultHelmRepoURL, "Helm chart repository to download the Everest charts from") - namespacesAddCmd.Flags().StringSliceVar(&namespacesAddCfg.CLIOptions.Values.Values, helm.FlagHelmSet, []string{}, "Set helm values on the command line (can specify multiple values with commas: key1=val1,key2=val2)") - namespacesAddCmd.Flags().StringSliceVarP(&namespacesAddCfg.CLIOptions.Values.ValueFiles, helm.FlagHelmValues, "f", []string{}, "Specify values in a YAML file or a URL (can specify multiple)") + namespacesAddCmd.Flags().StringVar(&namespacesAddCfg.HelmConfig.RepoURL, helm.FlagRepository, helm.DefaultHelmRepoURL, "Helm chart repository to download the Everest charts from") + namespacesAddCmd.Flags().StringSliceVar(&namespacesAddCfg.HelmConfig.Values.Values, helm.FlagHelmSet, []string{}, "Set helm values on the command line (can specify multiple values with commas: key1=val1,key2=val2)") + namespacesAddCmd.Flags().StringSliceVarP(&namespacesAddCfg.HelmConfig.Values.ValueFiles, helm.FlagHelmValues, "f", []string{}, "Specify values in a YAML file or a URL (can specify multiple)") // --operator.* flags - namespacesAddCmd.Flags().BoolVar(&namespacesAddCfg.Operator.PSMDB, cli.FlagOperatorMongoDB, true, "Install MongoDB operator") - namespacesAddCmd.Flags().BoolVar(&namespacesAddCfg.Operator.PG, cli.FlagOperatorPostgresql, true, "Install PostgreSQL operator") - namespacesAddCmd.Flags().BoolVar(&namespacesAddCfg.Operator.PXC, cli.FlagOperatorXtraDBCluster, true, "Install XtraDB Cluster operator") + namespacesAddCmd.Flags().BoolVar(&namespacesAddCfg.Operators.PSMDB, cli.FlagOperatorMongoDB, true, "Install MongoDB operator") + namespacesAddCmd.Flags().BoolVar(&namespacesAddCfg.Operators.PG, cli.FlagOperatorPostgresql, true, "Install PostgreSQL operator") + namespacesAddCmd.Flags().BoolVar(&namespacesAddCfg.Operators.PXC, cli.FlagOperatorXtraDBCluster, true, "Install XtraDB Cluster operator") } func namespacesAddPreRun(cmd *cobra.Command, args []string) { //nolint:revive - namespacesAddCfg.Namespaces = args[0] - // Copy global flags to config namespacesAddCfg.Pretty = !(cmd.Flag(cli.FlagVerbose).Changed || cmd.Flag(cli.FlagJSON).Changed) namespacesAddCfg.KubeconfigPath = cmd.Flag(cli.FlagKubeconfig).Value.String() + { + // Parse and validate provided namespaces + nsList := namespaces.ParseNamespaceNames(args[0]) + if err := namespacesAddCfg.ValidateNamespaces(cmd.Context(), nsList); err != nil { + if errors.Is(err, namespaces.ErrNamespaceAlreadyExists) { + err = fmt.Errorf("%w. %s", err, takeOwnershipHintMessage) + } + if errors.Is(err, namespaces.ErrNamespaceAlreadyManagedByEverest) { + err = fmt.Errorf("%w. %s", err, updateHintMessage) + } + output.PrintError(err, logger.GetLogger(), namespacesAddCfg.Pretty) + os.Exit(1) + } + + namespacesAddCfg.NamespaceList = nsList + } + // If user doesn't pass any --operator.* flags - need to ask explicitly. - namespacesAddCfg.AskOperators = !(cmd.Flags().Lookup(cli.FlagOperatorMongoDB).Changed || + askOperators := !(cmd.Flags().Lookup(cli.FlagOperatorMongoDB).Changed || cmd.Flags().Lookup(cli.FlagOperatorPostgresql).Changed || - cmd.Flags().Lookup(cli.FlagOperatorXtraDBCluster).Changed) + cmd.Flags().Lookup(cli.FlagOperatorXtraDBCluster).Changed || + namespacesAddCfg.SkipWizard) + + if askOperators { + // need to ask user to provide operators to be installed in interactive mode. + if err := namespacesAddCfg.PopulateOperators(cmd.Context()); err != nil { + output.PrintError(err, logger.GetLogger(), namespacesAddCfg.Pretty) + os.Exit(1) + } + } } func namespacesAddRun(cmd *cobra.Command, _ []string) { - op, err := namespaces.NewNamespaceAdd(*namespacesAddCfg, logger.GetLogger()) + op, err := namespaces.NewNamespaceAdd(namespacesAddCfg, logger.GetLogger()) if err != nil { - logger.GetLogger().Error(err) + output.PrintError(err, logger.GetLogger(), namespacesAddCfg.Pretty) os.Exit(1) } if err := op.Run(cmd.Context()); err != nil { - if errors.Is(err, namespaces.ErrNamespaceAlreadyExists) { - err = fmt.Errorf("%w. %s", err, takeOwnershipHintMessage) - } - if errors.Is(err, namespaces.ErrNamespaceAlreadyOwned) { - err = fmt.Errorf("%w. %s", err, updateHintMessage) - } output.PrintError(err, logger.GetLogger(), namespacesAddCfg.Pretty) os.Exit(1) } diff --git a/commands/namespaces/remove.go b/commands/namespaces/remove.go index 4a00ff0ed..a60fdae06 100644 --- a/commands/namespaces/remove.go +++ b/commands/namespaces/remove.go @@ -51,24 +51,30 @@ func init() { } func namespacesRemovePreRun(cmd *cobra.Command, args []string) { //nolint:revive - namespacesRemoveCfg.Namespaces = args[0] - // Copy global flags to config namespacesRemoveCfg.Pretty = !(cmd.Flag(cli.FlagVerbose).Changed || cmd.Flag(cli.FlagJSON).Changed) namespacesRemoveCfg.KubeconfigPath = cmd.Flag(cli.FlagKubeconfig).Value.String() + + // Parse and validate provided namespaces + nsList := namespaces.ParseNamespaceNames(args[0]) + if err := namespacesRemoveCfg.ValidateNamespaces(cmd.Context(), nsList); err != nil { + if errors.Is(err, namespaces.ErrNamespaceNotEmpty) { + err = fmt.Errorf("%w. %s", err, forceUninstallHint) + } + output.PrintError(err, logger.GetLogger(), namespacesRemoveCfg.Pretty) + os.Exit(1) + } + namespacesRemoveCfg.NamespaceList = nsList } func namespacesRemoveRun(cmd *cobra.Command, _ []string) { op, err := namespaces.NewNamespaceRemove(*namespacesRemoveCfg, logger.GetLogger()) if err != nil { - logger.GetLogger().Error(err) + output.PrintError(err, logger.GetLogger(), namespacesRemoveCfg.Pretty) os.Exit(1) } if err := op.Run(cmd.Context()); err != nil { - if errors.Is(err, namespaces.ErrNamespaceNotEmpty) { - err = fmt.Errorf("%w. %s", err, forceUninstallHint) - } output.PrintError(err, logger.GetLogger(), namespacesRemoveCfg.Pretty) os.Exit(1) } diff --git a/commands/namespaces/update.go b/commands/namespaces/update.go index b30cd879e..13191515a 100644 --- a/commands/namespaces/update.go +++ b/commands/namespaces/update.go @@ -20,6 +20,7 @@ import ( "errors" "fmt" "os" + "strings" "github.com/spf13/cobra" @@ -40,10 +41,12 @@ var ( PreRun: namespacesUpdatePreRun, Run: namespacesUpdateRun, } - namespacesUpdateCfg = &namespaces.NamespaceAddConfig{} + namespacesUpdateCfg = namespaces.NewNamespaceAddConfig() ) func init() { + namespacesUpdateCfg.Update = true + // local command flags namespacesUpdateCmd.Flags().BoolVar(&namespacesUpdateCfg.DisableTelemetry, cli.FlagDisableTelemetry, false, "Disable telemetry") _ = namespacesUpdateCmd.Flags().MarkHidden(cli.FlagDisableTelemetry) //nolint:errcheck,gosec @@ -51,36 +54,52 @@ func init() { namespacesUpdateCmd.Flags().BoolVar(&namespacesUpdateCfg.SkipEnvDetection, cli.FlagSkipEnvDetection, false, "Skip detecting Kubernetes environment where Everest is installed") // --helm.* flags - namespacesUpdateCmd.Flags().StringVar(&namespacesUpdateCfg.CLIOptions.ChartDir, helm.FlagChartDir, "", "Path to the chart directory. If not set, the chart will be downloaded from the repository") + namespacesUpdateCmd.Flags().StringVar(&namespacesUpdateCfg.HelmConfig.ChartDir, helm.FlagChartDir, "", "Path to the chart directory. If not set, the chart will be downloaded from the repository") _ = namespacesUpdateCmd.Flags().MarkHidden(helm.FlagChartDir) //nolint:errcheck,gosec - namespacesUpdateCmd.Flags().StringVar(&namespacesUpdateCfg.CLIOptions.RepoURL, helm.FlagRepository, helm.DefaultHelmRepoURL, "Helm chart repository to download the Everest charts from") - namespacesUpdateCmd.Flags().StringSliceVar(&namespacesUpdateCfg.CLIOptions.Values.Values, helm.FlagHelmSet, []string{}, "Set helm values on the command line (can specify multiple values with commas: key1=val1,key2=val2)") - namespacesUpdateCmd.Flags().StringSliceVarP(&namespacesUpdateCfg.CLIOptions.Values.ValueFiles, helm.FlagHelmValues, "f", []string{}, "Specify values in a YAML file or a URL (can specify multiple)") + namespacesUpdateCmd.Flags().StringVar(&namespacesUpdateCfg.HelmConfig.RepoURL, helm.FlagRepository, helm.DefaultHelmRepoURL, "Helm chart repository to download the Everest charts from") + namespacesUpdateCmd.Flags().StringSliceVar(&namespacesUpdateCfg.HelmConfig.Values.Values, helm.FlagHelmSet, []string{}, "Set helm values on the command line (can specify multiple values with commas: key1=val1,key2=val2)") + namespacesUpdateCmd.Flags().StringSliceVarP(&namespacesUpdateCfg.HelmConfig.Values.ValueFiles, helm.FlagHelmValues, "f", []string{}, "Specify values in a YAML file or a URL (can specify multiple)") // --operator.* flags - namespacesUpdateCmd.Flags().BoolVar(&namespacesUpdateCfg.Operator.PSMDB, cli.FlagOperatorMongoDB, true, "Install MongoDB operator") - namespacesUpdateCmd.Flags().BoolVar(&namespacesUpdateCfg.Operator.PG, cli.FlagOperatorPostgresql, true, "Install PostgreSQL operator") - namespacesUpdateCmd.Flags().BoolVar(&namespacesUpdateCfg.Operator.PXC, cli.FlagOperatorXtraDBCluster, true, "Install XtraDB Cluster operator") + namespacesUpdateCmd.Flags().BoolVar(&namespacesUpdateCfg.Operators.PSMDB, cli.FlagOperatorMongoDB, true, "Install MongoDB operator") + namespacesUpdateCmd.Flags().BoolVar(&namespacesUpdateCfg.Operators.PG, cli.FlagOperatorPostgresql, true, "Install PostgreSQL operator") + namespacesUpdateCmd.Flags().BoolVar(&namespacesUpdateCfg.Operators.PXC, cli.FlagOperatorXtraDBCluster, true, "Install XtraDB Cluster operator") } func namespacesUpdatePreRun(cmd *cobra.Command, args []string) { //nolint:revive - namespacesUpdateCfg.Namespaces = args[0] - namespacesUpdateCfg.Update = true - // Copy global flags to config namespacesUpdateCfg.Pretty = !(cmd.Flag(cli.FlagVerbose).Changed || cmd.Flag(cli.FlagJSON).Changed) namespacesUpdateCfg.KubeconfigPath = cmd.Flag(cli.FlagKubeconfig).Value.String() + { + // Parse and validate provided namespaces + nsList := namespaces.ParseNamespaceNames(args[0]) + if err := namespacesUpdateCfg.ValidateNamespaces(cmd.Context(), nsList); err != nil { + output.PrintError(err, logger.GetLogger(), namespacesUpdateCfg.Pretty) + os.Exit(1) + } + + namespacesUpdateCfg.NamespaceList = nsList + } + // If user doesn't pass any --operator.* flags - need to ask explicitly. - namespacesUpdateCfg.AskOperators = !(cmd.Flags().Lookup(cli.FlagOperatorMongoDB).Changed || + askOperators := !(cmd.Flags().Lookup(cli.FlagOperatorMongoDB).Changed || cmd.Flags().Lookup(cli.FlagOperatorPostgresql).Changed || cmd.Flags().Lookup(cli.FlagOperatorXtraDBCluster).Changed) + + if askOperators { + // need to ask user to provide operators to be installed in interactive mode. + if err := namespacesUpdateCfg.PopulateOperators(cmd.Context()); err != nil { + output.PrintError(err, logger.GetLogger(), namespacesUpdateCfg.Pretty) + os.Exit(1) + } + } } func namespacesUpdateRun(cmd *cobra.Command, _ []string) { - op, err := namespaces.NewNamespaceAdd(*namespacesUpdateCfg, logger.GetLogger()) + op, err := namespaces.NewNamespaceAdd(namespacesUpdateCfg, logger.GetLogger()) if err != nil { - logger.GetLogger().Error(err) + output.PrintError(err, logger.GetLogger(), namespacesUpdateCfg.Pretty) os.Exit(1) } @@ -89,11 +108,11 @@ func namespacesUpdateRun(cmd *cobra.Command, _ []string) { err = fmt.Errorf("%w. HINT: use 'everestctl namespaces add --%s %s' first to make namespace managed by Everest", err, cli.FlagTakeNamespaceOwnership, - namespacesUpdateCfg.Namespaces, + strings.Join(namespacesUpdateCfg.NamespaceList, ", "), ) } - output.PrintError(err, logger.GetLogger(), namespacesAddCfg.Pretty) + output.PrintError(err, logger.GetLogger(), namespacesUpdateCfg.Pretty) os.Exit(1) } } diff --git a/commands/settings/oidc/configure.go b/commands/settings/oidc/configure.go index 418f319b6..1183b9538 100644 --- a/commands/settings/oidc/configure.go +++ b/commands/settings/oidc/configure.go @@ -50,12 +50,42 @@ func settingsOIDCConfigurePreRun(cmd *cobra.Command, _ []string) { //nolint:revi // Copy global flags to config settingsOIDCConfigureCfg.Pretty = !(cmd.Flag(cli.FlagVerbose).Changed || cmd.Flag(cli.FlagJSON).Changed) settingsOIDCConfigureCfg.KubeconfigPath = cmd.Flag(cli.FlagKubeconfig).Value.String() + + // Check if issuer URL is provided + if settingsOIDCConfigureCfg.IssuerURL == "" { + // Ask user to provide issuer URL in interactive mode + if err := settingsOIDCConfigureCfg.PopulateIssuerURL(cmd.Context()); err != nil { + output.PrintError(err, logger.GetLogger(), settingsOIDCConfigureCfg.Pretty) + os.Exit(1) + } + } else { + // Validate issuer URL provided by user in flags + if err := oidc.ValidateURL(settingsOIDCConfigureCfg.IssuerURL); err != nil { + output.PrintError(err, logger.GetLogger(), settingsOIDCConfigureCfg.Pretty) + os.Exit(1) + } + } + + // Check if Client ID is provided + if settingsOIDCConfigureCfg.ClientID == "" { + // Ask user to provide client ID in interactive mode + if err := settingsOIDCConfigureCfg.PopulateClientID(cmd.Context()); err != nil { + output.PrintError(err, logger.GetLogger(), settingsOIDCConfigureCfg.Pretty) + os.Exit(1) + } + } else { + // Validate client ID provided by user in flags + if err := oidc.ValidateClientID(settingsOIDCConfigureCfg.ClientID); err != nil { + output.PrintError(err, logger.GetLogger(), settingsOIDCConfigureCfg.Pretty) + os.Exit(1) + } + } } func settingsOIDCConfigureRun(cmd *cobra.Command, _ []string) { op, err := oidc.NewOIDC(*settingsOIDCConfigureCfg, logger.GetLogger()) if err != nil { - logger.GetLogger().Error(err) + output.PrintError(err, logger.GetLogger(), settingsOIDCConfigureCfg.Pretty) os.Exit(1) } diff --git a/commands/settings/rbac/can.go b/commands/settings/rbac/can.go index b13b037a5..8fbc18abd 100644 --- a/commands/settings/rbac/can.go +++ b/commands/settings/rbac/can.go @@ -28,6 +28,7 @@ import ( "github.com/percona/everest/pkg/cli" "github.com/percona/everest/pkg/kubernetes" "github.com/percona/everest/pkg/logger" + "github.com/percona/everest/pkg/output" "github.com/percona/everest/pkg/rbac" ) @@ -99,7 +100,7 @@ func settingsRBACCanRun(cmd *cobra.Command, args []string) { client, err := kubernetes.New(rbacCanKubeconfigPath, l) if err != nil { - l.Error(err) + output.PrintError(err, logger.GetLogger(), rbacCanPretty) os.Exit(1) } k = client @@ -107,7 +108,7 @@ func settingsRBACCanRun(cmd *cobra.Command, args []string) { can, err := rbac.Can(cmd.Context(), rbacCanPolicyFilePath, k, args...) if err != nil { - logger.GetLogger().Error(err) + output.PrintError(err, logger.GetLogger(), rbacCanPretty) os.Exit(1) } diff --git a/commands/uninstall.go b/commands/uninstall.go index d6ef4a459..1190fa48e 100644 --- a/commands/uninstall.go +++ b/commands/uninstall.go @@ -57,7 +57,7 @@ func uninstallPreRun(_ *cobra.Command, _ []string) { //nolint:revive func uninstallRun(cmd *cobra.Command, _ []string) { //nolint:revive op, err := uninstall.NewUninstall(*uninstallCfg, logger.GetLogger()) if err != nil { - logger.GetLogger().Error(err) + output.PrintError(err, logger.GetLogger(), uninstallCfg.Pretty) os.Exit(1) } diff --git a/commands/upgrade.go b/commands/upgrade.go index 531e17536..0a6f225ed 100644 --- a/commands/upgrade.go +++ b/commands/upgrade.go @@ -71,7 +71,7 @@ func upgradePreRun(_ *cobra.Command, _ []string) { //nolint:revive func upgradeRun(cmd *cobra.Command, _ []string) { //nolint:revive op, err := upgrade.NewUpgrade(upgradeCfg, logger.GetLogger()) if err != nil { - logger.GetLogger().Error(err) + output.PrintError(err, logger.GetLogger(), upgradeCfg.Pretty) os.Exit(1) } diff --git a/go.mod b/go.mod index 059e88aaa..b2349682c 100644 --- a/go.mod +++ b/go.mod @@ -12,17 +12,17 @@ replace ( ) require ( - github.com/AlecAivazis/survey/v2 v2.3.7 github.com/AlekSi/pointer v1.2.0 github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.5.0 github.com/Percona-Lab/percona-version-service v0.0.0-20240311164804-ffbc02387a1b github.com/aws/aws-sdk-go v1.55.5 - github.com/briandowns/spinner v1.23.2 github.com/casbin/casbin/v2 v2.103.0 github.com/casbin/govaluate v1.3.0 github.com/cenkalti/backoff v2.2.1+incompatible github.com/cenkalti/backoff/v4 v4.3.0 - github.com/fatih/color v1.18.0 + github.com/charmbracelet/bubbles v0.20.0 + github.com/charmbracelet/bubbletea v1.2.4 + github.com/charmbracelet/lipgloss v1.0.0 github.com/getkin/kin-openapi v0.128.0 github.com/go-logr/zapr v1.3.0 github.com/golang-jwt/jwt/v5 v5.2.1 @@ -56,15 +56,11 @@ require ( k8s.io/cli-runtime v0.32.0 k8s.io/client-go v12.0.0+incompatible k8s.io/kubectl v0.32.0 + k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 sigs.k8s.io/controller-runtime v0.20.1 sigs.k8s.io/yaml v1.4.0 ) -require ( - k8s.io/apiserver v0.32.0 // indirect - k8s.io/component-base v0.32.0 // indirect -) - require ( cel.dev/expr v0.18.0 // indirect dario.cat/mergo v1.0.1 // indirect @@ -82,12 +78,16 @@ require ( github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect github.com/cert-manager/cert-manager v1.16.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chai2010/gettext-go v1.0.2 // indirect + github.com/charmbracelet/x/ansi v0.4.5 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/containerd/containerd v1.7.23 // indirect github.com/containerd/errdefs v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect @@ -103,9 +103,11 @@ require ( github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-metrics v0.0.1 // indirect github.com/emicklei/go-restful/v3 v3.12.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/evanphx/json-patch v5.9.0+incompatible // indirect github.com/evanphx/json-patch/v5 v5.9.0 // indirect github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect + github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/flosch/pongo2/v6 v6.0.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect @@ -146,7 +148,6 @@ require ( github.com/jmoiron/sqlx v1.4.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/klauspost/compress v1.17.10 // indirect github.com/klauspost/pgzip v1.2.6 // indirect github.com/labstack/gommon v0.4.2 // indirect @@ -159,11 +160,12 @@ require ( github.com/lestrrat-go/option v1.0.1 // indirect github.com/lib/pq v1.10.9 // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect @@ -174,6 +176,9 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.15.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/onsi/gomega v1.36.2 // indirect @@ -193,7 +198,7 @@ require ( github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.59.1 // indirect github.com/prometheus/procfs v0.15.1 // indirect - github.com/rivo/uniseg v0.4.4 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/rubenv/sql-migrate v1.7.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/segmentio/asm v1.2.0 // indirect @@ -228,9 +233,10 @@ require ( google.golang.org/grpc v1.67.1 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/apiserver v0.32.0 // indirect + k8s.io/component-base v0.32.0 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect - k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect oras.land/oras-go v1.2.5 // indirect sigs.k8s.io/gateway-api v1.1.0 // indirect sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect diff --git a/go.sum b/go.sum index a0c7f713c..5d9e69957 100644 --- a/go.sum +++ b/go.sum @@ -1326,8 +1326,6 @@ gioui.org v0.0.0-20210308172011-57750fc8a0a6/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zum git.sr.ht/~sbinet/gg v0.3.1/go.mod h1:KGYtlADtqsqANL9ueOFkWymvzUvLMQllU5Ixo+8v3pc= github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= -github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= -github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w= github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0 h1:JZg6HRh6W6U4OLl6lk7BZ7BLisIzM9dG1R50zUk9C/M= @@ -1367,8 +1365,6 @@ github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA github.com/Microsoft/hcsshim v0.12.0-rc.0 h1:wX/F5huJxH9APBkhKSEAqaiZsuBvbbDnyBROZAqsSaY= github.com/Microsoft/hcsshim v0.12.0-rc.0/go.mod h1:rvOnw3YlfoNnEp45wReUngvsXbwRW+AFQ10GVjG1kMU= github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= -github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= -github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/Percona-Lab/percona-version-service v0.0.0-20240311164804-ffbc02387a1b h1:6+5kSyTLQ3yiBtLzokN2GhgkkWwBZE0auO19PpOTndo= github.com/Percona-Lab/percona-version-service v0.0.0-20240311164804-ffbc02387a1b/go.mod h1:/R/tVunZsnlasTvqRbJvH/1doO818dpGJqhW3aXPH9g= @@ -1417,8 +1413,12 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkY github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU= github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -1432,8 +1432,6 @@ github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwN github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= -github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= -github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70= github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= github.com/bugsnag/bugsnag-go v1.5.3 h1:yeRUT3mUE13jL1tGwvoQsKdVbAsQx9AJ+fqahKveP04= @@ -1462,6 +1460,16 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chai2010/gettext-go v1.0.2 h1:1Lwwip6Q2QGsAdl/ZKPCwTe9fe0CjlUbqj5bFNSjIRk= github.com/chai2010/gettext-go v1.0.2/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= +github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= +github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= +github.com/charmbracelet/bubbletea v1.2.4 h1:KN8aCViA0eps9SCOThb2/XPIlea3ANJLUkv3KnQRNCE= +github.com/charmbracelet/bubbletea v1.2.4/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM= +github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= +github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= +github.com/charmbracelet/x/ansi v0.4.5 h1:LqK4vwBNaXw2AyGIICa5/29Sbdq58GbGdFngSexTdRM= +github.com/charmbracelet/x/ansi v0.4.5/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs= github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= @@ -1542,7 +1550,6 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsr github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/cyphar/filepath-securejoin v0.3.4 h1:VBWugsJh2ZxJmLFSM06/0qzQyiQX2Qs0ViKrUAcqdZ8= @@ -1606,6 +1613,8 @@ github.com/envoyproxy/protoc-gen-validate v0.10.1/go.mod h1:DRjgyB0I43LtJapqN6Ni github.com/envoyproxy/protoc-gen-validate v1.0.1/go.mod h1:0vj8bNkYbSTNS2PIyH87KZaeN4x9zpL9Qt8fQC7d+vs= github.com/envoyproxy/protoc-gen-validate v1.0.2/go.mod h1:GpiZQP3dDbg4JouG/NNS7QWXpgx6x8QiMKdmN72jogE= github.com/envoyproxy/protoc-gen-validate v1.0.4/go.mod h1:qys6tmnRsYrQqIhm2bvKZH4Blx/1gTIZ2UKVY1M+Yew= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/evanphx/json-patch v4.5.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls= github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= @@ -1943,8 +1952,6 @@ github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0m github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= -github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= @@ -1989,7 +1996,6 @@ github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+ github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA= github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= -github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= @@ -2049,6 +2055,8 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA= github.com/lyft/protoc-gen-star v0.6.1/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA= github.com/lyft/protoc-gen-star/v2 v2.0.1/go.mod h1:RcCdONR2ScXaYnQC5tUzxzlpA3WVYF7/opLeUgcQs/o= @@ -2072,6 +2080,8 @@ github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= @@ -2082,9 +2092,6 @@ github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxU github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= -github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= -github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= -github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ= github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ= @@ -2129,6 +2136,12 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9 github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= @@ -2316,8 +2329,8 @@ github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40T github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= -github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rodaine/table v1.3.0 h1:4/3S3SVkHnVZX91EHFvAMV7K42AnJ0XuymRR2C5HlGE= github.com/rodaine/table v1.3.0/go.mod h1:47zRsHar4zw0jgxGxL9YtFfs7EGN6B/TaS+/Dmk4WxU= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= @@ -2897,6 +2910,7 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/server/handlers/k8s/database_engine.go b/internal/server/handlers/k8s/database_engine.go index 466c2d5c3..b3070ef05 100644 --- a/internal/server/handlers/k8s/database_engine.go +++ b/internal/server/handlers/k8s/database_engine.go @@ -39,7 +39,7 @@ func (h *k8sHandler) GetUpgradePlan(ctx context.Context, namespace string) (*api if err != nil { return nil, fmt.Errorf("failed to getUpgradePlan: %w", err) } - // No upgrades available, so we will check if our clusters are ready for current version. + // Abort upgrades available, so we will check if our clusters are ready for current version. if len(pointer.Get(result.Upgrades)) == 0 { result.PendingActions = pointer.To([]api.UpgradeTask{}) engines, err := h.kubeClient.ListDatabaseEngines(ctx, namespace) diff --git a/pkg/accounts/cli/accounts.go b/pkg/accounts/cli/accounts.go index 24423b7dd..816161bba 100644 --- a/pkg/accounts/cli/accounts.go +++ b/pkg/accounts/cli/accounts.go @@ -21,10 +21,8 @@ import ( "errors" "fmt" "os" - "regexp" "strings" - "github.com/AlecAivazis/survey/v2" "github.com/rodaine/table" "go.uber.org/zap" @@ -35,25 +33,23 @@ import ( "github.com/percona/everest/pkg/output" ) -const ( - minPasswordLength = 6 -) - -// Accounts provides functionality for managing user accounts via the Accounts. -type Accounts struct { - accountManager accounts.Interface - l *zap.SugaredLogger - config Config - kubeClient *kubernetes.Kubernetes -} +type ( + // Config holds the configuration for the accounts subcommands. + Config struct { + // KubeconfigPath is a path to a kubeconfig + KubeconfigPath string + // If set, we will print the pretty output. + Pretty bool + } -// Config holds the configuration for the accounts subcommands. -type Config struct { - // KubeconfigPath is a path to a kubeconfig - KubeconfigPath string - // If set, we will print the pretty output. - Pretty bool -} + // Accounts provides functionality for managing user accounts via the Accounts. + Accounts struct { + accountManager accounts.Interface + l *zap.SugaredLogger + config Config + kubeClient *kubernetes.Kubernetes + } +) // NewAccounts creates a new Accounts for running accounts commands. func NewAccounts(c Config, l *zap.SugaredLogger) (*Accounts, error) { @@ -80,93 +76,6 @@ func (c *Accounts) WithAccountManager(m accounts.Interface) { c.accountManager = m } -func (c *Accounts) runCredentialsWizard(username, password *string) error { - if *username == "" { - pUsername := survey.Input{ - Message: "Enter username", - } - if err := survey.AskOne(&pUsername, username); err != nil { - return err - } - } - if *password == "" { - pPassword := survey.Password{ - Message: "Enter password", - } - if err := survey.AskOne(&pPassword, password); err != nil { - return err - } - } - return nil -} - -// SetPasswordOptions holds options for setting a new password for user accounts. -type SetPasswordOptions struct { - // Username is the username for the account. - Username string - // NewPassword is a new password for the account. - NewPassword string -} - -// SetPassword sets the password for an existing account. -func (c *Accounts) SetPassword(ctx context.Context, opts SetPasswordOptions) error { - if opts.Username == "" { - pUsername := survey.Input{ - Message: "Enter username", - } - if err := survey.AskOne(&pUsername, &opts.Username); err != nil { - return err - } - } - - if opts.Username == "" { - return errors.New("username is required") - } - - if opts.NewPassword == "" { - resp := struct { - Password string - ConfPassword string - }{} - if err := survey.Ask([]*survey.Question{ - { - Name: "Password", - Prompt: &survey.Password{Message: "Enter new password"}, - Validate: survey.Required, - }, - { - Name: "ConfPassword", - Prompt: &survey.Password{Message: "Re-enter new password"}, - Validate: survey.Required, - }, - }, &resp, - ); err != nil { - return err - } - if resp.Password != resp.ConfPassword { - return errors.New("passwords do not match") - } - opts.NewPassword = resp.Password - } - - c.l.Infof("Setting a new password for user '%s'", opts.Username) - if ok, msg := validateCredentials(opts.Username, opts.NewPassword); !ok { - c.l.Error(msg) - return errors.New("invalid credentials") - } - - if err := c.accountManager.SetPassword(ctx, opts.Username, opts.NewPassword, true); err != nil { - return err - } - - c.l.Infof("Password for user '%s' has been set succesfully", opts.Username) - if c.config.Pretty { - _, _ = fmt.Fprintln(os.Stdout, output.Success("Password for user '%s' has been set successfully", opts.Username)) - } - - return nil -} - // CreateOptions holds options for creating a new user accounts. type CreateOptions struct { // Username is the username for the account. @@ -177,13 +86,12 @@ type CreateOptions struct { // Create a new user account. func (c *Accounts) Create(ctx context.Context, opts CreateOptions) error { - if err := c.runCredentialsWizard(&opts.Username, &opts.Password); err != nil { + if err := ValidateUsername(opts.Username); err != nil { return err } - if ok, msg := validateCredentials(opts.Username, opts.Password); !ok { - c.l.Error(msg) - return errors.New("invalid credentials") + if err := ValidatePassword(opts.Password); err != nil { + return err } c.l.Infof("Creating user '%s'", opts.Username) @@ -201,16 +109,8 @@ func (c *Accounts) Create(ctx context.Context, opts CreateOptions) error { // Delete an existing user account. func (c *Accounts) Delete(ctx context.Context, username string) error { - if username == "" { - if err := survey.AskOne(&survey.Input{ - Message: "Enter username", - }, &username, - ); err != nil { - return err - } - } - if username == "" { - return errors.New("username is required") + if err := ValidateUsername(username); err != nil { + return err } c.l.Infof("Deleting user '%s'", username) @@ -218,7 +118,7 @@ func (c *Accounts) Delete(ctx context.Context, username string) error { return err } - c.l.Infof("User '%s' has been deleted succesfully", username) + c.l.Infof("User '%s' has been deleted successfully", username) if c.config.Pretty { _, _ = fmt.Fprintln(os.Stdout, output.Success("User '%s' has been deleted successfully", username)) } @@ -226,6 +126,37 @@ func (c *Accounts) Delete(ctx context.Context, username string) error { return nil } +// SetPasswordOptions holds options for setting a new password for user accounts. +type SetPasswordOptions struct { + // Username is the username for the account. + Username string + // NewPassword is a new password for the account. + NewPassword string +} + +// SetPassword sets the password for an existing account. +func (c *Accounts) SetPassword(ctx context.Context, opts SetPasswordOptions) error { + if err := ValidateUsername(opts.Username); err != nil { + return err + } + + if err := ValidatePassword(opts.NewPassword); err != nil { + return err + } + + c.l.Infof("Setting a new password for user '%s'", opts.Username) + if err := c.accountManager.SetPassword(ctx, opts.Username, opts.NewPassword, true); err != nil { + return err + } + + c.l.Infof("Password for user '%s' has been set succesfully", opts.Username) + if c.config.Pretty { + _, _ = fmt.Fprintln(os.Stdout, output.Success("Password for user '%s' has been set successfully", opts.Username)) + } + + return nil +} + // ListOptions holds options for listing user accounts. type ListOptions struct { NoHeaders bool @@ -267,7 +198,7 @@ func (c *Accounts) List(ctx context.Context, opts ListOptions) error { // Return a table row for the given account. row := func(user string, account *accounts.Account) []any { - row := []any{} + var row []any for _, heading := range headings { switch heading { case ColumnUser: @@ -304,18 +235,6 @@ func (c *Accounts) GetInitAdminPassword(ctx context.Context) (string, error) { return admin.PasswordHash, nil } -func validateCredentials(username, password string) (bool, string) { - if !validateUsername(username) { - return false, - "Username must contain only letters, numbers, and underscores, and must be at least 3 characters long" - } - if !validatePassword(password) { - return false, - "Password must contain only letters, numbers and specific special characters (@#$%^&+=!_), and must be at least 6 characters long" - } - return true, "" -} - // CreateRSAKeyPair creates a new RSA key pair for user authentication. New RSA key pair is stored in the Kubernetes secret. func (c *Accounts) CreateRSAKeyPair(ctx context.Context) error { c.l.Info("Creating/Updating JWT keys and restarting Everest.") @@ -330,22 +249,3 @@ func (c *Accounts) CreateRSAKeyPair(ctx context.Context) error { } return nil } - -func validateUsername(username string) bool { - // Regular expression to validate username. - // [a-zA-Z0-9_] - Allowed characters (letters, digits, underscore) - // {3,} - Length of the username (minimum 3 characters) - pattern := "^[a-zA-Z0-9_]{3,}$" - regex := regexp.MustCompile(pattern) - return regex.MatchString(username) -} - -func validatePassword(password string) bool { - if strings.Contains(password, " ") { - return false - } - if len(password) < minPasswordLength { - return false - } - return true -} diff --git a/pkg/accounts/cli/accounts_test.go b/pkg/accounts/cli/accounts_test.go index 877956764..6702ea4fe 100644 --- a/pkg/accounts/cli/accounts_test.go +++ b/pkg/accounts/cli/accounts_test.go @@ -3,7 +3,7 @@ package cli import ( "testing" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestUsernamePasswordSanitation(t *testing.T) { @@ -11,33 +11,56 @@ func TestUsernamePasswordSanitation(t *testing.T) { t.Run("Username", func(t *testing.T) { t.Parallel() testCases := []struct { - username string - allowed bool + name string + username string + expectedErr error }{ - {"alice", true}, - {"bob!!", false}, - {"f", false}, - {"hello@@", false}, - {"bruce_wayne11", true}, + {"invalid_with_spaces", "b ob", ErrInvalidUsername}, + {"invalid_non_latin_chars", "аккаунт", ErrInvalidUsername}, + {"invalid_special_chars", "bob!!", ErrInvalidUsername}, + {"invalid_short", "f", ErrInvalidUsername}, + {"invalid_empty", "", ErrInvalidUsername}, + {"valid", "bob1", nil}, + {"valid_with_underscore", "bruce_wayne11", nil}, } for _, tc := range testCases { - result := validateUsername(tc.username) - assert.Equal(t, tc.allowed, result) + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := ValidateUsername(tc.username) + if tc.expectedErr == nil { + require.ErrorIs(t, err, tc.expectedErr) + } + }) } }) - t.Run("Password", func(t *testing.T) { + + t.Run("Password validation", func(t *testing.T) { t.Parallel() + testCases := []struct { - password string - allowed bool + name string + password string + expectedErr error }{ - {"pass", false}, - {"password with spaces", false}, - {"verysecurepassword!", true}, + {"invalid_short", "pass", ErrInvalidNewPassword}, + {"invalid_with_spaces", "password with spaces", ErrInvalidNewPassword}, + {"invalid_non_latin_chars", "пароль", ErrInvalidNewPassword}, + {"invalid_empty", "", ErrInvalidNewPassword}, + {"valid_lower_case", "verysecurepassword", nil}, + {"valid_upper_case", "VERYSECUREPASSWORD", nil}, + {"valid_lower_case_with_special_chars", "^v#r4$ec*u%ep@s+sw_o&!d=", nil}, + {"valid_upper_case_with_special_chars", "^V#R4$EC*U%EP@S+SW_O&!D=", nil}, + {"valid_mixed_case_with_special_chars", "^V#R4$Ec*U%Ep@S+sW_o&!d=", nil}, } + for _, tc := range testCases { - result := validatePassword(tc.password) - assert.Equal(t, tc.allowed, result) + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := ValidatePassword(tc.password) + if tc.expectedErr == nil { + require.ErrorIs(t, err, tc.expectedErr) + } + }) } }) } diff --git a/pkg/accounts/cli/utils.go b/pkg/accounts/cli/utils.go new file mode 100644 index 000000000..d051459a7 --- /dev/null +++ b/pkg/accounts/cli/utils.go @@ -0,0 +1,122 @@ +// everest +// Copyright (C) 2025 Percona LLC +// +// 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 cli holds commands for accounts command. +package cli + +import ( + "context" + "errors" + "regexp" + "strings" + + "github.com/percona/everest/pkg/cli/tui" +) + +const ( + usernameCriteria = "Username may contain only letters, numbers, underscores, and must be at least 3 characters long" + passwordCriteria = "Password may contain only letters, numbers and specific special characters (@#$%^&+=!_), and must be at least 6 characters long" +) + +var ( + // Regular expression to validate username. + // [a-zA-Z0-9_] - Allowed characters (letters, digits, underscore) + // {3,} - Length of the username (minimum 3 characters) + userNameValidateRegex = regexp.MustCompile("^[a-zA-Z0-9_]{3,}$") + + // ErrInvalidUsername is returned when the username doesn't match criteria. + ErrInvalidUsername = errors.New(strings.ToLower(usernameCriteria)) + + // Regular expression to validate password. + // [a-zA-Z0-9@*#$%^&+=!_] - Allowed characters (letters, digits, underscore) + // {6,} - Length of the password (minimum 6 characters) + passwordValidateRegex = regexp.MustCompile("^[a-zA-Z0-9@*#$%^&+=!_]{6,}$") + + // ErrInvalidNewPassword is returned when the new password doesn't match criteria. + ErrInvalidNewPassword = errors.New(strings.ToLower(passwordCriteria)) +) + +// ValidateUsername validates the provided username. +func ValidateUsername(username string) error { + if !userNameValidateRegex.MatchString(username) { + return ErrInvalidUsername + } + return nil +} + +// ValidatePassword validates the provided password. +func ValidatePassword(password string) error { + if !passwordValidateRegex.MatchString(password) { + return ErrInvalidNewPassword + } + return nil +} + +// PopulateUsername function to fill the username. +// This function shall be called only in cases when there is no other way to obtain username value. +// User will be asked to provide the username in interactive mode. +func PopulateUsername(ctx context.Context) (string, error) { + if username, err := tui.NewInput(ctx, "Provide username", + tui.WithInputHint(usernameCriteria), + tui.WithInputValidation(ValidateUsername), + ).Run(); err != nil { + return "", err + } else { + return username, nil + } +} + +// PopulatePassword function to fill the password. +// This function shall be called only in cases when there is no other way to obtain password value. +// User will be asked to provide the password in interactive mode. +func PopulatePassword(ctx context.Context) (string, error) { + // ask user to provide password + if password, err := tui.NewInputPassword(ctx, "Provide password", + tui.WithPasswordHint(passwordCriteria), + tui.WithPasswordValidation(ValidatePassword), + ).Run(); err != nil { + return "", err + } else { + return password, nil + } +} + +// PopulateNewPassword function to fill the new password. +// This function shall be called only in cases when there is no other way to obtain new password value. +// User will be asked to provide the new password and password confirmation in interactive mode. +func PopulateNewPassword(ctx context.Context) (string, error) { + // ask user to provide new password + var newPassword, newConfPassword string + var err error + if newPassword, err = tui.NewInputPassword(ctx, "Provide a new password", + tui.WithPasswordHint(passwordCriteria), + tui.WithPasswordValidation(ValidatePassword), + ).Run(); err != nil { + return "", err + } + + if newConfPassword, err = tui.NewInputPassword(ctx, "Confirm a new password", + tui.WithPasswordHint(passwordCriteria), + tui.WithPasswordValidation(ValidatePassword), + ).Run(); err != nil { + return "", err + } + + if newPassword != newConfPassword { + return "", errors.New("passwords do not match") + } + + return newPassword, nil +} diff --git a/pkg/cli/helm/installer.go b/pkg/cli/helm/installer.go index 5fade2def..8de6c788a 100644 --- a/pkg/cli/helm/installer.go +++ b/pkg/cli/helm/installer.go @@ -39,57 +39,70 @@ import ( "k8s.io/cli-runtime/pkg/genericclioptions" ) -var settings = helmcli.New() //nolint:gochecknoglobals - -// CLIOptions contains common options for the CLI. -type CLIOptions struct { - ChartDir string - RepoURL string - Values values.Options - Devel bool - ReuseValues bool - ResetValues bool - ResetThenReuseValues bool -} - // Everest Helm chart names. const ( - EverestChartName = "everest" + // DefaultHelmRepoURL is the default Helm repository URL to download the Everest charts. + DefaultHelmRepoURL = "https://percona.github.io/percona-helm-charts/" + // EverestChartName is the name of the Everest Helm chart that installs the Everest operator. + EverestChartName = "everest" + // EverestDBNamespaceChartName is the name of the Everest Helm chart that is installed + // into DB namespaces managed by Everest. EverestDBNamespaceChartName = "everest-db-namespace" ) -// DefaultHelmRepoURL is the default Helm repository URL to download the Everest charts. -const DefaultHelmRepoURL = "https://percona.github.io/percona-helm-charts/" - -// Installer installs a Helm chart. -type Installer struct { - ReleaseName string - ReleaseNamespace string - Values map[string]interface{} - CreateReleaseNamespace bool +var settings = helmcli.New() //nolint:gochecknoglobals - // internal fields, set only after Init() is called. - chart *chart.Chart - cfg *action.Configuration +// CLIOptions contains common options for the CLI. +type ( + CLIOptions struct { + // ChartDir path to the local directory with the Helm chart to be installed. + ChartDir string + // RepoURL URL of the Helm repository to download the chart from. + RepoURL string + // Values Helm values to be used during installation. + Values values.Options + // Devel indicates whether to use development versions of Helm charts, if available. + Devel bool + // ReuseValues indicates whether to reuse the last release's values during release upgrade. + ReuseValues bool + // ResetValues indicates whether to reset the last release's values during release upgrade. + ResetValues bool + // ResetThenReuseValues indicates whether to reset the last release's values then reuse them during release upgrade. + ResetThenReuseValues bool + } - // This is set only after Install/Upgrade is called. - release *release.Release -} + // Installer installs a Helm chart. + Installer struct { + // ReleaseName is the name of the Helm release. + ReleaseName string + // ReleaseNamespace is the namespace where the Helm release will be installed. + ReleaseNamespace string + // Values are the Helm values to be used during installation. + Values map[string]interface{} + // CreateReleaseNamespace indicates whether to create the release namespace. + CreateReleaseNamespace bool + // internal fields, set only after Init() is called. + chart *chart.Chart + cfg *action.Configuration + // This is set only after Install/Upgrade is called. + release *release.Release + } -// ChartOptions provide the options for loading a Helm chart. -type ChartOptions struct { - // Directory to load the Helm chart from. - // If set, ignores URL. - Directory string - // URL of the repository to pull the chart from. - URL string - // Version of the helm chart to install. - // If loading from a directory, needs to match the chart version. - Version string - // Name of the Helm chart to install. - // Required only if pulling from the specified URL. - Name string -} + // ChartOptions provide the options for loading a Helm chart. + ChartOptions struct { + // Directory to load the Helm chart from. + // If set, ignores URL. + Directory string + // URL of the repository to pull the chart from. + URL string + // Version of the helm chart to install. + // If loading from a directory, needs to match the chart version. + Version string + // Name of the Helm chart to install. + // Required only if pulling from the specified URL. + Name string + } +) // Init initializes the Installer with the specified options. func (i *Installer) Init(kubeconfigPath string, o ChartOptions) error { diff --git a/pkg/cli/helm/values.go b/pkg/cli/helm/values.go index dd1f6ce2b..846fb3176 100644 --- a/pkg/cli/helm/values.go +++ b/pkg/cli/helm/values.go @@ -15,7 +15,7 @@ func NewValues(v Values) map[string]string { // no need to re-run them during the upgrade. values["upgrade.preflightChecks"] = "false" - // No need to deploy the default DB namespace with the helm chart. + // Abort need to deploy the default DB namespace with the helm chart. // We will create it separately so that we're able to provide its // details as a separate step and also to avoid any potential issues. values["dbNamespace.enabled"] = "false" diff --git a/pkg/cli/install/install.go b/pkg/cli/install/install.go index fe737aabe..f1e8a02e1 100644 --- a/pkg/cli/install/install.go +++ b/pkg/cli/install/install.go @@ -22,11 +22,10 @@ import ( "fmt" "io" "os" - "strings" "time" versionpb "github.com/Percona-Lab/percona-version-service/versionpb" - "github.com/fatih/color" + "github.com/charmbracelet/lipgloss" goversion "github.com/hashicorp/go-version" "go.uber.org/zap" k8serrors "k8s.io/apimachinery/pkg/api/errors" @@ -46,102 +45,128 @@ import ( ) const ( - pollInterval = 5 * time.Second - pollTimeout = 10 * time.Minute - backoffInterval = 5 * time.Second - - // DefaultDBNamespaceName is the name of the default DB namespace during installation. - DefaultDBNamespaceName = "everest" + pollInterval = 5 * time.Second + pollTimeout = 10 * time.Minute ) -// Install implements the main logic for commands. -type Install struct { - l *zap.SugaredLogger +// Installer implements the main logic for commands. +type ( + // InstallConfig holds the configuration for the `install` command. + InstallConfig struct { + // KubeconfigPath is the path to the kubeconfig file. + KubeconfigPath string + // VersionMetadataURL Version service URL to retrieve version metadata information from. + VersionMetadataURL string + // Version defines Everest version to be installed. If empty, the latest version is installed. + Version string + // DisableTelemetry disables telemetry. + DisableTelemetry bool + // ClusterType is the type of the Kubernetes environment. + // If it is not set, the environment will be detected. + ClusterType kubernetes.ClusterType + // SkipEnvDetection skips detecting the Kubernetes environment. + // If it is set, the environment will not be detected. + // Set ClusterType if the environment is known and set this flag to avoid detection duplication. + SkipEnvDetection bool + // Pretty if set print the output in pretty mode. + Pretty bool + // SkipDBNamespace is set if the installation should skip provisioning database. + SkipDBNamespace bool + // Options related to Helm. + HelmConfig helm.CLIOptions + // NamespaceAddConfig is the configuration for the namespace add operation. + NamespaceAddConfig namespaces.NamespaceAddConfig + } - config Config - kubeClient *kubernetes.Kubernetes - versionService versionservice.Interface + // Installer provides the functionality to install Everest. + Installer struct { + l *zap.SugaredLogger + cfg InstallConfig + kubeClient *kubernetes.Kubernetes + versionService versionservice.Interface + // these are set only when Run is called. + installVersion string + helmInstaller *helm.Installer + } +) - // these are set only when Run is called. - clusterType kubernetes.ClusterType - installVersion string - helmInstaller *helm.Installer +// ------ Install Config ------ + +// NewInstallConfig returns a new InstallConfig. +func NewInstallConfig() InstallConfig { + return InstallConfig{ + ClusterType: kubernetes.ClusterTypeUnknown, + Pretty: true, + NamespaceAddConfig: namespaces.NewNamespaceAddConfig(), + } } -// Config holds the configuration for the install command. -type Config struct { - // KubeconfigPath is a path to a kubeconfig - KubeconfigPath string - // VersionMetadataURL stores hostname to retrieve version metadata information from. - VersionMetadataURL string - // Version defines the version to be installed. If empty, the latest version is installed. - Version string - // DisableTelemetry disables telemetry. - DisableTelemetry bool - // SkipEnvDetection skips detecting the Kubernetes environment. - SkipEnvDetection bool - // If set, we will print the pretty output. - Pretty bool - // SkipDBNamespace is set if the installation should skip provisioning database. - SkipDBNamespace bool - // Ask user to provide namespaces to be managed by Everest. - AskNamespaces bool - // Ask user to provide DB operators to be installed into namespaces managed by Everest. - AskOperators bool - - helm.CLIOptions - namespaces.NamespaceAddConfig +// detectKubernetesEnv detects the Kubernetes environment where Everest is installed. +func (cfg *InstallConfig) detectKubernetesEnv(ctx context.Context, l *zap.SugaredLogger) error { + if cfg.SkipEnvDetection { + return nil + } + + kubeClient, err := cliutils.NewKubeclient(l, cfg.KubeconfigPath) + if err != nil { + return fmt.Errorf("failed to create kubernetes client: %w", err) + } + + t, err := kubeClient.GetClusterType(ctx) + if err != nil { + return fmt.Errorf("failed to detect cluster type: %w", err) + } + cfg.ClusterType = t + cfg.NamespaceAddConfig.ClusterType = t + + // Skip detecting Kubernetes environment in the future. + cfg.SkipEnvDetection = true + cfg.NamespaceAddConfig.SkipEnvDetection = true + l.Infof("Detected Kubernetes environment: %s", t) + return nil } -// NewInstall returns a new Install struct. -func NewInstall(c Config, l *zap.SugaredLogger) (*Install, error) { - cli := &Install{ +// ------ Installer ------ + +// NewInstall returns a new Installer struct. +func NewInstall(c InstallConfig, l *zap.SugaredLogger) (*Installer, error) { + cli := &Installer{ l: l.With("component", "install"), } if c.Pretty { cli.l = zap.NewNop().Sugar() } - c.NamespaceAddConfig.Pretty = false - c.NamespaceAddConfig.CLIOptions = c.CLIOptions + c.NamespaceAddConfig.Pretty = c.Pretty + c.NamespaceAddConfig.HelmConfig = c.HelmConfig c.NamespaceAddConfig.KubeconfigPath = c.KubeconfigPath c.NamespaceAddConfig.DisableTelemetry = c.DisableTelemetry - cli.config = c + c.NamespaceAddConfig.SkipEnvDetection = c.SkipEnvDetection + cli.cfg = c - k, err := cliutils.NewKubeclient(cli.l, c.KubeconfigPath) + var err error + cli.kubeClient, err = cliutils.NewKubeclient(cli.l, c.KubeconfigPath) if err != nil { return nil, err } - cli.kubeClient = k + cli.versionService = versionservice.New(c.VersionMetadataURL) return cli, nil } // Run the Everest installation process. -func (o *Install) Run(ctx context.Context) error { - // Do not continue if Everest is already installed. - installedVersion, err := version.EverestVersionFromDeployment(ctx, o.kubeClient) - if client.IgnoreNotFound(err) != nil { - return errors.Join(err, errors.New("cannot check if Everest is already installed")) - } else if err == nil { - return fmt.Errorf("everest is already installed. Version: %s", installedVersion) - } - - if err := o.setKubernetesEnv(ctx); err != nil { +func (o *Installer) Run(ctx context.Context) error { + if err := o.cfg.detectKubernetesEnv(ctx, o.l); err != nil { return fmt.Errorf("failed to detect Kubernetes environment: %w", err) } - dbInstallStep, err := o.installDBNamespacesStep(ctx) - if err != nil { - return fmt.Errorf("could not create db install step: %w", err) - } - if err := o.setVersionInfo(ctx); err != nil { return fmt.Errorf("failed to get Everest version info: %w", err) } - if version.IsDev(o.installVersion) && o.config.ChartDir == "" { - cleanup, err := helmutils.SetupEverestDevChart(o.l, &o.config.ChartDir) + if version.IsDev(o.installVersion) && o.cfg.HelmConfig.ChartDir == "" { + // Note: n.cfg.HelmConfig.ChartDir will be rewritten inside SetupEverestDevChart + cleanup, err := helmutils.SetupEverestDevChart(o.l, &o.cfg.HelmConfig.ChartDir) if err != nil { return err } @@ -153,18 +178,23 @@ func (o *Install) Run(ctx context.Context) error { } installSteps := o.newInstallSteps() - if dbInstallStep != nil { - installSteps = append(installSteps, *dbInstallStep) + if !o.cfg.SkipDBNamespace { + // DB namespaces creation is required. + if dbInstallSteps, err := o.getDBNamespacesInstallSteps(ctx); err != nil { + return fmt.Errorf("could not create db install step: %w", err) + } else if dbInstallSteps != nil { + installSteps = append(installSteps, dbInstallSteps...) + } } var out io.Writer = os.Stdout - if !o.config.Pretty { + if !o.cfg.Pretty { out = io.Discard } // Run steps. - fmt.Fprintln(out, output.Info("Installing Everest version %s", o.installVersion)) - if err := steps.RunStepsWithSpinner(ctx, installSteps, out); err != nil { + _, _ = fmt.Fprintln(out, output.Info("Installing Everest version %s", o.installVersion)) + if err := steps.RunStepsWithSpinner(ctx, installSteps, o.cfg.Pretty); err != nil { return err } o.l.Infof("Everest '%s' has been successfully installed", o.installVersion) @@ -172,56 +202,53 @@ func (o *Install) Run(ctx context.Context) error { return nil } -func (o *Install) installDBNamespacesStep(ctx context.Context) (*steps.Step, error) { - if err := o.config.Populate(ctx, o.config.AskNamespaces, o.config.AskOperators); err != nil { - // not specifying a namespace in this context is allowed. - if errors.Is(err, namespaces.ErrNSEmpty) { - return nil, nil //nolint:nilnil - } - return nil, errors.Join(err, errors.New("namespaces configuration error")) - } - o.config.NamespaceAddConfig.ClusterType = o.clusterType - if o.clusterType != "" || o.config.SkipEnvDetection { - o.config.NamespaceAddConfig.SkipEnvDetection = true - } - i, err := namespaces.NewNamespaceAdd(o.config.NamespaceAddConfig, zap.NewNop().Sugar()) +// getDBNamespacesInstallSteps returns the steps to install the database namespaces. +// It returns nil if the namespaces are already installed. +// Note: o.cfg.NamespaceAddConfig.NamespaceList and o.cfg.NamespaceAddConfig.Operators +// must be set before calling this function. +func (o *Installer) getDBNamespacesInstallSteps(ctx context.Context) ([]steps.Step, error) { + i, err := namespaces.NewNamespaceAdd(o.cfg.NamespaceAddConfig, o.l) if err != nil { return nil, err } - return &steps.Step{ - Desc: fmt.Sprintf("Provisioning database namespaces (%s)", strings.Join(o.config.NamespaceList, ", ")), - F: func(ctx context.Context) error { - return i.Run(ctx) - }, - }, nil + + return i.GetNamespaceInstallSteps(ctx, o.installVersion) } //nolint:gochecknoglobals -var bold = color.New(color.Bold).SprintFunc() +var ( + titleStyle = lipgloss.NewStyle().Bold(true) + commandStyle = lipgloss.NewStyle().Italic(true) +) -func (o *Install) printPostInstallMessage(out io.Writer) { +func (o *Installer) printPostInstallMessage(out io.Writer) { + // func PrintPostInstallMessage(out io.Writer) { message := "\n" + output.Rocket("Thank you for installing Everest (v%s)!\n", o.installVersion) - message += "Follow the steps below to get started:\n\n" + // message := "\n" + output.Rocket("Thank you for installing Everest (v%s)!\n", "1.4.0") + message += "Follow the steps below to get started:" - if len(o.config.NamespaceList) == 0 { - message += bold("PROVISION A NAMESPACE FOR YOUR DATABASE:\n\n") + if len(o.cfg.NamespaceAddConfig.NamespaceList) == 0 { + message += fmt.Sprintf("\n\n%s", output.Info(titleStyle.Render("PROVISION A NAMESPACE FOR YOUR DATABASE:"))) message += "Install a namespace for your databases using the following command:\n\n" - message += "\teverestctl namespaces add [NAMESPACE]" - message += "\n\n" + message += fmt.Sprintf("\t%s", commandStyle.Render("everestctl namespaces add [NAMESPACE]")) } - message += bold("RETRIEVE THE INITIAL ADMIN PASSWORD:\n\n") - message += common.InitialPasswordWarningMessage + "\n\n" + message += fmt.Sprintf("\n\n%s", output.Info(titleStyle.Render("RETRIEVE THE INITIAL ADMIN PASSWORD:"))) + // message += common.InitialPasswordWarningMessage + "\n\n" + message += "Run the following command to get the initial admin password:\n\n" + message += fmt.Sprintf("\t%s\n\n", commandStyle.Render("everestctl accounts initial-admin-password")) + message += output.Warn("NOTE: The initial password is stored in plain text. For security, change it immediately using the following command:\n") + message += fmt.Sprintf("\t%s", commandStyle.Render("everestctl accounts set-password --username admin")) - message += bold("ACCESS THE EVEREST UI:\n\n") + message += fmt.Sprintf("\n\n%s", output.Info(titleStyle.Render("ACCESS THE EVEREST UI:"))) message += "To access the web UI, set up port-forwarding and visit http://localhost:8080 in your browser:\n\n" - message += "\tkubectl port-forward -n everest-system svc/everest 8080:8080" - message += "\n" + message += fmt.Sprintf("\t%s\n", commandStyle.Render("kubectl port-forward -n everest-system svc/everest 8080:8080")) - fmt.Fprint(out, message) + _, _ = fmt.Fprint(out, message) } -func (o *Install) setVersionInfo(ctx context.Context) error { +// setVersionInfo fetches the latest Everest version information from Version service. +func (o *Installer) setVersionInfo(ctx context.Context) error { meta, err := o.versionService.GetEverestMetadata(ctx) if err != nil { return errors.Join(err, errors.New("could not fetch version metadata")) @@ -245,32 +272,33 @@ func (o *Install) setVersionInfo(ctx context.Context) error { return nil } -func (o *Install) checkRequirements(supVer *common.SupportedVersion) error { +func (o *Installer) checkRequirements(supVer *common.SupportedVersion) error { if err := cliutils.VerifyCLIVersion(supVer); err != nil { return err } return nil } -func (o *Install) setupHelmInstaller(ctx context.Context) error { +// setupHelmInstaller initializes the Helm installer. +func (o *Installer) setupHelmInstaller(ctx context.Context) error { nsExists, err := o.namespaceExists(ctx, common.SystemNamespace) if err != nil { return err } overrides := helm.NewValues(helm.Values{ - ClusterType: o.clusterType, - VersionMetadataURL: o.config.VersionMetadataURL, + ClusterType: o.cfg.ClusterType, + VersionMetadataURL: o.cfg.VersionMetadataURL, }) - values := Must(helmutils.MergeVals(o.config.Values, overrides)) + values := Must(helmutils.MergeVals(o.cfg.HelmConfig.Values, overrides)) installer := &helm.Installer{ ReleaseName: common.SystemNamespace, ReleaseNamespace: common.SystemNamespace, Values: values, CreateReleaseNamespace: !nsExists, } - if err := installer.Init(o.config.KubeconfigPath, helm.ChartOptions{ - Directory: o.config.ChartDir, - URL: o.config.RepoURL, + if err := installer.Init(o.cfg.KubeconfigPath, helm.ChartOptions{ + Directory: o.cfg.HelmConfig.ChartDir, + URL: o.cfg.HelmConfig.RepoURL, Name: helm.EverestChartName, Version: o.installVersion, }); err != nil { @@ -280,21 +308,8 @@ func (o *Install) setupHelmInstaller(ctx context.Context) error { return nil } -func (o *Install) setKubernetesEnv(ctx context.Context) error { - if o.config.SkipEnvDetection { - return nil - } - t, err := o.kubeClient.GetClusterType(ctx) - if err != nil { - return fmt.Errorf("failed to detect cluster type: %w", err) - } - o.clusterType = t - o.l.Infof("Detected Kubernetes environment: %s", t) - return nil -} - -func (o *Install) newInstallSteps() []steps.Step { - steps := []steps.Step{ +func (o *Installer) newInstallSteps() []steps.Step { + return []steps.Step{ o.newStepInstallEverestHelmChart(), o.newStepEnsureEverestAPI(), o.newStepEnsureEverestOperator(), @@ -302,10 +317,9 @@ func (o *Install) newInstallSteps() []steps.Step { o.newStepEnsureCatalogSource(), o.newStepEnsureEverestMonitoring(), } - return steps } -func (o *Install) latestVersion(meta *versionpb.MetadataResponse) (*goversion.Version, *versionpb.MetadataVersion, error) { +func (o *Installer) latestVersion(meta *versionpb.MetadataResponse) (*goversion.Version, *versionpb.MetadataVersion, error) { var ( latest *goversion.Version latestMeta *versionpb.MetadataVersion @@ -314,10 +328,10 @@ func (o *Install) latestVersion(meta *versionpb.MetadataResponse) (*goversion.Ve err error ) - if o.config.Version != "" { - targetVersion, err = goversion.NewSemver(o.config.Version) + if o.cfg.Version != "" { + targetVersion, err = goversion.NewSemver(o.cfg.Version) if err != nil { - return nil, nil, errors.Join(err, fmt.Errorf("could not parse target version %q", o.config.Version)) + return nil, nil, errors.Join(err, fmt.Errorf("could not parse target version %q", o.cfg.Version)) } } @@ -348,7 +362,7 @@ func (o *Install) latestVersion(meta *versionpb.MetadataResponse) (*goversion.Ve return latest, latestMeta, nil } -func (o *Install) namespaceExists(ctx context.Context, namespace string) (bool, error) { +func (o *Installer) namespaceExists(ctx context.Context, namespace string) (bool, error) { _, err := o.kubeClient.GetNamespace(ctx, namespace) if err != nil { if k8serrors.IsNotFound(err) { @@ -358,3 +372,20 @@ func (o *Install) namespaceExists(ctx context.Context, namespace string) (bool, } return true, nil } + +// CheckEverestAlreadyinstalled checks if Everest is already installed. +func CheckEverestAlreadyinstalled(ctx context.Context, l *zap.SugaredLogger, kubeConfig string) error { + kubeClient, err := cliutils.NewKubeclient(l, kubeConfig) + if err != nil { + return fmt.Errorf("failed to create kubernetes client: %w", err) + } + + installedVersion, err := version.EverestVersionFromDeployment(ctx, kubeClient) + if client.IgnoreNotFound(err) != nil { + return errors.Join(err, errors.New("cannot check if Everest is already installed")) + } else if err == nil { + return fmt.Errorf("everest is already installed. Version: %s", installedVersion) + } + + return nil +} diff --git a/pkg/cli/install/install_test.go b/pkg/cli/install/install_test.go index e3d992f0d..d8c33c35f 100644 --- a/pkg/cli/install/install_test.go +++ b/pkg/cli/install/install_test.go @@ -98,8 +98,8 @@ func TestInstall_latestVersion(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - i := &Install{ - config: Config{ + i := &Installer{ + cfg: InstallConfig{ Version: tc.providedVersion, }, } diff --git a/pkg/cli/install/steps.go b/pkg/cli/install/steps.go index 8ffab2597..2719b4ded 100644 --- a/pkg/cli/install/steps.go +++ b/pkg/cli/install/steps.go @@ -27,7 +27,7 @@ import ( "github.com/percona/everest/pkg/kubernetes" ) -func (o *Install) newStepInstallEverestHelmChart() steps.Step { +func (o *Installer) newStepInstallEverestHelmChart() steps.Step { return steps.Step{ Desc: "Installing Everest Helm chart", F: func(ctx context.Context) error { @@ -36,7 +36,7 @@ func (o *Install) newStepInstallEverestHelmChart() steps.Step { } } -func (o *Install) newStepEnsureEverestOperator() steps.Step { +func (o *Installer) newStepEnsureEverestOperator() steps.Step { return steps.Step{ Desc: "Ensuring Everest operator deployment is ready", F: func(ctx context.Context) error { @@ -45,7 +45,7 @@ func (o *Install) newStepEnsureEverestOperator() steps.Step { } } -func (o *Install) newStepEnsureEverestAPI() steps.Step { +func (o *Installer) newStepEnsureEverestAPI() steps.Step { return steps.Step{ Desc: "Ensuring Everest API deployment is ready", F: func(ctx context.Context) error { @@ -54,7 +54,7 @@ func (o *Install) newStepEnsureEverestAPI() steps.Step { } } -func (o *Install) newStepEnsureEverestOLM() steps.Step { +func (o *Installer) newStepEnsureEverestOLM() steps.Step { return steps.Step{ Desc: "Ensuring OLM components are ready", F: func(ctx context.Context) error { @@ -72,14 +72,14 @@ func (o *Install) newStepEnsureEverestOLM() steps.Step { } } -func (o *Install) newStepEnsureEverestMonitoring() steps.Step { +func (o *Installer) newStepEnsureEverestMonitoring() steps.Step { return steps.Step{ Desc: "Ensuring monitoring stack is ready", F: func(ctx context.Context) error { if err := o.waitForDeployment(ctx, common.VictoriaMetricsOperatorDeploymentName, common.MonitoringNamespace); err != nil { return err } - if o.clusterType != kubernetes.ClusterTypeOpenShift { + if o.cfg.ClusterType != kubernetes.ClusterTypeOpenShift { if err := o.waitForDeployment(ctx, common.KubeStateMetricsDeploymentName, common.MonitoringNamespace); err != nil { return err } @@ -89,7 +89,7 @@ func (o *Install) newStepEnsureEverestMonitoring() steps.Step { } } -func (o *Install) newStepEnsureCatalogSource() steps.Step { +func (o *Installer) newStepEnsureCatalogSource() steps.Step { return steps.Step{ Desc: "Ensuring Everest CatalogSource is ready", F: func(ctx context.Context) error { @@ -112,7 +112,7 @@ func (o *Install) newStepEnsureCatalogSource() steps.Step { } } -func (o *Install) waitForDeployment(ctx context.Context, name, namespace string) error { +func (o *Installer) waitForDeployment(ctx context.Context, name, namespace string) error { o.l.Infof("Waiting for Deployment '%s' in namespace '%s'", name, namespace) if err := o.kubeClient.WaitForRollout(ctx, name, namespace); err != nil { return err @@ -121,7 +121,7 @@ func (o *Install) waitForDeployment(ctx context.Context, name, namespace string) return nil } -func (o *Install) installEverestHelmChart(ctx context.Context) error { +func (o *Installer) installEverestHelmChart(ctx context.Context) error { o.l.Info("Installing Everest Helm chart") if err := o.helmInstaller.Install(ctx); err != nil { return fmt.Errorf("could not install Helm chart: %w", err) diff --git a/pkg/cli/namespaces/add.go b/pkg/cli/namespaces/add.go index e861d5c48..4952fe76c 100644 --- a/pkg/cli/namespaces/add.go +++ b/pkg/cli/namespaces/add.go @@ -20,21 +20,15 @@ import ( "context" "errors" "fmt" - "io" "os" - "regexp" - "strings" - "github.com/AlecAivazis/survey/v2" - olmv1alpha1 "github.com/operator-framework/api/pkg/operators/v1alpha1" "go.uber.org/zap" "helm.sh/helm/v3/pkg/cli/values" - v1 "k8s.io/api/core/v1" - k8serrors "k8s.io/apimachinery/pkg/api/errors" "github.com/percona/everest/pkg/cli/helm" helmutils "github.com/percona/everest/pkg/cli/helm/utils" "github.com/percona/everest/pkg/cli/steps" + "github.com/percona/everest/pkg/cli/tui" cliutils "github.com/percona/everest/pkg/cli/utils" "github.com/percona/everest/pkg/common" "github.com/percona/everest/pkg/kubernetes" @@ -43,432 +37,369 @@ import ( "github.com/percona/everest/pkg/version" ) -//nolint:gochecknoglobals -var ( - // ErrNSEmpty appears when the provided list of the namespaces is considered empty. - ErrNSEmpty = errors.New("namespace list is empty. Specify at least one namespace") - // ErrNSReserved appears when some of the provided names are forbidden to use. - ErrNSReserved = func(ns string) error { - return fmt.Errorf("'%s' namespace is reserved for Everest internals. Please specify another namespace", ns) - } - // ErrNameNotRFC1035Compatible appears when some of the provided names are not RFC1035 compatible. - ErrNameNotRFC1035Compatible = func(fieldName string) error { - return fmt.Errorf(`'%s' is not RFC 1035 compatible. The name should contain only lowercase alphanumeric characters or '-', start with an alphabetic character, end with an alphanumeric character`, - fieldName, - ) +type ( + // OperatorConfig identifies which operators shall be installed. + OperatorConfig struct { + PG bool // is set if PostgresSQL shall be installed. + PSMDB bool // is set if MongoDB shall be installed. + PXC bool // is set if XtraDB Cluster shall be installed. + } + + // NamespaceAddConfig is the configuration for adding namespaces. + NamespaceAddConfig struct { + // NamespaceList is a list of namespaces to be managed by Everest and install operators. + // The property shall be set in case the namespaces are parsed and validated using ValidateNamespaces func. + // Otherwise, use Populate function to asked user to provide the namespaces in interactive mode. + NamespaceList []string + // SkipWizard is set if the wizard should be skipped. + SkipWizard bool + // KubeconfigPath is the path to the kubeconfig file. + KubeconfigPath string + // DisableTelemetry is set if telemetry should be disabled. + DisableTelemetry bool + // TakeOwnership make an existing namespace managed by Everest. + TakeOwnership bool + // ClusterType is the type of the Kubernetes environment. + // If it is not set, the environment will be detected. + ClusterType kubernetes.ClusterType + // SkipEnvDetection skips detecting the Kubernetes environment. + // If it is set, the environment will not be detected. + // Set ClusterType if the environment is known and set this flag to avoid detection duplication. + SkipEnvDetection bool + // AskOperators is set in case it is needed to use interactive mode and + // ask user to provide DB operators to be installed into namespaces managed by Everest. + // AskOperators bool + // Operators configurations for the operators to be installed into namespaces managed by Everest. + Operators OperatorConfig + // Pretty if set print the output in pretty mode. + Pretty bool + // Update is set if the existing namespace needs to be updated. + // This flag is set internally only, so that the add functionality may + // be re-used for updating the namespace as well. + Update bool + // Helm related options + HelmConfig helm.CLIOptions + } + + // NamespaceAdder provides the functionality to add namespaces. + NamespaceAdder struct { + l *zap.SugaredLogger + cfg NamespaceAddConfig + kubeClient *kubernetes.Kubernetes } - // ErrNoOperatorsSelected appears when no operators are selected for installation. - ErrNoOperatorsSelected = errors.New("no operators selected for installation. Minimum one operator must be selected") - - errCannotRemoveOperators = errors.New("cannot remove operators") ) -// NewNamespaceAdd returns a new CLI operation to add namespaces. -func NewNamespaceAdd(c NamespaceAddConfig, l *zap.SugaredLogger) (*NamespaceAdder, error) { - n := &NamespaceAdder{ - cfg: c, - l: l.With("component", "namespace-adder"), - } - if c.Pretty { - n.l = zap.NewNop().Sugar() - } +// --- NamespaceAddConfig functions - k, err := cliutils.NewKubeclient(n.l, c.KubeconfigPath) - if err != nil { - return nil, err +// NewNamespaceAddConfig returns a new NamespaceAddConfig. +func NewNamespaceAddConfig() NamespaceAddConfig { + return NamespaceAddConfig{ + ClusterType: kubernetes.ClusterTypeUnknown, + Pretty: true, } - n.kubeClient = k - n.clusterType = c.ClusterType - n.skipEnvDetection = c.SkipEnvDetection - return n, nil -} - -// NamespaceAddConfig is the configuration for adding namespaces. -type NamespaceAddConfig struct { - // Namespaces to install. - Namespaces string - // SkipWizard is set if the wizard should be skipped. - SkipWizard bool - // KubeconfigPath is the path to the kubeconfig file. - KubeconfigPath string - // DisableTelemetry is set if telemetry should be disabled. - DisableTelemetry bool - // TakeOwnership of an existing namespace. - TakeOwnership bool - // SkipEnvDetection skips detecting the Kubernetes environment. - SkipEnvDetection bool - // Ask user to provide DB operators to be installed into namespaces managed by Everest. - AskOperators bool - - Operator OperatorConfig - - // Pretty print the output. - Pretty bool - - // Update is set if the existing namespace needs to be updated. - // This flag is set internally only, so that the add functionality may - // be re-used for updating the namespace as well. - Update bool - // NamespaceList is a list of namespaces to install. - // This is populated internally after validating the Namespaces field.: - NamespaceList []string - - ClusterType kubernetes.ClusterType - helm.CLIOptions -} - -// OperatorConfig identifies which operators shall be installed. -type OperatorConfig struct { - // PG stores if PostgresSQL shall be installed. - PG bool - // PSMDB stores if MongoDB shall be installed. - PSMDB bool - // PXC stores if XtraDB Cluster shall be installed. - PXC bool -} - -// NamespaceAdder provides the functionality to add namespaces. -type NamespaceAdder struct { - l *zap.SugaredLogger - cfg NamespaceAddConfig - kubeClient *kubernetes.Kubernetes - clusterType kubernetes.ClusterType - skipEnvDetection bool } -// Run namespace add operation. -func (n *NamespaceAdder) Run(ctx context.Context) error { - // This command expects a Helm based installation (< 1.4.0) - ver, err := cliutils.CheckHelmInstallation(ctx, n.kubeClient) - if err != nil { +// PopulateNamespaces function to fill the configuration with the required NamespaceList. +// This function shall be called only in cases when there is no other way to obtain values for NamespaceList. +// User will be asked to provide the namespaces in interactive mode (if it is enabled). +// Provided by user namespaces will be parsed, validated and stored in the NamespaceList property. +// Note: in case NamespaceList is not empty - it will be overwritten by user's input. +func (cfg *NamespaceAddConfig) PopulateNamespaces(ctx context.Context) error { + if cfg.SkipWizard { + return errors.Join(fmt.Errorf("can't ask user for namespaces to install"), ErrInteractiveModeDisabled) + } + + var err error + var ns string + // Ask user to provide namespaces in interactive mode. + if ns, err = tui.NewInput(ctx, + "Provide database namespaces to be managed by Everest", + tui.WithInputDefaultValue(common.DefaultDBNamespaceName), + tui.WithInputHint("Namespaces can be provided in comma-separated form: ns-1,ns-2"), + ).Run(); err != nil { return err } - if err := n.cfg.Populate(ctx, false, n.cfg.AskOperators); err != nil { + nsList := ParseNamespaceNames(ns) + if err = cfg.ValidateNamespaces(ctx, nsList); err != nil { return err } - if !n.skipEnvDetection { - if err := n.setKubernetesEnv(ctx); err != nil { - return err - } - } + cfg.NamespaceList = nsList + return nil +} - var installSteps []steps.Step - if version.IsDev(ver) && n.cfg.ChartDir == "" { - cleanup, err := helmutils.SetupEverestDevChart(n.l, &n.cfg.ChartDir) - if err != nil { - return err - } - defer cleanup() +// PopulateOperators function to fill the configuration with the required Operators. +// This function shall be called only in cases when there is no other way to obtain values for Operators. +// User will be asked to provide the operators in interactive mode (if it is enabled). +// Provided by user operators will be stored in the Operators property. +// Note: Operators property will be overwritten by user's input. +func (cfg *NamespaceAddConfig) PopulateOperators(ctx context.Context) error { + if cfg.SkipWizard { + return fmt.Errorf("can't ask user for operators to install: %w", ErrInteractiveModeDisabled) + } + + // By default, all operators are selected. + defaultOpts := []tui.MultiSelectOption{ + {common.PXCProductName, true}, + {common.PSMDBProductName, true}, + {common.PGProductName, true}, + } + + var selectedOpts []tui.MultiSelectOption + var err error + if selectedOpts, err = tui.NewMultiSelect( + ctx, + "Which operators do you want to install?", + defaultOpts, + ).Run(); err != nil { + return err } - // validate operators for each namespace. - for _, namespace := range n.cfg.NamespaceList { - err := n.validateNamespace(ctx, namespace) - if errors.Is(err, errCannotRemoveOperators) { - msg := "Removal of an installed operator is not supported. Proceeding without removal." - fmt.Fprint(os.Stdout, output.Warn(msg)) //nolint:govet - n.l.Warn(msg) - break - } else if err != nil { - return fmt.Errorf("namespace validation error: %w", err) + // Copy user's choice to config. + for _, op := range selectedOpts { + switch op.Text { + case common.PXCProductName: + cfg.Operators.PXC = op.Selected + case common.PSMDBProductName: + cfg.Operators.PSMDB = op.Selected + case common.PGProductName: + cfg.Operators.PG = op.Selected } } - for _, namespace := range n.cfg.NamespaceList { - installSteps = append(installSteps, - n.newStepInstallNamespace(ver, namespace), - ) - } - - var out io.Writer = os.Stdout - if !n.cfg.Pretty { - out = io.Discard + if !(cfg.Operators.PXC || cfg.Operators.PG || cfg.Operators.PSMDB) { + // need to select at least one operator to install + return ErrOperatorsNotSelected } - if err := steps.RunStepsWithSpinner(ctx, installSteps, out); err != nil { - return err - } return nil } -func (n *NamespaceAdder) setKubernetesEnv(ctx context.Context) error { - if n.skipEnvDetection { - return nil - } - t, err := n.kubeClient.GetClusterType(ctx) - if err != nil { - return fmt.Errorf("failed to detect cluster type: %w", err) +// ValidateNamespaces validates the provided list of namespaces. +// It validates: +// - namespace names +// - namespace ownership +func (cfg *NamespaceAddConfig) ValidateNamespaces(ctx context.Context, nsList []string) error { + if err := validateNamespaceNames(nsList); err != nil { + return err } - n.clusterType = t - n.l.Infof("Detected Kubernetes environment: %s", t) - return nil -} - -func (n *NamespaceAdder) getValues() values.Options { - v := []string{} - v = append(v, "cleanupOnUninstall=false") // uninstall command will do the clean-up on its own. - v = append(v, fmt.Sprintf("pxc=%t", n.cfg.Operator.PXC)) - v = append(v, fmt.Sprintf("postgresql=%t", n.cfg.Operator.PG)) - v = append(v, fmt.Sprintf("psmdb=%t", n.cfg.Operator.PSMDB)) - v = append(v, fmt.Sprintf("telemetry=%t", !n.cfg.DisableTelemetry)) - if n.clusterType == kubernetes.ClusterTypeOpenShift { - v = append(v, "compatibility.openshift=true") + k, err := cliutils.NewKubeclient(zap.NewNop().Sugar(), cfg.KubeconfigPath) + if err != nil { + return err } - return values.Options{Values: v} -} -func (n *NamespaceAdder) newStepInstallNamespace(version, namespace string) steps.Step { - action := "Installing" - if n.cfg.Update { - action = "Updating" - } - return steps.Step{ - Desc: fmt.Sprintf("%s namespace '%s'", action, namespace), - F: func(ctx context.Context) error { - return n.provisionDBNamespace(ctx, version, namespace) - }, + for _, ns := range nsList { + if err := cfg.validateNamespaceOwnership(ctx, k, ns); err != nil { + return err + } } + return nil } -var ( - // ErrNsDoesNotExist appears when the namespace does not exist. - ErrNsDoesNotExist = errors.New("namespace does not exist") - // ErrNamespaceNotManagedByEverest appears when the namespace is not managed by Everest. - ErrNamespaceNotManagedByEverest = errors.New("namespace is not managed by Everest") - // ErrNamespaceAlreadyExists appears when the namespace already exists. - ErrNamespaceAlreadyExists = errors.New("namespace already exists") - // ErrNamespaceAlreadyOwned appears when the namespace is already owned by Everest. - ErrNamespaceAlreadyOwned = errors.New("namespace already exists and is managed by Everest") -) - +// validateNamespaceOwnership validates the namespace existence and ownership. func (cfg *NamespaceAddConfig) validateNamespaceOwnership( ctx context.Context, + k kubernetes.KubernetesConnector, namespace string, ) error { - k, err := cliutils.NewKubeclient(zap.NewNop().Sugar(), cfg.KubeconfigPath) - if err != nil { - return err - } - - nsExists, ownedByEverest, err := namespaceExists(ctx, namespace, k) + nsExists, ownedByEverest, err := namespaceExists(ctx, k, namespace) if err != nil { return err } if cfg.Update { - if !nsExists { - return ErrNsDoesNotExist - } if !ownedByEverest { - return ErrNamespaceNotManagedByEverest + return NewErrNamespaceNotManagedByEverest(namespace) } + + if !nsExists { + return NewErrNamespaceNotExist(namespace) + } + return nil } - if nsExists && !cfg.TakeOwnership { - return ErrNamespaceAlreadyExists - } + if nsExists && ownedByEverest { - return ErrNamespaceAlreadyOwned + return NewErrNamespaceAlreadyManagedByEverest(namespace) + } + + if nsExists && !cfg.TakeOwnership { + return NewErrNamespaceAlreadyExists(namespace) } return nil } -func (n *NamespaceAdder) provisionDBNamespace( - ctx context.Context, - version string, - namespace string, -) error { - nsExists, _, err := namespaceExists(ctx, namespace, n.kubeClient) - if err != nil { - return err - } - values := Must(helmutils.MergeVals(n.getValues(), nil)) - installer := helm.Installer{ - ReleaseName: namespace, - ReleaseNamespace: namespace, - Values: values, - CreateReleaseNamespace: !nsExists, +// detectKubernetesEnv detects the Kubernetes environment where Everest is installed. +func (cfg *NamespaceAddConfig) detectKubernetesEnv(ctx context.Context, l *zap.SugaredLogger) error { + if cfg.SkipEnvDetection { + return nil } - if err := installer.Init(n.cfg.KubeconfigPath, helm.ChartOptions{ - Directory: cliutils.DBNamespaceSubChartPath(n.cfg.ChartDir), - URL: n.cfg.RepoURL, - Name: helm.EverestDBNamespaceChartName, - Version: version, - }); err != nil { - return fmt.Errorf("could not initialize Helm installer: %w", err) + + client, err := cliutils.NewKubeclient(l, cfg.KubeconfigPath) + if err != nil { + return fmt.Errorf("failed to create kubernetes client: %w", err) } - n.l.Infof("Installing DB namespace Helm chart in namespace ", namespace) - return installer.Install(ctx) -} -// Returns: [exists, managedByEverest, error]. -func namespaceExists( - ctx context.Context, - namespace string, - k kubernetes.KubernetesConnector, -) (bool, bool, error) { - ns, err := k.GetNamespace(ctx, namespace) + t, err := client.GetClusterType(ctx) if err != nil { - if k8serrors.IsNotFound(err) { - return false, false, nil - } - return false, false, fmt.Errorf("cannot check if namesapce exists: %w", err) + return fmt.Errorf("failed to detect cluster type: %w", err) } - return true, isManagedByEverest(ns), nil -} -func isManagedByEverest(ns *v1.Namespace) bool { - val, ok := ns.GetLabels()[common.KubernetesManagedByLabel] - return ok && val == common.Everest + cfg.ClusterType = t + // Skip detecting Kubernetes environment in the future. + cfg.SkipEnvDetection = true + l.Infof("Detected Kubernetes environment: %s", t) + return nil } -// Populate the configuration with the required values. -func (cfg *NamespaceAddConfig) Populate(ctx context.Context, askNamespaces, askOperators bool) error { - if err := cfg.populateNamespaces(askNamespaces); err != nil { - return err - } +// --- NewNamespaceAdd functions - for _, ns := range cfg.NamespaceList { - if err := cfg.validateNamespaceOwnership(ctx, ns); err != nil { - return fmt.Errorf("invalid namespace (%s): %w", ns, err) +// NewNamespaceAdd returns a new CLI operation to add namespaces. +func NewNamespaceAdd(c NamespaceAddConfig, l *zap.SugaredLogger) (*NamespaceAdder, error) { + { + // validate the provided configuration + if len(c.NamespaceList) == 0 { + // need to provide at least one namespace to install + return nil, ErrNamespaceListEmpty } - } - if askOperators && len(cfg.NamespaceList) > 0 && !cfg.SkipWizard { - if err := cfg.populateOperators(); err != nil { - return err + if !(c.Operators.PXC || c.Operators.PG || c.Operators.PSMDB) { + // need to select at least one operator to install + return nil, ErrOperatorsNotSelected } } - return nil -} - -func (cfg *NamespaceAddConfig) populateNamespaces(wizard bool) error { - namespaces := cfg.Namespaces - // no namespaces provided, ask the user - if wizard && !cfg.SkipWizard { - pNamespace := &survey.Input{ - Message: "Namespaces managed by Everest [comma separated]", - Default: cfg.Namespaces, - } - if err := survey.AskOne(pNamespace, &namespaces); err != nil { - return err - } + n := &NamespaceAdder{ + cfg: c, + l: l.With("component", "namespace-adder"), + } + if c.Pretty { + n.l = zap.NewNop().Sugar() } - list, err := ValidateNamespaces(namespaces) + k, err := cliutils.NewKubeclient(n.l, c.KubeconfigPath) if err != nil { - return err + return nil, err } - cfg.NamespaceList = list - return nil + n.kubeClient = k + return n, nil } -func (cfg *NamespaceAddConfig) populateOperators() error { - operatorOpts := []struct { - label string - boolFlag *bool - }{ - {"MySQL", &cfg.Operator.PXC}, - {"MongoDB", &cfg.Operator.PSMDB}, - {"PostgreSQL", &cfg.Operator.PG}, - } - operatorLabels := make([]string, 0, len(operatorOpts)) - for _, v := range operatorOpts { - operatorLabels = append(operatorLabels, v.label) - } - operatorDefaults := make([]string, 0, len(operatorOpts)) - for _, v := range operatorOpts { - if *v.boolFlag { - operatorDefaults = append(operatorDefaults, v.label) - } - } - - pOps := &survey.MultiSelect{ - Message: "Which operators do you want to install?", - Default: operatorDefaults, - Options: operatorLabels, - } - opIndexes := []int{} - if err := survey.AskOne( - pOps, - &opIndexes, - ); err != nil { +// Run namespace add operation. +func (n *NamespaceAdder) Run(ctx context.Context) error { + // This command expects a Helm based installation (>= 1.4.0) + dbNSChartVersion, err := cliutils.CheckHelmInstallation(ctx, n.kubeClient) + if err != nil { return err } - if len(opIndexes) == 0 && len(cfg.NamespaceList) > 0 { - return ErrNoOperatorsSelected + if version.IsDev(dbNSChartVersion) && n.cfg.HelmConfig.ChartDir == "" { + // Note: new value will be set to n.cfg.ChartDir inside SetupEverestDevChart + cleanup, err := helmutils.SetupEverestDevChart(n.l, &n.cfg.HelmConfig.ChartDir) + if err != nil { + return err + } + defer cleanup() } - // We reset all flags to false so we select only - // the ones which the user selected in the multiselect. - for _, op := range operatorOpts { - *op.boolFlag = false + if err := n.cfg.detectKubernetesEnv(ctx, n.l); err != nil { + return fmt.Errorf("failed to detect Kubernetes environment: %w", err) } - for _, i := range opIndexes { - *operatorOpts[i].boolFlag = true + installSteps, err := n.GetNamespaceInstallSteps(ctx, dbNSChartVersion) + if err != nil { + return err } + if err := steps.RunStepsWithSpinner(ctx, installSteps, n.cfg.Pretty); err != nil { + return err + } return nil } -// ValidateNamespaces validates a comma-separated namespaces string. -func ValidateNamespaces(str string) ([]string, error) { - nsList := strings.Split(str, ",") - m := make(map[string]struct{}) - for _, ns := range nsList { - ns = strings.TrimSpace(ns) - if ns == "" { - continue - } - - if ns == common.SystemNamespace || ns == common.MonitoringNamespace || ns == kubernetes.OLMNamespace { - return nil, ErrNSReserved(ns) - } - - if err := validateRFC1035(ns); err != nil { - return nil, err +// GetNamespaceInstallSteps returns the steps to install namespaces. +func (n *NamespaceAdder) GetNamespaceInstallSteps(ctx context.Context, dbNSChartVersion string) ([]steps.Step, error) { + if n.cfg.Update { + // validate operators updated list for each namespace. + for _, namespace := range n.cfg.NamespaceList { + err := n.validateNamespaceUpdate(ctx, namespace) + if errors.Is(err, ErrCannotRemoveOperators) { + msg := "Removal of an installed operator is not supported. Proceeding without removal." + _, _ = fmt.Fprint(os.Stdout, output.Warn(msg)) //nolint:govet + n.l.Warn(msg) + break + } else if err != nil { + return nil, fmt.Errorf("namespace validation error: %w", err) + } } - - m[ns] = struct{}{} } - list := make([]string, 0, len(m)) - for k := range m { - list = append(list, k) - } - if len(list) == 0 { - return nil, ErrNSEmpty + var installSteps []steps.Step + for _, namespace := range n.cfg.NamespaceList { + installSteps = append(installSteps, + n.newStepInstallNamespace(dbNSChartVersion, namespace), + ) } - return list, nil + return installSteps, nil } -// validates names to be RFC-1035 compatible https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#rfc-1035-label-names -func validateRFC1035(s string) error { - rfc1035Regex := "^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$" - re := regexp.MustCompile(rfc1035Regex) - if !re.MatchString(s) { - return ErrNameNotRFC1035Compatible(s) +func (n *NamespaceAdder) getValues() values.Options { + var v []string + v = append(v, "cleanupOnUninstall=false") // uninstall command will do the clean-up on its own. + v = append(v, fmt.Sprintf("pxc=%t", n.cfg.Operators.PXC)) + v = append(v, fmt.Sprintf("postgresql=%t", n.cfg.Operators.PG)) + v = append(v, fmt.Sprintf("psmdb=%t", n.cfg.Operators.PSMDB)) + v = append(v, fmt.Sprintf("telemetry=%t", !n.cfg.DisableTelemetry)) + + if n.cfg.ClusterType == kubernetes.ClusterTypeOpenShift { + v = append(v, "compatibility.openshift=true") } + return values.Options{Values: v} +} - return nil +func (n *NamespaceAdder) newStepInstallNamespace(version, namespace string) steps.Step { + action := "Provisioning" + if n.cfg.Update { + action = "Updating" + } + return steps.Step{ + Desc: fmt.Sprintf("%s database namespace '%s'", action, namespace), + F: func(ctx context.Context) error { + return n.provisionDBNamespace(ctx, version, namespace) + }, + } } -func (n *NamespaceAdder) validateNamespace( +func (n *NamespaceAdder) provisionDBNamespace( ctx context.Context, + version string, namespace string, ) error { - if n.cfg.Update { - return n.validateNamespaceUpdate(ctx, namespace) + nsExists, _, err := namespaceExists(ctx, n.kubeClient, namespace) + if err != nil { + return err } - return nil + values := Must(helmutils.MergeVals(n.getValues(), nil)) + installer := helm.Installer{ + ReleaseName: namespace, + ReleaseNamespace: namespace, + Values: values, + CreateReleaseNamespace: !nsExists, + } + if err := installer.Init(n.cfg.KubeconfigPath, helm.ChartOptions{ + Directory: cliutils.DBNamespaceSubChartPath(n.cfg.HelmConfig.ChartDir), + URL: n.cfg.HelmConfig.RepoURL, + Name: helm.EverestDBNamespaceChartName, + Version: version, + }); err != nil { + return fmt.Errorf("could not initialize Helm installer: %w", err) + } + n.l.Info("Installing DB namespace Helm chart in namespace ", namespace) + return installer.Install(ctx) } func (n *NamespaceAdder) validateNamespaceUpdate(ctx context.Context, namespace string) error { @@ -477,34 +408,9 @@ func (n *NamespaceAdder) validateNamespaceUpdate(ctx context.Context, namespace return fmt.Errorf("cannot list subscriptions: %w", err) } if !ensureNoOperatorsRemoved(subscriptions.Items, - n.cfg.Operator.PG, n.cfg.Operator.PXC, n.cfg.Operator.PSMDB, + n.cfg.Operators.PG, n.cfg.Operators.PXC, n.cfg.Operators.PSMDB, ) { - return errCannotRemoveOperators + return ErrCannotRemoveOperators } return nil } - -func ensureNoOperatorsRemoved( - subscriptions []olmv1alpha1.Subscription, - installPG, installPXC, installPSMDB bool, -) bool { - for _, subscription := range subscriptions { - switch subscription.GetName() { - case common.PGOperatorName: - if !installPG { - return false - } - case common.PSMDBOperatorName: - if !installPSMDB { - return false - } - case common.PXCOperatorName: - if !installPXC { - return false - } - default: - continue - } - } - return true -} diff --git a/pkg/cli/namespaces/add_test.go b/pkg/cli/namespaces/add_test.go index 4a5b7bfaf..d1cf18e98 100644 --- a/pkg/cli/namespaces/add_test.go +++ b/pkg/cli/namespaces/add_test.go @@ -6,98 +6,166 @@ import ( "github.com/stretchr/testify/assert" ) -func TestValidateNamespaces(t *testing.T) { +func TestParseNamespaceNames(t *testing.T) { t.Parallel() type tcase struct { name string input string output []string - error error } tcases := []tcase{ { name: "empty string", input: "", - output: nil, - error: ErrNSEmpty, + output: []string{}, }, { name: "several empty strings", input: " , ,", - output: nil, - error: ErrNSEmpty, + output: []string{}, }, { name: "correct", input: "aaa,bbb,ccc", output: []string{"aaa", "bbb", "ccc"}, - error: nil, }, { name: "correct with spaces", input: ` aaa, bbb ,ccc `, output: []string{"aaa", "bbb", "ccc"}, - error: nil, }, { name: "reserved system ns", input: "everest-system", - output: nil, - error: ErrNSReserved("everest-system"), + output: []string{"everest-system"}, }, { name: "reserved system ns and empty ns", input: "everest-system, ", - output: nil, - error: ErrNSReserved("everest-system"), + output: []string{"everest-system"}, }, { name: "reserved monitoring ns", input: "everest-monitoring", - output: nil, - error: ErrNSReserved("everest-monitoring"), + output: []string{"everest-monitoring"}, }, { name: "reserved olm ns", input: "everest-olm", - output: nil, - error: ErrNSReserved("everest-olm"), + output: []string{"everest-olm"}, }, { name: "duplicated ns", input: "aaa,bbb,aaa", output: []string{"aaa", "bbb"}, - error: nil, }, { name: "name is too long", input: "e1234567890123456789012345678901234567890123456789012345678901234567890,bbb", - output: nil, - error: ErrNameNotRFC1035Compatible("e1234567890123456789012345678901234567890123456789012345678901234567890"), + output: []string{"e1234567890123456789012345678901234567890123456789012345678901234567890", "bbb"}, }, { name: "name starts with number", input: "1aaa,bbb", - output: nil, - error: ErrNameNotRFC1035Compatible("1aaa"), + output: []string{"1aaa", "bbb"}, }, { name: "name contains special characters", input: "aa12a,b$s", - output: nil, - error: ErrNameNotRFC1035Compatible("b$s"), + output: []string{"aa12a", "b$s"}, + }, + } + + for _, tc := range tcases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + output := ParseNamespaceNames(tc.input) + assert.Equal(t, tc.output, output) + }) + } +} + +func TestValidateNamespaces(t *testing.T) { + t.Parallel() + + type tcase struct { + name string + input []string + error error + } + + tcases := []tcase{ + { + name: "empty list", + input: []string{}, + error: ErrNamespaceListEmpty, + }, + { + name: "empty string", + input: []string{""}, + error: ErrNameNotRFC1035Compatible(""), + }, + { + name: "several empty strings", + input: []string{" ", " "}, + error: ErrNameNotRFC1035Compatible(" "), + }, + { + name: "correct", + input: []string{"aaa", "bbb", "ccc"}, + error: nil, + }, + { + name: "reserved system ns", + input: []string{"everest-system"}, + error: ErrNamespaceReserved("everest-system"), + }, + { + name: "reserved system ns and empty ns", + input: []string{"everest-system", " "}, + error: ErrNamespaceReserved("everest-system"), + }, + { + name: "reserved monitoring ns", + input: []string{"everest-monitoring"}, + error: ErrNamespaceReserved("everest-monitoring"), + }, + { + name: "reserved olm ns", + input: []string{"everest-olm"}, + error: ErrNamespaceReserved("everest-olm"), + }, + { + name: "duplicated ns", + input: []string{"aaa", "bbb", "aaa"}, + error: nil, + }, + { + name: "name is too long", + input: []string{"e1234567890123456789012345678901234567890123456789012345678901234567890", "bbb"}, + error: ErrNameNotRFC1035Compatible("e1234567890123456789012345678901234567890123456789012345678901234567890"), + }, + { + name: "name starts with number", + input: []string{"1aaa", "bbb"}, + error: ErrNameNotRFC1035Compatible("1aaa"), + }, + { + name: "name contains special characters", + input: []string{"aa12a", "b$s"}, + error: ErrNameNotRFC1035Compatible("b$s"), }, } for _, tc := range tcases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - output, err := ValidateNamespaces(tc.input) + err := validateNamespaceNames(tc.input) assert.Equal(t, tc.error, err) - assert.ElementsMatch(t, tc.output, output) + // assert.ElementsMatch(t, tc.output, output) }) } } diff --git a/pkg/cli/namespaces/errors.go b/pkg/cli/namespaces/errors.go new file mode 100644 index 000000000..b68d6feda --- /dev/null +++ b/pkg/cli/namespaces/errors.go @@ -0,0 +1,84 @@ +// everest +// Copyright (C) 2025 Percona LLC +// +// 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 namespaces + +import ( + "errors" + "fmt" +) + +// nolint:gochecknoglobals +var ( + // ErrNamespaceNotExist appears when the namespace does not exist. + ErrNamespaceNotExist = errors.New("namespace does not exist") + + // ErrNamespaceNotExist appears when the namespace does not exist. + NewErrNamespaceNotExist = func(namespace string) error { + return fmt.Errorf("'%s': %w", namespace, ErrNamespaceNotExist) + } + + // ErrNamespaceAlreadyExists appears when the namespace already exists. + ErrNamespaceAlreadyExists = errors.New("namespace already exists") + + // ErrNamespaceAlreadyExists appears when the namespace already exists. + NewErrNamespaceAlreadyExists = func(namespace string) error { + // return errors.Join(fmt.Errorf("'%s'", namespace), ErrNamespaceAlreadyExists) + return fmt.Errorf("'%s': %w", namespace, ErrNamespaceAlreadyExists) + } + + // ErrNamespaceNotManagedByEverest appears when the namespace is not managed by Everest. + ErrNamespaceNotManagedByEverest = errors.New("namespace is not managed by Everest") + + // ErrNamespaceNotManagedByEverest appears when the namespace is not managed by Everest. + NewErrNamespaceNotManagedByEverest = func(namespace string) error { + return fmt.Errorf("'%s': %w", namespace, ErrNamespaceNotManagedByEverest) + } + + // // ErrNamespaceAlreadyManagedByEverest appears when the namespace is already owned by Everest. + ErrNamespaceAlreadyManagedByEverest = errors.New("namespace already exists and is managed by Everest") + + // ErrNamespaceAlreadyManagedByEverest appears when the namespace is already owned by Everest. + NewErrNamespaceAlreadyManagedByEverest = func(namespace string) error { + return fmt.Errorf("'%s': %s", namespace, ErrNamespaceAlreadyManagedByEverest) + } + + // ErrNamespaceListEmpty appears when the provided list of the namespaces is considered empty. + ErrNamespaceListEmpty = errors.New("namespace list is empty. Specify at least one namespace") + + // ErrNamespaceReserved appears when some of the provided names are forbidden to use. + ErrNamespaceReserved = func(ns string) error { + return fmt.Errorf("'%s' namespace is reserved for Everest internals. Please specify another namespace", ns) + } + + // ErrOperatorsNotSelected appears when no operators are selected for installation. + ErrOperatorsNotSelected = errors.New("no operators selected for installation. Minimum one operator must be selected") + + // ErrCannotRemoveOperators appears when user tries to delete operator from namespace. + ErrCannotRemoveOperators = errors.New("cannot remove operators") + + // ErrNameNotRFC1035Compatible appears when some of the provided names are not RFC1035 compatible. + ErrNameNotRFC1035Compatible = func(fieldName string) error { + return fmt.Errorf(`'%s' is not RFC 1035 compatible. The name should contain only lowercase alphanumeric characters or '-', start with an alphabetic character, end with an alphanumeric character`, + fieldName, + ) + } + + // ErrNamespaceNotEmpty is returned when the namespace is not empty. + ErrNamespaceNotEmpty = errors.New("cannot remove namespace with running database clusters") + + // ErrInteractiveModeDisabled is returned when interactive mode is disabled. + ErrInteractiveModeDisabled = errors.New("interactive mode is disabled") +) diff --git a/pkg/cli/namespaces/remove.go b/pkg/cli/namespaces/remove.go index 41ac894a5..9cf23e6cb 100644 --- a/pkg/cli/namespaces/remove.go +++ b/pkg/cli/namespaces/remove.go @@ -20,8 +20,6 @@ import ( "context" "errors" "fmt" - "io" - "os" "time" "go.uber.org/zap" @@ -41,67 +39,121 @@ const ( pollTimeout = 5 * time.Minute ) -// ErrNamespaceNotEmpty is returned when the namespace is not empty. -var ErrNamespaceNotEmpty = errors.New("cannot remove namespace with running database clusters") - // NamespaceRemoveConfig is the configuration for the namespace removal operation. -type NamespaceRemoveConfig struct { - // KubeconfigPath is a path to a kubeconfig - KubeconfigPath string - // Force delete a namespace by deleting databases in it. - Force bool - // If set, we will keep the namespace - KeepNamespace bool - // If set, we will print the pretty output. - Pretty bool - - // Namespaces (DB Namespaces managed by Everest) to remove - Namespaces string - // NamespaceList is a list of namespaces to remove. - // This is populated internally after validating the Namespaces field.: - NamespaceList []string -} +type ( + NamespaceRemoveConfig struct { + // NamespaceList is a list of namespaces to be removed. + // The property shall be set explicitly after the provided namespaces are parsed and validated using ValidateNamespaces func. + NamespaceList []string + // KubeconfigPath is a path to a kubeconfig + KubeconfigPath string + // Force delete a namespace by deleting databases in it. + Force bool + // If set, keep the namespace but remove all resources from it. + KeepNamespace bool + // Pretty if set print the output in pretty mode. + Pretty bool -// populate the configuration with the required values. -func (cfg *NamespaceRemoveConfig) populate(ctx context.Context, kubeClient *kubernetes.Kubernetes) error { - nsList, err := ValidateNamespaces(cfg.Namespaces) + // // Namespaces (DB Namespaces managed by Everest) to remove + // Namespaces string + } + + // NamespaceRemover is the CLI operation to remove namespaces. + NamespaceRemover struct { + cfg NamespaceRemoveConfig + kubeClient *kubernetes.Kubernetes + l *zap.SugaredLogger + } +) + +// ValidateNamespaces validates the provided list of namespaces. +// It validates: +// - namespace names +// - namespace ownership +func (cfg *NamespaceRemoveConfig) ValidateNamespaces(ctx context.Context, nsList []string) error { + if err := validateNamespaceNames(nsList); err != nil { + return err + } + + k, err := cliutils.NewKubeclient(zap.NewNop().Sugar(), cfg.KubeconfigPath) if err != nil { return err } for _, ns := range nsList { - // Check that the namespace exists. - exists, managedByEverest, err := namespaceExists(ctx, ns, kubeClient) - if err != nil { - return errors.Join(err, errors.New("failed to check if namespace exists")) - } - if !exists || !managedByEverest { - return errors.New(fmt.Sprintf("namespace '%s' does not exist or not managed by Everest", ns)) + if err := cfg.validateNamespaceOwnership(ctx, k, ns); err != nil { + return err } } - cfg.NamespaceList = nsList + // Check that there are no DB clusters left in namespaces. + dbsExist, err := k.DatabasesExist(ctx, nsList...) + if err != nil { + return errors.Join(err, errors.New("failed to check if databases exist")) + } + + if dbsExist && !cfg.Force { + return ErrNamespaceNotEmpty + } + return nil } -// NamespaceRemover is the CLI operation to remove namespaces. -type NamespaceRemover struct { - config NamespaceRemoveConfig - kubeClient *kubernetes.Kubernetes - l *zap.SugaredLogger +// validateNamespaceOwnership validates the namespace existence and ownership. +func (cfg *NamespaceRemoveConfig) validateNamespaceOwnership( + ctx context.Context, + k kubernetes.KubernetesConnector, + namespace string, +) error { + // Check that the namespace exists. + exists, managedByEverest, err := namespaceExists(ctx, k, namespace) + if err != nil { + return err + } + if !exists { + return NewErrNamespaceNotExist(namespace) + } + + if !managedByEverest { + return NewErrNamespaceNotManagedByEverest(namespace) + } + + return nil } +// populate the configuration with the required values. +// func (cfg *NamespaceRemoveConfig) populate(ctx context.Context, kubeClient *kubernetes.Kubernetes) error { +// nsList, err := validateNamespaceNames(cfg.Namespaces) +// if err != nil { +// return err +// } +// +// for _, ns := range nsList { +// // Check that the namespace exists. +// exists, managedByEverest, err := namespaceExists(ctx, ns, kubeClient) +// if err != nil { +// return errors.Join(err, errors.New("failed to check if namespace exists")) +// } +// if !exists || !managedByEverest { +// return errors.New(fmt.Sprintf("namespace '%s' does not exist or not managed by Everest", ns)) +// } +// } +// +// cfg.NamespaceList = nsList +// return nil +// } + // NewNamespaceRemove returns a new CLI operation to remove namespaces. func NewNamespaceRemove(c NamespaceRemoveConfig, l *zap.SugaredLogger) (*NamespaceRemover, error) { n := &NamespaceRemover{ - config: c, - l: l.With("component", "namespace-remover"), + cfg: c, + l: l.With("component", "namespace-remover"), } if c.Pretty { n.l = zap.NewNop().Sugar() } - k, err := cliutils.NewKubeclient(n.l, n.config.KubeconfigPath) + k, err := cliutils.NewKubeclient(n.l, n.cfg.KubeconfigPath) if err != nil { return nil, err } @@ -117,30 +169,25 @@ func (r *NamespaceRemover) Run(ctx context.Context) error { return err } - if err := r.config.populate(ctx, r.kubeClient); err != nil { - return err - } - - dbsExist, err := r.kubeClient.DatabasesExist(ctx, r.config.NamespaceList...) - if err != nil { - return errors.Join(err, errors.New("failed to check if databases exist")) - } + // if err := r.config.populate(ctx, r.kubeClient); err != nil { + // return err + // } - if dbsExist && !r.config.Force { - return ErrNamespaceNotEmpty - } + // dbsExist, err := r.kubeClient.DatabasesExist(ctx, r.cfg.NamespaceList...) + // if err != nil { + // return errors.Join(err, errors.New("failed to check if databases exist")) + // } + // + // if dbsExist && !r.cfg.Force { + // return ErrNamespaceNotEmpty + // } var removalSteps []steps.Step - for _, ns := range r.config.NamespaceList { - removalSteps = append(removalSteps, NewRemoveNamespaceSteps(ns, r.config.KeepNamespace, r.kubeClient)...) - } - - var out io.Writer = os.Stdout - if !r.config.Pretty { - out = io.Discard + for _, ns := range r.cfg.NamespaceList { + removalSteps = append(removalSteps, NewRemoveNamespaceSteps(ns, r.cfg.KeepNamespace, r.kubeClient)...) } - return steps.RunStepsWithSpinner(ctx, removalSteps, out) + return steps.RunStepsWithSpinner(ctx, removalSteps, r.cfg.Pretty) } // NewRemoveNamespaceSteps returns the steps to remove a namespace. diff --git a/pkg/cli/namespaces/utils.go b/pkg/cli/namespaces/utils.go new file mode 100644 index 000000000..08d5515af --- /dev/null +++ b/pkg/cli/namespaces/utils.go @@ -0,0 +1,133 @@ +// everest +// Copyright (C) 2025 Percona LLC +// +// 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 namespaces + +import ( + "context" + "fmt" + "regexp" + "strings" + + olmv1alpha1 "github.com/operator-framework/api/pkg/operators/v1alpha1" + v1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/utils/strings/slices" + + "github.com/percona/everest/pkg/common" + "github.com/percona/everest/pkg/kubernetes" +) + +// Regexp used to validate RFC1035 compatible names. +var rfc1035Regexp = regexp.MustCompile("^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$") + +// ParseNamespaceNames parses a comma-separated namespaces string. +// It returns a list of namespaces. +// Note: namespace names are not validated. +// Use validateNamespaceNames to validate them. +func ParseNamespaceNames(namespaces string) []string { + result := []string{} + for _, ns := range strings.Split(namespaces, ",") { + ns = strings.TrimSpace(ns) + if ns == "" { + continue + } + + if !slices.Contains(result, ns) { + result = append(result, ns) + } + } + + return result +} + +// validateNamespaceNames validates a list of namespaces parsed by ParseNamespaceNames. +// It validates the names to be: +// - RFC-1035 compatible +// - not reserved by Everest core +func validateNamespaceNames(nsList []string) error { + if len(nsList) == 0 { + return ErrNamespaceListEmpty + } + + for _, ns := range nsList { + if ns == common.SystemNamespace || + ns == common.MonitoringNamespace || + ns == kubernetes.OLMNamespace { + return ErrNamespaceReserved(ns) + } + + if err := validateRFC1035(ns); err != nil { + return err + } + } + return nil +} + +// validates names to be RFC-1035 compatible https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#rfc-1035-label-names +func validateRFC1035(s string) error { + if !rfc1035Regexp.MatchString(s) { + return ErrNameNotRFC1035Compatible(s) + } + + return nil +} + +// isManagedByEverest checks if the namespace is managed by Everest. +func isManagedByEverest(ns *v1.Namespace) bool { + val, ok := ns.GetLabels()[common.KubernetesManagedByLabel] + return ok && val == common.Everest +} + +// Returns: [exists, managedByEverest, error]. +func namespaceExists( + ctx context.Context, + k kubernetes.KubernetesConnector, + namespace string, +) (bool, bool, error) { + ns, err := k.GetNamespace(ctx, namespace) + if err != nil { + if k8serrors.IsNotFound(err) { + return false, false, nil + } + return false, false, fmt.Errorf("cannot check if namesapce exists: %w", err) + } + return true, isManagedByEverest(ns), nil +} + +func ensureNoOperatorsRemoved( + subscriptions []olmv1alpha1.Subscription, + installPG, installPXC, installPSMDB bool, +) bool { + for _, subscription := range subscriptions { + switch subscription.GetName() { + case common.PGOperatorName: + if !installPG { + return false + } + case common.PSMDBOperatorName: + if !installPSMDB { + return false + } + case common.PXCOperatorName: + if !installPXC { + return false + } + default: + continue + } + } + return true +} diff --git a/pkg/cli/steps/steps.go b/pkg/cli/steps/steps.go index 6b3d2d791..20f5a554e 100644 --- a/pkg/cli/steps/steps.go +++ b/pkg/cli/steps/steps.go @@ -18,23 +18,14 @@ package steps import ( "context" - "fmt" - "io" - "time" - "github.com/briandowns/spinner" - - "github.com/percona/everest/pkg/output" -) - -const ( - spinnerInterval = 150 * time.Millisecond + "github.com/percona/everest/pkg/cli/tui" ) // Step provides a way to run a function with a // pretty loading spinner animation. type Step struct { - // Desc is a human readable description of the step. + // Desc is a human-readable description of the step. Desc string // F is the function that will be called to execute the step. F func(ctx context.Context) error @@ -44,24 +35,19 @@ type Step struct { func RunStepsWithSpinner( ctx context.Context, steps []Step, - out io.Writer, + prettyPrint bool, ) error { - s := spinner.New( - spinner.CharSets[9], - spinnerInterval, - spinner.WithWriter(out), - ) + spinnerSteps := make([]tui.Step, 0, len(steps)) for _, step := range steps { - s.Suffix = " " + step.Desc - s.Start() - if err := step.F(ctx); err != nil { - s.Stop() - fmt.Fprint(out, output.Failure(step.Desc)) //nolint:govet - fmt.Fprint(out, "\t", err, "\n") - return err - } - s.Stop() - fmt.Fprint(out, output.Success(step.Desc)) //nolint:govet + spinnerSteps = append(spinnerSteps, tui.Step{ + Desc: step.Desc, + F: step.F, + }) } + + if err := tui.NewSpinner(ctx, spinnerSteps, tui.WithSpinnerPrettyPrint(prettyPrint)).Run(); err != nil { + return err + } + return nil } diff --git a/pkg/cli/tui/common.go b/pkg/cli/tui/common.go new file mode 100644 index 000000000..50b4a125c --- /dev/null +++ b/pkg/cli/tui/common.go @@ -0,0 +1,92 @@ +// everest +// Copyright (C) 2025 Percona LLC +// +// 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 tui provides UI elements for the CLI. +package tui + +import ( + "errors" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type ( + // ValidateInputFunc is a function that returns an error if the input is invalid. + ValidateInputFunc func(string) error +) + +var ( + // Common errors. + + ErrUserInterrupted = errors.New("user interrupted") + + // Common styles. + + // Style is applied to the prompt text. + textStyle = lipgloss.NewStyle(). + Bold(true). + Foreground( + lipgloss.AdaptiveColor{Light: "#000000", Dark: "ffffff"}, + ) + + // Style is applied to the successful result. + successStyle = lipgloss.NewStyle(). + Foreground( + lipgloss.AdaptiveColor{Light: "#000000", Dark: "#5fd700"}, + ) + + // Style is applied to the failure result. + failureStyle = lipgloss.NewStyle(). + Foreground( + lipgloss.AdaptiveColor{Light: "#F37C6F", Dark: "#F37C6F"}, + ) + + // Style is applied to the helper text: supported key combinations, etc. + // helperTextStyle = lipgloss.NewStyle(). + // Foreground( + // lipgloss.AdaptiveColor{Light: "#000000", Dark: "#46a4a9"}, + // ) + + helperTextStyle = lipgloss.NewStyle(). + Foreground( + lipgloss.AdaptiveColor{Light: "#1A7362", Dark: "#30D1B2"}, + ) + + // Common key bindings. + + // key binding and help description for Confirm action. + confirmKeyBinding = key.NewBinding( + key.WithKeys(tea.KeyEnter.String()), + key.WithHelp("Enter", "confirm"), + ) + + // key binding and help description for Quit action. + quitKeyBinding = key.NewBinding( + key.WithKeys(tea.KeyEsc.String(), tea.KeyCtrlC.String()), + key.WithHelp("Esc/Ctrl+c", "quit"), + ) +) + +func newHelpModel() help.Model { + model := help.New() + model.ShortSeparator = " | " + model.Styles.ShortKey = textStyle + model.Styles.ShortDesc = helperTextStyle + model.Styles.ShortSeparator = helperTextStyle + return model +} diff --git a/pkg/cli/tui/confirm.go b/pkg/cli/tui/confirm.go new file mode 100644 index 000000000..0d57c312b --- /dev/null +++ b/pkg/cli/tui/confirm.go @@ -0,0 +1,165 @@ +// everest +// Copyright (C) 2025 Percona LLC +// +// 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 tui provides UI elements for the CLI. +package tui + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +// Key bindings. +var confirmKeys = confirmKeyMap{ + Confirm: key.NewBinding( + key.WithKeys("y"), + key.WithHelp("y", "confirm"), + ), + Abort: key.NewBinding( + key.WithKeys("n"), + key.WithHelp("n", "abort"), + ), + Quit: quitKeyBinding, +} + +type ( + // confirmKeyMap defines a set of keybindings. To work for help it must satisfy + // key.Map. It could also very easily be a map[string]key.Binding. + confirmKeyMap struct { + Confirm key.Binding + Abort key.Binding + Help key.Binding + Quit key.Binding + } + + // Confirm represents a confirm (y/N) input element. + Confirm struct { + keys confirmKeyMap + help help.Model + textInput textinput.Model + p *tea.Program + confirm bool // set to true in case user confirms the action + done bool // set when user made a choice (doesn't matter if it's y or n) + interrupt bool // set in case user wants to quit (Esc or Ctrl+c) + } +) + +// ShortHelp returns keybindings to be shown in the mini help view. It's part +// of the key.Map interface. +func (k confirmKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Confirm, k.Abort, k.Quit} +} + +// FullHelp returns keybindings for the expanded help view. It's part of the +// key.Map interface. +func (k confirmKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Confirm, k.Abort, k.Quit}, // first column + {}, // second column + } +} + +// NewConfirm creates a new confirm input element. +// It asks user the question/message and wait for the confirmation (y/N). +func NewConfirm(ctx context.Context, message string) Confirm { + ti := textinput.New() + ti.Prompt = fmt.Sprintf("❓ %s ", message) + ti.PromptStyle = textStyle + ti.Placeholder = "(y/N): " + ti.PlaceholderStyle = helperTextStyle + ti.Cursor.Style = textStyle + ti.Cursor.TextStyle = textStyle + ti.CharLimit = 1 + ti.Focus() + + m := Confirm{ + keys: confirmKeys, + help: newHelpModel(), + textInput: ti, + } + + p := tea.NewProgram(m, tea.WithContext(ctx)) + m.p = p + return m +} + +// Run runs the confirm element. +func (m Confirm) Run() (bool, error) { + model, err := m.p.Run() + if model.(Confirm).interrupt { + os.Exit(1) + } + + return model.(Confirm).confirm, err +} + +// Init initializes the text confirm element. +// Implements bubbletea.Model interface. +func (m Confirm) Init() tea.Cmd { + return textinput.Blink +} + +// Update updates the text confirm element. +// Implements bubbletea.Model interface. +func (m Confirm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + // need to update the text input model first in order to + // get the correct cursor position and show user's input + var textInputCmd tea.Cmd + m.textInput, textInputCmd = m.textInput.Update(msg) + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keys.Quit): + m.interrupt = true + m.textInput.Blur() + cmd = tea.Quit + case key.Matches(msg, m.keys.Confirm): + m.confirm = true + m.done = true + m.textInput.Blur() + cmd = tea.Quit + case key.Matches(msg, m.keys.Abort): + m.confirm = false + m.done = true + m.textInput.Blur() + cmd = tea.Quit + } + } + + return m, tea.Sequence(textInputCmd, cmd) +} + +// View renders the confirm element view. +// Implements bubbletea.Model interface. +func (m Confirm) View() string { + s := strings.Builder{} + s.WriteString(fmt.Sprintf("%s\n", m.textInput.View())) + + if !m.done { + s.WriteString(fmt.Sprintf("\n%s\n", m.help.View(m.keys))) + } + + return s.String() +} diff --git a/pkg/cli/tui/input.go b/pkg/cli/tui/input.go new file mode 100644 index 000000000..dbe363f67 --- /dev/null +++ b/pkg/cli/tui/input.go @@ -0,0 +1,206 @@ +// everest +// Copyright (C) 2025 Percona LLC +// +// 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 tui provides UI elements for the CLI. +package tui + +import ( + "context" + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +type ( + // inputKeyMap defines a set of keybindings. To work for help it must satisfy + // key.Map. It could also very easily be a map[string]key.Binding. + inputKeyMap struct { + Confirm key.Binding + Quit key.Binding + } + + // Input represents a text input element. + Input struct { + keys inputKeyMap + help help.Model + textInput textinput.Model + p *tea.Program + done bool // user has confirmed the input + interrupt bool // set in case user wants to quit (Esc or Ctrl+c) + validateFunc ValidateInputFunc // function to validate the input + hint string // hint to show in the input field + defaultValue string // default value to show in the input field + } + + // InputOption is used to set options when initializing Input. + // Input can accept a variable number of options. + // + // Example usage: + // + // p := NewInput(ctx, WithValidation(validationFunc), WithHint(hintMessage)) + InputOption func(m *Input) +) + +// Key bindings. +var inputKeys = inputKeyMap{ + Confirm: confirmKeyBinding, + Quit: quitKeyBinding, +} + +// ShortHelp returns keybindings to be shown in the mini help view. It's part +// of the key.Map interface. +func (k inputKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Confirm, k.Quit} +} + +// FullHelp returns keybindings for the expanded help view. It's part of the +// key.Map interface. +func (k inputKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Confirm, k.Quit}, // first column + {}, // second column + } +} + +// NewInput creates a new text input element. +func NewInput(ctx context.Context, message string, opts ...InputOption) Input { + ti := textinput.New() + ti.Prompt = fmt.Sprintf("❓ %s: ", message) + ti.PromptStyle = textStyle + ti.Cursor.Style = textStyle + ti.Cursor.TextStyle = textStyle + ti.Focus() + + m := Input{ + keys: inputKeys, + help: newHelpModel(), + textInput: ti, + } + + // Apply all options to the program. + for _, opt := range opts { + opt(&m) + } + + p := tea.NewProgram(m, tea.WithContext(ctx)) + m.p = p + return m +} + +// WithInputDefaultValue lets you specify a default value that will be shown in the dialog +// when the user is prompted for input. +func WithInputDefaultValue(defaultValue string) InputOption { + return func(m *Input) { + if defaultValue != "" { + m.defaultValue = defaultValue + m.textInput.Placeholder = defaultValue + m.textInput.PlaceholderStyle = helperTextStyle + } + } +} + +// WithInputHint lets you specify a hint message that will be shown in the dialog +// when the user is prompted for input. +func WithInputHint(hint string) InputOption { + return func(m *Input) { + if hint != "" { + m.hint = fmt.Sprintf("HINT: %s", hint) + } + } +} + +// WithInputValidation lets you specify a validate function that will be +// used to validate user's input. +func WithInputValidation(validateFunc ValidateInputFunc) InputOption { + return func(m *Input) { + if validateFunc != nil { + m.validateFunc = validateFunc + } + } +} + +// Run runs the text input element. +func (m Input) Run() (string, error) { + model, err := m.p.Run() + if err != nil { + return "", err + } + + if model.(Input).interrupt { + return "", ErrUserInterrupted + } + + if model.(Input).validateFunc != nil { + if err := model.(Input).validateFunc(model.(Input).textInput.Value()); err != nil { + return "", err + } + } + return model.(Input).textInput.Value(), nil +} + +// Init initializes the text input element. +// Implements bubbletea.Model interface. +func (m Input) Init() tea.Cmd { + return textinput.Blink +} + +// Update updates the text input element. +// Implements bubbletea.Model interface. +func (m Input) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keys.Confirm): + m.done = true + m.textInput.Blur() + if m.textInput.Value() == "" { + m.textInput.SetValue(m.defaultValue) + } + cmd = tea.Quit + case key.Matches(msg, m.keys.Quit): + m.done = true + m.interrupt = true + m.textInput.Blur() + cmd = tea.Quit + } + } + + var textInputCmd tea.Cmd + m.textInput, textInputCmd = m.textInput.Update(msg) + + return m, tea.Sequence(textInputCmd, cmd) +} + +// View renders the input element view. +// Implements bubbletea.Model interface. +func (m Input) View() string { + s := strings.Builder{} + s.WriteString(fmt.Sprintf("%s\n", m.textInput.View())) + + if !m.done { + if m.hint != "" { + s.WriteString(fmt.Sprintf("\n%s\n", helperTextStyle.Render(m.hint))) + } + s.WriteString(fmt.Sprintf("\n%s\n", m.help.View(m.keys))) + } + + return s.String() +} diff --git a/pkg/cli/tui/input_password.go b/pkg/cli/tui/input_password.go new file mode 100644 index 000000000..0f8ace62a --- /dev/null +++ b/pkg/cli/tui/input_password.go @@ -0,0 +1,191 @@ +// everest +// Copyright (C) 2025 Percona LLC +// +// 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 tui provides UI elements for the CLI. +package tui + +import ( + "context" + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +type ( + // inputPasswordKeyMap defines a set of keybindings. To work for help it must satisfy + // key.Map. It could also very easily be a map[string]key.Binding. + inputPasswordKeyMap struct { + Confirm key.Binding + Quit key.Binding + } + + // InputPassword represents a password input element. + InputPassword struct { + keys inputPasswordKeyMap + help help.Model + textInput textinput.Model + p *tea.Program + done bool // user has confirmed the input + interrupt bool // set in case user wants to quit (Esc or Ctrl+c) + validateFunc ValidateInputFunc // function to validate the input + hint string // hint to show in the input field + } + + // InputPasswordOption is used to set options when initializing InputPassword. + // InputPassword can accept a variable number of options. + // + // Example usage: + // + // p := NewInputPassword(ctx, WithValidation(validationFunc), WithHint(hintMessage)) + InputPasswordOption func(m *InputPassword) +) + +// Key bindings. +var inputPasswordKeys = inputPasswordKeyMap{ + Confirm: confirmKeyBinding, + Quit: quitKeyBinding, +} + +// ShortHelp returns keybindings to be shown in the mini help view. It's part +// of the key.Map interface. +func (k inputPasswordKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Confirm, k.Quit} +} + +// FullHelp returns keybindings for the expanded help view. It's part of the +// key.Map interface. +func (k inputPasswordKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Confirm, k.Quit}, // first column + {}, // second column + } +} + +// NewInputPassword creates a new password input element. +func NewInputPassword(ctx context.Context, message string, opts ...InputPasswordOption) InputPassword { + ti := textinput.New() + ti.Prompt = fmt.Sprintf("❓ %s: ", message) + ti.PromptStyle = textStyle + ti.EchoMode = textinput.EchoPassword + ti.EchoCharacter = '*' + ti.Cursor.Style = textStyle + ti.Cursor.TextStyle = textStyle + ti.Focus() + + m := InputPassword{ + keys: inputPasswordKeys, + help: newHelpModel(), + textInput: ti, + } + + // Apply all options to the program. + for _, opt := range opts { + opt(&m) + } + + p := tea.NewProgram(m, tea.WithContext(ctx)) + m.p = p + return m +} + +// WithPasswordHint lets you specify a hint message that will be shown in the dialog +// when the user is prompted for input. +func WithPasswordHint(hint string) InputPasswordOption { + return func(m *InputPassword) { + if hint != "" { + m.hint = fmt.Sprintf("HINT: %s", hint) + } + } +} + +// WithPasswordValidation lets you specify a validate function that will be +// used to validate user's input. +func WithPasswordValidation(validateFunc ValidateInputFunc) InputPasswordOption { + return func(m *InputPassword) { + if validateFunc != nil { + m.validateFunc = validateFunc + } + } +} + +// Run runs the password input element. +func (m InputPassword) Run() (string, error) { + model, err := m.p.Run() + if err != nil { + return "", err + } + + if model.(InputPassword).interrupt { + return "", ErrUserInterrupted + } + + if model.(InputPassword).validateFunc != nil { + if err := model.(InputPassword).validateFunc(model.(InputPassword).textInput.Value()); err != nil { + return "", err + } + } + return model.(InputPassword).textInput.Value(), nil +} + +// Init initializes the password input element. +// Implements bubbletea.Model interface. +func (m InputPassword) Init() tea.Cmd { + return textinput.Blink +} + +// Update updates the password input element. +// Implements bubbletea.Model interface. +func (m InputPassword) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keys.Confirm): + m.done = true + m.textInput.Blur() + cmd = tea.Quit + case key.Matches(msg, m.keys.Quit): + m.done = true + m.interrupt = true + m.textInput.Blur() + cmd = tea.Quit + } + } + + var textInputCmd tea.Cmd + m.textInput, textInputCmd = m.textInput.Update(msg) + return m, tea.Sequence(textInputCmd, cmd) +} + +// View renders the password element view. +// Implements bubbletea.Model interface. +func (m InputPassword) View() string { + s := strings.Builder{} + s.WriteString(fmt.Sprintf("%s\n", m.textInput.View())) + + if !m.done { + if m.hint != "" { + s.WriteString(fmt.Sprintf("\n%s\n", helperTextStyle.Render(m.hint))) + } + s.WriteString(fmt.Sprintf("\n%s\n", m.help.View(m.keys))) + } + + return s.String() +} diff --git a/pkg/cli/tui/multi_select.go b/pkg/cli/tui/multi_select.go new file mode 100644 index 000000000..0896257c7 --- /dev/null +++ b/pkg/cli/tui/multi_select.go @@ -0,0 +1,241 @@ +// everest +// Copyright (C) 2025 Percona LLC +// +// 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 tui provides UI elements for the CLI. +package tui + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + // Cursor symbol. + // cursorChar = lipgloss.NewStyle(). + // Bold(true). + // Foreground(lipgloss.AdaptiveColor{Light: "#000000", Dark: "feffff"}). + // Render(">") + + // ------ + // Style applied to option label in case its checkbox is selected. + optionStyle = lipgloss.NewStyle(). + Foreground( + lipgloss.AdaptiveColor{Light: "#0E5FB5", Dark: "#93C7FF"}, + ) + // Style applied to option label in case its checkbox is unselected. + hoverOptionStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#0B4A8C", Dark: "#62AEFF"}). + Background(lipgloss.AdaptiveColor{Light: "#127AE8", Dark: "#439EFF"}) + + // Selected checkbox symbol. + // selectedCheckBox = fmt.Sprintf("{%s}", successStyle.Render("✓")) // [✓] + selectedCheckBox = "{X}" + // Unselected checkbox symbol. + // unselectedCheckbox = "[ ]" + unselectedCheckbox = "{ }" + + // ---------- + // Key bindings. + multiSelectKeys = multiSelectKeyMap{ + Confirm: confirmKeyBinding, + Quit: quitKeyBinding, + Up: key.NewBinding( + key.WithKeys(tea.KeyUp.String()), + key.WithHelp("↑", "up"), + ), + Down: key.NewBinding( + key.WithKeys(tea.KeyDown.String()), + key.WithHelp("↓", "down"), + ), + Space: key.NewBinding( + key.WithKeys(tea.KeySpace.String()), + key.WithHelp("space", "select/unselect"), + ), + } +) + +type ( + // multiSelectKeyMap defines a set of keybindings. To work for help it must satisfy + // key.Map. It could also very easily be a map[string]key.Binding. + multiSelectKeyMap struct { + Confirm key.Binding + Quit key.Binding + Up key.Binding + Down key.Binding + Space key.Binding + } + + // MultiSelectOption represents an option in the multi-select list. + MultiSelectOption struct { + Text string + Selected bool + } + + // MultiSelect represents a multi-select list. + MultiSelect struct { + keys multiSelectKeyMap + help help.Model + Message string // message to display + Choices []MultiSelectOption // possible options user may choose from + cursor int // which item in choice list our cursor is pointing at + p *tea.Program + done bool // user has confirmed the selection + interrupt bool // set in case user wants to quit (Esc or Ctrl+c) + } +) + +// ShortHelp returns keybindings to be shown in the mini help view. It's part +// of the key.Map interface. +func (k multiSelectKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Confirm, k.Up, k.Down, k.Space, k.Quit} +} + +// FullHelp returns keybindings for the expanded help view. It's part of the +// key.Map interface. +func (k multiSelectKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Confirm, k.Up, k.Down, k.Space, k.Quit}, // first column + {}, // second column + } +} + +// NewMultiSelect creates a new multi-select list. +func NewMultiSelect(ctx context.Context, message string, choices []MultiSelectOption) MultiSelect { + m := MultiSelect{ + keys: multiSelectKeys, + help: newHelpModel(), + Message: message, + Choices: choices, + } + + p := tea.NewProgram(m, tea.WithContext(ctx)) + m.p = p + return m +} + +// Run starts the multi-select list. +// It returns the selected options and error. +func (m MultiSelect) Run() ([]MultiSelectOption, error) { + model, err := m.p.Run() + if model.(MultiSelect).interrupt { + os.Exit(1) + } + return model.(MultiSelect).Choices, err +} + +// Init initializes the multi-select list. +// Implements bubbletea.Model interface. +func (m MultiSelect) Init() tea.Cmd { + // we do not need command here. + return nil +} + +// Update updates the multi-select list. +// Implements bubbletea.Model interface. +func (m MultiSelect) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keys.Confirm): + m.done = true + return m, tea.Quit + case key.Matches(msg, m.keys.Quit): + m.interrupt = true + m.done = true + return m, tea.Quit + + // The "up" key move the cursor up + case key.Matches(msg, m.keys.Up): + if m.cursor > 0 { + m.cursor-- + } + + // The "down" key move the cursor down + case key.Matches(msg, m.keys.Down): + if m.cursor < len(m.Choices)-1 { + m.cursor++ + } + + // The spacebar (a literal space) toggle + // the selected state for the item that the cursor is pointing at. + case key.Matches(msg, m.keys.Space): + m.Choices[m.cursor].Selected = !m.Choices[m.cursor].Selected + } + } + + // Return the updated model to the Bubble Tea runtime for processing. + // Note that we're not returning a command. + return m, nil +} + +// View renders the multi-select list. +// It returns a string representation of the UI. +// Implements bubbletea.Model interface. +func (m MultiSelect) View() string { + // The header + s := strings.Builder{} + s.WriteString(fmt.Sprintf("❓ %s\n", textStyle.Render(m.Message))) + + // Iterate over our choices + for i, choice := range m.Choices { + // Render the row + s.WriteString(drawLine(m.cursor == i, choice.Selected, choice.Text)) + } + + if !m.done { + s.WriteString(fmt.Sprintf("\n%s\n", m.help.View(m.keys))) + } + + // Send the UI for rendering + return s.String() +} + +// func drawLine(cursor, checked bool, label string) string { +// // Template contains: