diff --git a/cmd/cli/cmd/install.go b/cmd/cli/cmd/install.go index 5ebb8d944..955c14fc6 100644 --- a/cmd/cli/cmd/install.go +++ b/cmd/cli/cmd/install.go @@ -19,9 +19,10 @@ func NewInstall() *cobra.Command { var opts install.Config installCmd := &cobra.Command{ - Use: "install [OPTIONS]", - Short: "install Botkube into cluster", - Long: "Use this command to install the Botkube agent.", + Use: "install [OPTIONS]", + Short: "install Botkube into cluster", + Long: "Use this command to install the Botkube agent.", + Aliases: []string{"instl", "deploy"}, Example: heredoc.WithCLIName(` # Install latest stable Botkube version install diff --git a/cmd/cli/cmd/root.go b/cmd/cli/cmd/root.go index ac71ac594..961dcdec5 100644 --- a/cmd/cli/cmd/root.go +++ b/cmd/cli/cmd/root.go @@ -26,6 +26,7 @@ func NewRoot() *cobra.Command { Quick Start: $ install # Install Botkube + $ uninstall # Uninstall Botkube Botkube Cloud: @@ -45,6 +46,7 @@ func NewRoot() *cobra.Command { NewMigrate(), NewDocs(), NewInstall(), + NewUninstall(), extension.NewVersionCobraCmd( extension.WithUpgradeNotice(orgName, repoName), ), diff --git a/cmd/cli/cmd/uninstall.go b/cmd/cli/cmd/uninstall.go new file mode 100644 index 000000000..fdae1dcde --- /dev/null +++ b/cmd/cli/cmd/uninstall.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "os" + "time" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "github.com/kubeshop/botkube/internal/cli" + "github.com/kubeshop/botkube/internal/cli/heredoc" + "github.com/kubeshop/botkube/internal/cli/install" + "github.com/kubeshop/botkube/internal/cli/uninstall" + "github.com/kubeshop/botkube/internal/kubex" +) + +// NewUninstall returns a cobra.Command for deleting Botkube Helm release. +func NewUninstall() *cobra.Command { + var opts uninstall.Config + + uninstallCmd := &cobra.Command{ + Use: "uninstall [OPTIONS]", + Short: "uninstall Botkube from cluster", + Long: "Use this command to uninstall the Botkube agent.", + Aliases: []string{"uninstall", "del", "delete", "un"}, + Example: heredoc.WithCLIName(` + # Uninstall default Botkube Helm release + uninstall + + # Uninstall specific Botkube Helm release + uninstall --release-name botkube-dev`, cli.Name), + RunE: func(cmd *cobra.Command, args []string) error { + config, err := kubex.LoadRestConfigWithMetaInformation() + if err != nil { + return err + } + if err != nil { + return errors.Wrap(err, "while creating k8s config") + } + + return uninstall.Uninstall(cmd.Context(), os.Stdout, config, opts) + }, + } + + flags := uninstallCmd.Flags() + + kubex.RegisterKubeconfigFlag(flags) + + flags.StringVar(&opts.HelmParams.ReleaseName, "release-name", install.ReleaseName, "Botkube Helm release name.") + flags.StringVar(&opts.HelmParams.ReleaseNamespace, "namespace", install.Namespace, "Botkube namespace.") + + flags.BoolVar(&opts.HelmParams.DryRun, "dry-run", false, "simulate a uninstall") + flags.BoolVar(&opts.HelmParams.DisableHooks, "no-hooks", false, "prevent hooks from running during uninstallation") + flags.BoolVar(&opts.HelmParams.KeepHistory, "keep-history", false, "remove all associated resources and mark the release as deleted, but retain the release history") + flags.BoolVar(&opts.HelmParams.Wait, "wait", true, "if set, will wait until all the resources are deleted before returning. It will wait for as long as --timeout") + flags.StringVar(&opts.HelmParams.DeletionPropagation, "cascade", "background", "Must be \"background\", \"orphan\", or \"foreground\". Selects the deletion cascading strategy for the dependents. Defaults to background.") + flags.DurationVar(&opts.HelmParams.Timeout, "timeout", 300*time.Second, "time to wait for any individual Kubernetes operation (like Jobs for hooks)") + flags.StringVar(&opts.HelmParams.Description, "description", "", "add a custom description") + + return uninstallCmd +} diff --git a/cmd/cli/docs/botkube.md b/cmd/cli/docs/botkube.md index 220137b43..47c8f722a 100644 --- a/cmd/cli/docs/botkube.md +++ b/cmd/cli/docs/botkube.md @@ -15,6 +15,7 @@ A utility that simplifies working with Botkube. Quick Start: $ botkube install # Install Botkube + $ botkube uninstall # Uninstall Botkube Botkube Cloud: @@ -38,5 +39,6 @@ botkube [flags] * [botkube install](botkube_install.md) - install Botkube into cluster * [botkube login](botkube_login.md) - Login to a Botkube Cloud * [botkube migrate](botkube_migrate.md) - Automatically migrates Botkube installation into Botkube Cloud +* [botkube uninstall](botkube_uninstall.md) - uninstall Botkube from cluster * [botkube version](botkube_version.md) - Print the CLI version diff --git a/cmd/cli/docs/botkube_uninstall.md b/cmd/cli/docs/botkube_uninstall.md new file mode 100644 index 000000000..d534c3be3 --- /dev/null +++ b/cmd/cli/docs/botkube_uninstall.md @@ -0,0 +1,52 @@ +--- +title: botkube uninstall +--- + +## botkube uninstall + +uninstall Botkube from cluster + +### Synopsis + +Use this command to uninstall the Botkube agent. + +``` +botkube uninstall [OPTIONS] [flags] +``` + +### Examples + +``` +# Uninstall default Botkube Helm release +botkube uninstall + +# Uninstall specific Botkube Helm release +botkube uninstall --release-name botkube-dev +``` + +### Options + +``` + --cascade string Must be "background", "orphan", or "foreground". Selects the deletion cascading strategy for the dependents. Defaults to background. (default "background") + --description string add a custom description + --dry-run simulate a uninstall + -h, --help help for uninstall + --keep-history remove all associated resources and mark the release as deleted, but retain the release history + --kubeconfig string Paths to a kubeconfig. Only required if out-of-cluster. + --namespace string Botkube namespace. (default "botkube") + --no-hooks prevent hooks from running during uninstallation + --release-name string Botkube Helm release name. (default "botkube") + --timeout duration time to wait for any individual Kubernetes operation (like Jobs for hooks) (default 5m0s) + --wait if set, will wait until all the resources are deleted before returning. It will wait for as long as --timeout (default true) +``` + +### Options inherited from parent commands + +``` + -v, --verbose int/string[=simple] Prints more verbose output. Allowed values: 0 - disable, 1 - simple, 2 - trace (default 0 - disable) +``` + +### SEE ALSO + +* [botkube](botkube.md) - Botkube CLI + diff --git a/internal/cli/helmx/action_cfg.go b/internal/cli/helmx/action_cfg.go new file mode 100644 index 000000000..625fe05ba --- /dev/null +++ b/internal/cli/helmx/action_cfg.go @@ -0,0 +1,41 @@ +package helmx + +import ( + "fmt" + + "helm.sh/helm/v3/pkg/action" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/rest" + + "github.com/kubeshop/botkube/internal/cli" + "github.com/kubeshop/botkube/internal/ptr" +) + +const helmDriver = "secrets" + +// GetActionConfiguration returns generic configuration for Helm actions. +func GetActionConfiguration(k8sCfg *rest.Config, forNamespace string) (*action.Configuration, error) { + actionConfig := new(action.Configuration) + helmCfg := &genericclioptions.ConfigFlags{ + APIServer: &k8sCfg.Host, + Insecure: &k8sCfg.Insecure, + CAFile: &k8sCfg.CAFile, + BearerToken: &k8sCfg.BearerToken, + Namespace: ptr.FromType(forNamespace), + } + + debugLog := func(format string, v ...interface{}) { + if cli.VerboseMode.IsTracing() { + fmt.Print(" Helm log: ") // if enabled, we need to nest that under Helm step which was already printed with 2 spaces. + fmt.Printf(format, v...) + fmt.Println() + } + } + + err := actionConfig.Init(helmCfg, forNamespace, helmDriver, debugLog) + if err != nil { + return nil, fmt.Errorf("while initializing Helm configuration: %v", err) + } + + return actionConfig, nil +} diff --git a/internal/cli/install/helm/config.go b/internal/cli/install/helm/config.go index fca646094..0e3583d4c 100644 --- a/internal/cli/install/helm/config.go +++ b/internal/cli/install/helm/config.go @@ -4,10 +4,7 @@ import ( "helm.sh/helm/v3/pkg/cli/values" ) -const ( - repositoryCache = "/tmp/helm" - helmDriver = "secrets" -) +const repositoryCache = "/tmp/helm" // Config holds Helm configuration parameters. type Config struct { diff --git a/internal/cli/install/helm/install.go b/internal/cli/install/helm/install.go index 7eae546a3..fc58b4ef4 100644 --- a/internal/cli/install/helm/install.go +++ b/internal/cli/install/helm/install.go @@ -17,13 +17,11 @@ import ( "helm.sh/helm/v3/pkg/getter" "helm.sh/helm/v3/pkg/release" "helm.sh/helm/v3/pkg/storage/driver" - "k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/client-go/rest" - "github.com/kubeshop/botkube/internal/cli" + "github.com/kubeshop/botkube/internal/cli/helmx" "github.com/kubeshop/botkube/internal/cli/install/iox" "github.com/kubeshop/botkube/internal/cli/printer" - "github.com/kubeshop/botkube/internal/ptr" ) // Run provides single function signature both for install and upgrade. @@ -36,7 +34,7 @@ type Helm struct { // NewHelm returns a new Helm instance. func NewHelm(k8sCfg *rest.Config, forNamespace string) (*Helm, error) { - configuration, err := getConfiguration(k8sCfg, forNamespace) + configuration, err := helmx.GetActionConfiguration(k8sCfg, forNamespace) if err != nil { return nil, err } @@ -183,32 +181,6 @@ func (c *Helm) upgradeAction(opts Config) Run { } } -func getConfiguration(k8sCfg *rest.Config, forNamespace string) (*action.Configuration, error) { - actionConfig := new(action.Configuration) - helmCfg := &genericclioptions.ConfigFlags{ - APIServer: &k8sCfg.Host, - Insecure: &k8sCfg.Insecure, - CAFile: &k8sCfg.CAFile, - BearerToken: &k8sCfg.BearerToken, - Namespace: ptr.FromType(forNamespace), - } - - debugLog := func(format string, v ...interface{}) { - if cli.VerboseMode.IsTracing() { - fmt.Print(" Helm log: ") // if enabled, we need to nest that under Helm step which was already printed with 2 spaces. - fmt.Printf(format, v...) - fmt.Println() - } - } - - err := actionConfig.Init(helmCfg, forNamespace, helmDriver, debugLog) - if err != nil { - return nil, fmt.Errorf("while initializing Helm configuration: %v", err) - } - - return actionConfig, nil -} - func isLocalDir(in string) bool { f, err := os.Stat(in) return err == nil && f.IsDir() diff --git a/internal/cli/install/install.go b/internal/cli/install/install.go index ebf33b15e..cb05e6805 100644 --- a/internal/cli/install/install.go +++ b/internal/cli/install/install.go @@ -57,7 +57,12 @@ func Install(ctx context.Context, w io.Writer, k8sCfg *kubex.ConfigWithMeta, opt opts.HelmParams.Version = ver } - if err = printInstallationDetails(k8sCfg, opts, status); err != nil { + err = status.InfoStructFields("Installation details:", installDetails{ + Version: opts.HelmParams.Version, + HelmRepo: opts.HelmParams.RepoLocation, + K8sCtx: k8sCfg.CurrentContext, + }) + if err != nil { return err } @@ -93,6 +98,10 @@ func Install(ctx context.Context, w io.Writer, k8sCfg *kubex.ConfigWithMeta, opt return err } + if opts.HelmParams.DryRun { + return printSuccessInstallMessage(opts.HelmParams.Version, w) + } + if !opts.Watch { status.Infof("Watching Botkube installation is disabled") if err := helm.PrintReleaseStatus("Release details:", status, rel); err != nil { @@ -231,37 +240,13 @@ func printFailedInstallMessage(version string, namespace string, name string, w return nil } -var infoFieldsGoTpl = `{{ AdjustKeyWidth . }} - {{- range $item := (. | Extra) }} - {{ $item.Key | Key }} {{ $item.Value | Val }} - {{- end}} - -` - -type Custom struct { +type installDetails struct { // Fields are printed in the same order as defined in struct. Version string `pretty:"Version"` HelmRepo string `pretty:"Helm repository"` K8sCtx string `pretty:"Kubernetes Context"` } -func printInstallationDetails(cfg *kubex.ConfigWithMeta, opts Config, status *printer.StatusPrinter) error { - renderer := style.NewGoTemplateRender(style.DefaultConfig(infoFieldsGoTpl)) - - out, err := renderer.Render(Custom{ - Version: opts.HelmParams.Version, - HelmRepo: opts.HelmParams.RepoLocation, - K8sCtx: cfg.CurrentContext, - }, cli.IsSmartTerminal(status.Writer())) - if err != nil { - return err - } - - status.InfoWithBody("Installation details:", indent.String(out, 4)) - - return nil -} - // ensureNamespaceCreated creates a k8s namespaces. If it already exists it does nothing. func ensureNamespaceCreated(ctx context.Context, clientset *kubernetes.Clientset, namespace string) error { nsName := &corev1.Namespace{ diff --git a/internal/cli/printer/status.go b/internal/cli/printer/status.go index e240982ed..95da82856 100644 --- a/internal/cli/printer/status.go +++ b/internal/cli/printer/status.go @@ -8,6 +8,8 @@ import ( "github.com/fatih/color" "github.com/morikuni/aec" + "github.com/muesli/reflow/indent" + "go.szostok.io/version/style" "k8s.io/apimachinery/pkg/util/duration" "github.com/kubeshop/botkube/internal/cli" @@ -132,3 +134,24 @@ func (s *StatusPrinter) InfoWithBody(header, body string) { fmt.Fprint(s.w, aec.Column(0)) fmt.Fprintf(s.w, " • %s\n%s", header, body) } + +var allFieldsGoTpl = `{{ AdjustKeyWidth . }} + {{- range $item := (. | Extra) }} + {{ $item.Key | Key }} {{ $item.Value | Val }} + {{- end}} + +` + +// InfoStructFields prints a given struct with key-value layout. +func (s *StatusPrinter) InfoStructFields(header string, data any) error { + renderer := style.NewGoTemplateRender(style.DefaultConfig(allFieldsGoTpl)) + + out, err := renderer.Render(data, cli.IsSmartTerminal(s.Writer())) + if err != nil { + return err + } + + s.InfoWithBody(header, indent.String(out, 4)) + + return nil +} diff --git a/internal/cli/uninstall/config.go b/internal/cli/uninstall/config.go new file mode 100644 index 000000000..09a702475 --- /dev/null +++ b/internal/cli/uninstall/config.go @@ -0,0 +1,8 @@ +package uninstall + +import "github.com/kubeshop/botkube/internal/cli/uninstall/helm" + +// Config holds parameters for Botkube deletion. +type Config struct { + HelmParams helm.Config +} diff --git a/internal/cli/uninstall/helm/config.go b/internal/cli/uninstall/helm/config.go new file mode 100644 index 000000000..dd3fb38f8 --- /dev/null +++ b/internal/cli/uninstall/helm/config.go @@ -0,0 +1,19 @@ +package helm + +import ( + "time" +) + +// Config holds Helm configuration parameters. +type Config struct { + ReleaseName string + ReleaseNamespace string + + DisableHooks bool + DryRun bool + KeepHistory bool + Wait bool + DeletionPropagation string + Timeout time.Duration + Description string +} diff --git a/internal/cli/uninstall/helm/uninstall.go b/internal/cli/uninstall/helm/uninstall.go new file mode 100644 index 000000000..f2cab1673 --- /dev/null +++ b/internal/cli/uninstall/helm/uninstall.go @@ -0,0 +1,56 @@ +package helm + +import ( + "context" + "time" + + "github.com/avast/retry-go/v4" + "helm.sh/helm/v3/pkg/action" + "k8s.io/client-go/rest" + + "github.com/kubeshop/botkube/internal/cli/helmx" + "github.com/kubeshop/botkube/internal/cli/printer" +) + +// Helm provides option to or delete Helm release. +type Helm struct { + helmCfg *action.Configuration +} + +// NewHelm returns a new Helm instance. +func NewHelm(k8sCfg *rest.Config, forNamespace string) (*Helm, error) { + configuration, err := helmx.GetActionConfiguration(k8sCfg, forNamespace) + if err != nil { + return nil, err + } + return &Helm{helmCfg: configuration}, nil +} + +// Uninstall uninstalls a given Helm release. +func (c *Helm) Uninstall(ctx context.Context, status *printer.StatusPrinter, opts Config) error { + status.Step("Uninstalling...") + uninstall := c.uninstallAction(opts) + // We may run into in issue temporary network issues. + return retry.Do(func() error { + if ctx.Err() != nil { + return ctx.Err() // context cancelled or timed out. + } + + _, err := uninstall.Run(opts.ReleaseName) + return err + }, retry.Attempts(3), retry.Delay(time.Second)) +} + +func (c *Helm) uninstallAction(opts Config) *action.Uninstall { + deleteAction := action.NewUninstall(c.helmCfg) + + deleteAction.DisableHooks = opts.DisableHooks + deleteAction.DryRun = opts.DryRun + deleteAction.KeepHistory = opts.KeepHistory + deleteAction.Wait = opts.Wait + deleteAction.DeletionPropagation = opts.DeletionPropagation + deleteAction.Timeout = opts.Timeout + deleteAction.Description = opts.Description + + return deleteAction +} diff --git a/internal/cli/uninstall/uninstall.go b/internal/cli/uninstall/uninstall.go new file mode 100644 index 000000000..6a6c828e1 --- /dev/null +++ b/internal/cli/uninstall/uninstall.go @@ -0,0 +1,65 @@ +package uninstall + +import ( + "context" + "fmt" + "io" + "os" + + "github.com/AlecAivazis/survey/v2" + "github.com/morikuni/aec" + + "github.com/kubeshop/botkube/internal/cli/install/iox" + "github.com/kubeshop/botkube/internal/cli/printer" + "github.com/kubeshop/botkube/internal/cli/uninstall/helm" + "github.com/kubeshop/botkube/internal/kubex" +) + +// Uninstall deletes Botkube Helm release. +func Uninstall(ctx context.Context, w io.Writer, k8sCfg *kubex.ConfigWithMeta, opts Config) (err error) { + status := printer.NewStatus(w, fmt.Sprintf("Uninstalling %s Helm release...", opts.HelmParams.ReleaseName)) + defer func() { + status.End(err == nil) + fmt.Println(aec.Show) + }() + + err = status.InfoStructFields("Release details:", uninstallationDetails{ + RelName: opts.HelmParams.ReleaseName, + Namespace: opts.HelmParams.ReleaseNamespace, + K8sCtx: k8sCfg.CurrentContext, + }) + if err != nil { + return err + } + + var confirm bool + prompt := &survey.Confirm{ + Message: "Do you want to delete existing installation?", + Default: false, + } + + questionIndent := iox.NewIndentStdoutWriter("?", 1) // we indent questions by 1 space to match the step layout + err = survey.AskOne(prompt, &confirm, survey.WithStdio(os.Stdin, questionIndent, os.Stderr)) + if err != nil { + return fmt.Errorf("while confiriming confirm: %v", err) + } + + if !confirm { + status.Infof("Botkube installation not deleted") + return nil + } + + uninstaller, err := helm.NewHelm(k8sCfg.K8s, opts.HelmParams.ReleaseNamespace) + if err != nil { + return err + } + + return uninstaller.Uninstall(ctx, status, opts.HelmParams) +} + +type uninstallationDetails struct { + // Fields are printed in the same order as defined in struct. + RelName string `pretty:"Release Name"` + Namespace string `pretty:"Namespace"` + K8sCtx string `pretty:"Kubernetes Context"` +}