From 9a974c1a9c9fe0594460352b25ef05fd4ba13734 Mon Sep 17 00:00:00 2001 From: Nitya Dhanushkodi Date: Thu, 23 Sep 2021 10:55:04 -0700 Subject: [PATCH] cli: uninstall command (#725) Uninstalls Consul on Kubernetes with options to delete all Consul installation related resources. Also removes a hack using HELM_NAMESPACE environment variable to set the Helm Go SDK Kube Client namespace and replaces it with common.InitActionConfig, which also uses a slight hack. That hack can be removed when https://github.com/helm/helm/pull/10148 is merged. --- cli/README.md | 46 ++- cli/cmd/common/utils.go | 39 +++ cli/cmd/install/install.go | 184 +++++----- cli/cmd/install/install_test.go | 10 +- cli/cmd/uninstall/uninstall.go | 507 ++++++++++++++++++++++++++++ cli/cmd/uninstall/uninstall_test.go | 197 +++++++++++ cli/commands.go | 6 + cli/go.mod | 2 + cli/go.sum | 1 + 9 files changed, 889 insertions(+), 103 deletions(-) create mode 100644 cli/cmd/common/utils.go create mode 100644 cli/cmd/uninstall/uninstall.go create mode 100644 cli/cmd/uninstall/uninstall_test.go diff --git a/cli/README.md b/cli/README.md index 14f7c50a47..1820b1cdd3 100644 --- a/cli/README.md +++ b/cli/README.md @@ -4,10 +4,11 @@ This repository contains a CLI tool for installing and operating [Consul](https: ## Installation & Setup Currently the tool is not available on any releases page. Instead clone the repository and run `go build -o bin/consul-k8s` -and proceed to run the binary. +from this directory and proceed to run the binary. ## Commands * [consul-k8s install](#consul-k8s-install) +* [consul-k8s uninstall](#consul-k8s-uninstall) ### consul-k8s install This command installs Consul on a Kubernetes cluster. It allows `demo` and `secure` installations via preset configurations @@ -81,3 +82,46 @@ Global Options: Path to kubeconfig file. This is aliased as "-c". ``` + +### consul-k8s uninstall +This command uninstalls Consul on Kubernetes, while prompting whether to uninstall the release and whether to delete all +related resources such as PVCs, Secrets, and ServiceAccounts. + +Get started with: +```bash +consul-k8s uninstall +``` + +``` +Usage: consul-k8s uninstall [flags] +Uninstall Consul with options to delete data and resources associated with Consul installation. + +Command Options: + + -auto-approve + Skip approval prompt for uninstalling Consul. The default is false. + + -name= + Name of the installation. This can be used to uninstall and/or delete + the resources of a specific Helm release. + + -namespace= + Namespace for the Consul installation. + + -timeout= + Timeout to wait for uninstall. The default is 10m. + + -wipe-data + When used in combination with -auto-approve, all persisted data (PVCs + and Secrets) from previous installations will be deleted. Only set this + to true when data from previous installations is no longer necessary. + The default is false. + +Global Options: + + -context= + Kubernetes context to use. + + -kubeconfig= + Path to kubeconfig file. This is aliased as "-c". +``` \ No newline at end of file diff --git a/cli/cmd/common/utils.go b/cli/cmd/common/utils.go new file mode 100644 index 0000000000..5c044a0e03 --- /dev/null +++ b/cli/cmd/common/utils.go @@ -0,0 +1,39 @@ +package common + +import ( + "fmt" + "os" + "strings" + + "helm.sh/helm/v3/pkg/action" + helmCLI "helm.sh/helm/v3/pkg/cli" + "k8s.io/cli-runtime/pkg/genericclioptions" +) + +const ( + DefaultReleaseName = "consul" + DefaultReleaseNamespace = "consul" +) + +// Abort returns true if the raw input string is not equal to "y" or "yes". +func Abort(raw string) bool { + confirmation := strings.TrimSuffix(raw, "\n") + if !(strings.ToLower(confirmation) == "y" || strings.ToLower(confirmation) == "yes") { + return true + } + return false +} + +// InitActionConfig initializes a Helm Go SDK action configuration. This function currently uses a hack to override the +// namespace field that gets set in the K8s client set up by the SDK. +func InitActionConfig(actionConfig *action.Configuration, namespace string, settings *helmCLI.EnvSettings, logger action.DebugLog) (*action.Configuration, error) { + getter := settings.RESTClientGetter() + configFlags := getter.(*genericclioptions.ConfigFlags) + configFlags.Namespace = &namespace + err := actionConfig.Init(settings.RESTClientGetter(), namespace, + os.Getenv("HELM_DRIVER"), logger) + if err != nil { + return nil, fmt.Errorf("error setting up helm action configuration to find existing installations: %s", err) + } + return actionConfig, nil +} diff --git a/cli/cmd/install/install.go b/cli/cmd/install/install.go index 4317dad452..9e1d406ed8 100644 --- a/cli/cmd/install/install.go +++ b/cli/cmd/install/install.go @@ -27,8 +27,6 @@ const ( flagNamePreset = "preset" defaultPreset = "" - defaultReleaseName = "consul" - flagNameConfigFile = "config-file" flagNameSetStringValues = "set-string" flagNameSetValues = "set" @@ -41,7 +39,6 @@ const ( defaultAutoApprove = false flagNameNamespace = "namespace" - defaultNamespace = "consul" flagNameTimeout = "timeout" defaultTimeout = "10m" @@ -86,82 +83,80 @@ func (c *Command) init() { } c.set = flag.NewSets() - { - f := c.set.NewSet("Command Options") - f.BoolVar(&flag.BoolVar{ - Name: flagNameAutoApprove, - Target: &c.flagAutoApprove, - Default: defaultAutoApprove, - Usage: "Skip confirmation prompt.", - }) - f.BoolVar(&flag.BoolVar{ - Name: flagNameDryRun, - Target: &c.flagDryRun, - Default: defaultDryRun, - Usage: "Run pre-install checks and display summary of installation.", - }) - f.StringSliceVar(&flag.StringSliceVar{ - Name: flagNameConfigFile, - Aliases: []string{"f"}, - Target: &c.flagValueFiles, - Usage: "Path to a file to customize the installation, such as Consul Helm chart values file. Can be specified multiple times.", - }) - f.StringVar(&flag.StringVar{ - Name: flagNameNamespace, - Target: &c.flagNamespace, - Default: defaultNamespace, - Usage: "Namespace for the Consul installation.", - }) - f.StringVar(&flag.StringVar{ - Name: flagNamePreset, - Target: &c.flagPreset, - Default: defaultPreset, - Usage: fmt.Sprintf("Use an installation preset, one of %s. Defaults to none", strings.Join(presetList, ", ")), - }) - f.StringSliceVar(&flag.StringSliceVar{ - Name: flagNameSetValues, - Target: &c.flagSetValues, - Usage: "Set a value to customize. Can be specified multiple times. Supports Consul Helm chart values.", - }) - f.StringSliceVar(&flag.StringSliceVar{ - Name: flagNameFileValues, - Target: &c.flagFileValues, - Usage: "Set a value to customize via a file. The contents of the file will be set as the value. Can be " + - "specified multiple times. Supports Consul Helm chart values.", - }) - f.StringSliceVar(&flag.StringSliceVar{ - Name: flagNameSetStringValues, - Target: &c.flagSetStringValues, - Usage: "Set a string value to customize. Can be specified multiple times. Supports Consul Helm chart values.", - }) - f.StringVar(&flag.StringVar{ - Name: flagNameTimeout, - Target: &c.flagTimeout, - Default: defaultTimeout, - Usage: "Timeout to wait for installation to be ready.", - }) - f.BoolVar(&flag.BoolVar{ - Name: flagNameWait, - Target: &c.flagWait, - Default: defaultWait, - Usage: "Determines whether to wait for resources in installation to be ready before exiting command.", - }) - - f = c.set.NewSet("Global Options") - f.StringVar(&flag.StringVar{ - Name: "kubeconfig", - Aliases: []string{"c"}, - Target: &c.flagKubeConfig, - Default: "", - Usage: "Path to kubeconfig file.", - }) - f.StringVar(&flag.StringVar{ - Name: "context", - Target: &c.flagKubeContext, - Default: "", - Usage: "Kubernetes context to use.", - }) - } + f := c.set.NewSet("Command Options") + f.BoolVar(&flag.BoolVar{ + Name: flagNameAutoApprove, + Target: &c.flagAutoApprove, + Default: defaultAutoApprove, + Usage: "Skip confirmation prompt.", + }) + f.BoolVar(&flag.BoolVar{ + Name: flagNameDryRun, + Target: &c.flagDryRun, + Default: defaultDryRun, + Usage: "Run pre-install checks and display summary of installation.", + }) + f.StringSliceVar(&flag.StringSliceVar{ + Name: flagNameConfigFile, + Aliases: []string{"f"}, + Target: &c.flagValueFiles, + Usage: "Path to a file to customize the installation, such as Consul Helm chart values file. Can be specified multiple times.", + }) + f.StringVar(&flag.StringVar{ + Name: flagNameNamespace, + Target: &c.flagNamespace, + Default: common.DefaultReleaseNamespace, + Usage: "Namespace for the Consul installation.", + }) + f.StringVar(&flag.StringVar{ + Name: flagNamePreset, + Target: &c.flagPreset, + Default: defaultPreset, + Usage: fmt.Sprintf("Use an installation preset, one of %s. Defaults to none", strings.Join(presetList, ", ")), + }) + f.StringSliceVar(&flag.StringSliceVar{ + Name: flagNameSetValues, + Target: &c.flagSetValues, + Usage: "Set a value to customize. Can be specified multiple times. Supports Consul Helm chart values.", + }) + f.StringSliceVar(&flag.StringSliceVar{ + Name: flagNameFileValues, + Target: &c.flagFileValues, + Usage: "Set a value to customize via a file. The contents of the file will be set as the value. Can be " + + "specified multiple times. Supports Consul Helm chart values.", + }) + f.StringSliceVar(&flag.StringSliceVar{ + Name: flagNameSetStringValues, + Target: &c.flagSetStringValues, + Usage: "Set a string value to customize. Can be specified multiple times. Supports Consul Helm chart values.", + }) + f.StringVar(&flag.StringVar{ + Name: flagNameTimeout, + Target: &c.flagTimeout, + Default: defaultTimeout, + Usage: "Timeout to wait for installation to be ready.", + }) + f.BoolVar(&flag.BoolVar{ + Name: flagNameWait, + Target: &c.flagWait, + Default: defaultWait, + Usage: "Determines whether to wait for resources in installation to be ready before exiting command.", + }) + + f = c.set.NewSet("Global Options") + f.StringVar(&flag.StringVar{ + Name: "kubeconfig", + Aliases: []string{"c"}, + Target: &c.flagKubeConfig, + Default: "", + Usage: "Path to kubeconfig file.", + }) + f.StringVar(&flag.StringVar{ + Name: "context", + Target: &c.flagKubeContext, + Default: "", + Usage: "Kubernetes context to use.", + }) c.help = c.set.Help() @@ -172,31 +167,27 @@ func (c *Command) init() { func (c *Command) Run(args []string) int { c.once.Do(c.init) + // The logger is initialized in main with the name cli. Here, we reset the name to install so log lines would be prefixed with install. + c.Log.ResetNamed("install") + defer func() { if err := c.Close(); err != nil { - c.UI.Output(err.Error()) + c.Log.Error(err.Error()) + os.Exit(1) } }() - // The logger is initialized in main with the name cli. Here, we reset the name to install so log lines would be prefixed with install. - c.Log.ResetNamed("install") - if err := c.validateFlags(args); err != nil { c.UI.Output(err.Error()) return 1 } - // A hack to set namespace via the HELM_NAMESPACE env var until we merge a PR that will allow us to use the latest - // Helm templates. - prevHelmNSEnv := os.Getenv("HELM_NAMESPACE") - os.Setenv("HELM_NAMESPACE", c.flagNamespace) // helmCLI.New() will create a settings object which is used by the Helm Go SDK calls. + settings := helmCLI.New() + // Any overrides by our kubeconfig and kubecontext flags is done here. The Kube client that // is created will use this command's flags first, then the HELM_KUBECONTEXT environment variable, // then call out to genericclioptions.ConfigFlag - settings := helmCLI.New() - os.Setenv("HELM_NAMESPACE", prevHelmNSEnv) - if c.flagKubeConfig != "" { settings.KubeConfig = c.flagKubeConfig } @@ -260,7 +251,7 @@ func (c *Command) Run(args []string) int { // Print out the installation summary. if !c.flagAutoApprove { c.UI.Output("Consul Installation Summary", terminal.WithHeaderStyle()) - c.UI.Output("Installation name: %s", defaultReleaseName, terminal.WithInfoStyle()) + c.UI.Output("Installation name: %s", common.DefaultReleaseName, terminal.WithInfoStyle()) c.UI.Output("Namespace: %s", c.flagNamespace, terminal.WithInfoStyle()) if len(vals) == 0 { @@ -292,8 +283,7 @@ func (c *Command) Run(args []string) int { c.UI.Output(err.Error(), terminal.WithErrorStyle()) return 1 } - confirmation = strings.TrimSuffix(confirmation, "\n") - if !(strings.ToLower(confirmation) == "y" || strings.ToLower(confirmation) == "yes") { + if common.Abort(confirmation) { c.UI.Output("Install aborted. To learn how to customize your installation, run:\nconsul-k8s install --help", terminal.WithInfoStyle()) return 1 } @@ -303,15 +293,15 @@ func (c *Command) Run(args []string) int { // Setup action configuration for Helm Go SDK function calls. actionConfig := new(action.Configuration) - if err := actionConfig.Init(settings.RESTClientGetter(), c.flagNamespace, - os.Getenv("HELM_DRIVER"), uiLogger); err != nil { - c.UI.Output(err.Error()) + actionConfig, err = common.InitActionConfig(actionConfig, c.flagNamespace, settings, uiLogger) + if err != nil { + c.UI.Output(err.Error(), terminal.WithErrorStyle()) return 1 } // Setup the installation action. install := action.NewInstall(actionConfig) - install.ReleaseName = defaultReleaseName + install.ReleaseName = common.DefaultReleaseName install.Namespace = c.flagNamespace install.CreateNamespace = true install.ChartPathOptions.RepoURL = helmRepository diff --git a/cli/cmd/install/install_test.go b/cli/cmd/install/install_test.go index 0c34d6fe48..b299b84f5f 100644 --- a/cli/cmd/install/install_test.go +++ b/cli/cmd/install/install_test.go @@ -26,8 +26,8 @@ func TestCheckForPreviousPVCs(t *testing.T) { Name: "consul-server-test2", }, } - c.kubernetes.CoreV1().PersistentVolumeClaims("default").Create(context.TODO(), pvc, metav1.CreateOptions{}) - c.kubernetes.CoreV1().PersistentVolumeClaims("default").Create(context.TODO(), pvc2, metav1.CreateOptions{}) + c.kubernetes.CoreV1().PersistentVolumeClaims("default").Create(context.Background(), pvc, metav1.CreateOptions{}) + c.kubernetes.CoreV1().PersistentVolumeClaims("default").Create(context.Background(), pvc2, metav1.CreateOptions{}) err := c.checkForPreviousPVCs() require.Error(t, err) require.Contains(t, err.Error(), "found PVCs from previous installations (default/consul-server-test1,default/consul-server-test2), delete before re-installing") @@ -43,7 +43,7 @@ func TestCheckForPreviousPVCs(t *testing.T) { Name: "irrelevant-pvc", }, } - c.kubernetes.CoreV1().PersistentVolumeClaims("default").Create(context.TODO(), pvc, metav1.CreateOptions{}) + c.kubernetes.CoreV1().PersistentVolumeClaims("default").Create(context.Background(), pvc, metav1.CreateOptions{}) err = c.checkForPreviousPVCs() require.NoError(t, err) } @@ -56,7 +56,7 @@ func TestCheckForPreviousSecrets(t *testing.T) { Name: "test-consul-bootstrap-acl-token", }, } - c.kubernetes.CoreV1().Secrets("default").Create(context.TODO(), secret, metav1.CreateOptions{}) + c.kubernetes.CoreV1().Secrets("default").Create(context.Background(), secret, metav1.CreateOptions{}) err := c.checkForPreviousSecrets() require.Error(t, err) require.Contains(t, err.Error(), "found consul-acl-bootstrap-token secret from previous installations: \"test-consul-bootstrap-acl-token\" in namespace \"default\". To delete, run kubectl delete secret test-consul-bootstrap-acl-token --namespace default") @@ -72,7 +72,7 @@ func TestCheckForPreviousSecrets(t *testing.T) { Name: "irrelevant-secret", }, } - c.kubernetes.CoreV1().Secrets("default").Create(context.TODO(), secret, metav1.CreateOptions{}) + c.kubernetes.CoreV1().Secrets("default").Create(context.Background(), secret, metav1.CreateOptions{}) err = c.checkForPreviousSecrets() require.NoError(t, err) } diff --git a/cli/cmd/uninstall/uninstall.go b/cli/cmd/uninstall/uninstall.go new file mode 100644 index 0000000000..2e1d870c00 --- /dev/null +++ b/cli/cmd/uninstall/uninstall.go @@ -0,0 +1,507 @@ +package uninstall + +import ( + "fmt" + "os" + "strings" + "sync" + "time" + + "github.com/cenkalti/backoff" + "github.com/hashicorp/consul-k8s/cli/cmd/common" + "github.com/hashicorp/consul-k8s/cli/cmd/common/flag" + "github.com/hashicorp/consul-k8s/cli/cmd/common/terminal" + "helm.sh/helm/v3/pkg/action" + helmCLI "helm.sh/helm/v3/pkg/cli" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +const ( + flagAutoApprove = "auto-approve" + defaultAutoApprove = false + + flagNamespace = "namespace" + defaultAllNamespaces = "" + + flagReleaseName = "name" + defaultAnyReleaseName = "" + + flagWipeData = "wipe-data" + defaultWipeData = false + + flagTimeout = "timeout" + defaultTimeout = "10m" +) + +type Command struct { + *common.BaseCommand + + kubernetes kubernetes.Interface + + set *flag.Sets + + flagNamespace string + flagReleaseName string + flagAutoApprove bool + flagWipeData bool + flagTimeout string + timeoutDuration time.Duration + + flagKubeConfig string + flagKubeContext string + + once sync.Once + help string +} + +func (c *Command) init() { + c.set = flag.NewSets() + f := c.set.NewSet("Command Options") + f.BoolVar(&flag.BoolVar{ + Name: flagAutoApprove, + Target: &c.flagAutoApprove, + Default: defaultAutoApprove, + Usage: "Skip approval prompt for uninstalling Consul.", + }) + f.BoolVar(&flag.BoolVar{ + Name: flagWipeData, + Target: &c.flagWipeData, + Default: defaultWipeData, + Usage: "When used in combination with -auto-approve, all persisted data (PVCs and Secrets) from previous installations will be deleted. Only set this to true when data from previous installations is no longer necessary.", + }) + f.StringVar(&flag.StringVar{ + Name: flagNamespace, + Target: &c.flagNamespace, + Default: defaultAllNamespaces, + Usage: "Namespace for the Consul installation.", + }) + f.StringVar(&flag.StringVar{ + Name: flagReleaseName, + Target: &c.flagReleaseName, + Default: defaultAnyReleaseName, + Usage: "Name of the installation. This can be used to uninstall and/or delete the resources of a specific Helm release.", + }) + f.StringVar(&flag.StringVar{ + Name: flagTimeout, + Target: &c.flagTimeout, + Default: defaultTimeout, + Usage: "Timeout to wait for uninstall.", + }) + + f = c.set.NewSet("Global Options") + f.StringVar(&flag.StringVar{ + Name: "kubeconfig", + Aliases: []string{"c"}, + Target: &c.flagKubeConfig, + Default: "", + Usage: "Path to kubeconfig file.", + }) + f.StringVar(&flag.StringVar{ + Name: "context", + Target: &c.flagKubeContext, + Default: "", + Usage: "Kubernetes context to use.", + }) + + c.help = c.set.Help() + + // c.Init() calls the embedded BaseCommand's initialization function. + c.Init() +} + +func (c *Command) Run(args []string) int { + c.once.Do(c.init) + + // The logger is initialized in main with the name cli. Here, we reset the name to uninstall so log lines would be prefixed with uninstall. + c.Log.ResetNamed("uninstall") + + defer func() { + if err := c.Close(); err != nil { + c.Log.Error(err.Error()) + os.Exit(1) + } + }() + + if err := c.set.Parse(args); err != nil { + c.UI.Output(err.Error(), terminal.WithErrorStyle()) + return 1 + } + if len(c.set.Args()) > 0 { + c.UI.Output("Should have no non-flag arguments.", terminal.WithErrorStyle()) + return 1 + } + if c.flagWipeData && !c.flagAutoApprove { + c.UI.Output("Can't set -wipe-data alone. Omit this flag to interactively uninstall, or use it with -auto-approve to wipe all data during the uninstall.", terminal.WithErrorStyle()) + return 1 + } + duration, err := time.ParseDuration(c.flagTimeout) + if err != nil { + c.UI.Output("unable to parse -%s: %s", flagTimeout, err, terminal.WithErrorStyle()) + return 1 + } + c.timeoutDuration = duration + + // helmCLI.New() will create a settings object which is used by the Helm Go SDK calls. + settings := helmCLI.New() + if c.flagKubeConfig != "" { + settings.KubeConfig = c.flagKubeConfig + } + if c.flagKubeContext != "" { + settings.KubeContext = c.flagKubeContext + } + + // Set up the kubernetes client to use for non Helm SDK calls to the Kubernetes API + // The Helm SDK will use settings.RESTClientGetter for its calls as well, so this will + // use a consistent method to target the right cluster for both Helm SDK and non Helm SDK calls. + if c.kubernetes == nil { + restConfig, err := settings.RESTClientGetter().ToRESTConfig() + if err != nil { + c.UI.Output("retrieving Kubernetes auth: %v", err, terminal.WithErrorStyle()) + return 1 + } + c.kubernetes, err = kubernetes.NewForConfig(restConfig) + if err != nil { + c.UI.Output("initializing Kubernetes client: %v", err, terminal.WithErrorStyle()) + return 1 + } + } + + // Setup logger to stream Helm library logs. + var uiLogger = func(s string, args ...interface{}) { + logMsg := fmt.Sprintf(s, args...) + c.UI.Output(logMsg, terminal.WithLibraryStyle()) + } + + c.UI.Output("Existing Installation", terminal.WithHeaderStyle()) + + // Search for Consul installation by calling `helm list`. Depends on what's already specified. + actionConfig := new(action.Configuration) + actionConfig, err = common.InitActionConfig(actionConfig, c.flagNamespace, settings, uiLogger) + if err != nil { + c.UI.Output(err.Error(), terminal.WithErrorStyle()) + return 1 + } + + found, foundReleaseName, foundReleaseNamespace, err := c.findExistingInstallation(actionConfig) + if err != nil { + c.UI.Output(err.Error(), terminal.WithErrorStyle()) + return 1 + } + if !found { + c.UI.Output("No existing Consul installations.", terminal.WithSuccessStyle()) + } else { + c.UI.Output("Existing Consul installation found.", terminal.WithSuccessStyle()) + c.UI.Output("Consul Uninstall Summary", terminal.WithHeaderStyle()) + c.UI.Output("Name: %s", foundReleaseName, terminal.WithInfoStyle()) + c.UI.Output("Namespace: %s", foundReleaseNamespace, terminal.WithInfoStyle()) + + // Prompt for approval to uninstall Helm release. + if !c.flagAutoApprove { + confirmation, err := c.UI.Input(&terminal.Input{ + Prompt: "Proceed with uninstall? (y/N)", + Style: terminal.InfoStyle, + Secret: false, + }) + if err != nil { + c.UI.Output(err.Error(), terminal.WithErrorStyle()) + return 1 + } + if common.Abort(confirmation) { + c.UI.Output("Uninstall aborted. To learn how to customize the uninstall, run:\nconsul-k8s uninstall --help", terminal.WithInfoStyle()) + return 1 + } + } + + // Actually call out to `helm delete`. + actionConfig, err = common.InitActionConfig(actionConfig, foundReleaseNamespace, settings, uiLogger) + if err != nil { + c.UI.Output(err.Error(), terminal.WithErrorStyle()) + return 1 + } + + uninstaller := action.NewUninstall(actionConfig) + uninstaller.Timeout = c.timeoutDuration + res, err := uninstaller.Run(foundReleaseName) + if err != nil { + c.UI.Output("unable to uninstall: %s", err, terminal.WithErrorStyle()) + return 1 + } + if res != nil && res.Info != "" { + c.UI.Output("Uninstall result: %s", res.Info, terminal.WithInfoStyle()) + } + c.UI.Output("Successfully uninstalled Consul Helm release", terminal.WithSuccessStyle()) + } + + // If -auto-approve=true and -wipe-data=false, we should only uninstall the release, and skip deleting resources. + if c.flagAutoApprove && !c.flagWipeData { + c.UI.Output("Skipping deleting PVCs, secrets, and service accounts.", terminal.WithSuccessStyle()) + return 0 + } + + // At this point, even if no Helm release was found and uninstalled, there could + // still be PVCs, Secrets, and Service Accounts left behind from a previous installation. + // If there isn't a foundReleaseName and foundReleaseNamespace, we'll use the values of the + // flags c.flagReleaseName and c.flagNamespace. If those are empty we'll fall back to defaults "consul" for the + // installation name and "consul" for the namespace. + if !found { + if c.flagReleaseName == "" || c.flagNamespace == "" { + foundReleaseName = common.DefaultReleaseName + foundReleaseNamespace = common.DefaultReleaseNamespace + } else { + foundReleaseName = c.flagReleaseName + foundReleaseNamespace = c.flagNamespace + } + } + + c.UI.Output("Other Consul Resources", terminal.WithHeaderStyle()) + if c.flagAutoApprove { + c.UI.Output("Deleting data for installation: ", terminal.WithInfoStyle()) + c.UI.Output("Name: %s", foundReleaseName, terminal.WithInfoStyle()) + c.UI.Output("Namespace %s", foundReleaseNamespace, terminal.WithInfoStyle()) + } + // Prompt with a warning for approval before deleting PVCs, Secrets, Service Accounts, Roles, and Role Bindings. + if !c.flagAutoApprove { + confirmation, err := c.UI.Input(&terminal.Input{ + Prompt: fmt.Sprintf("WARNING: Proceed with deleting PVCs, Secrets, Service Accounts, Roles, and Role Bindings for the following installation? \n\n Name: %s \n Namespace: %s \n\n Only approve if all data from this installation can be deleted. (y/N)", foundReleaseName, foundReleaseNamespace), + Style: terminal.WarningStyle, + Secret: false, + }) + if err != nil { + c.UI.Output(err.Error(), terminal.WithErrorStyle()) + return 1 + } + if common.Abort(confirmation) { + c.UI.Output("Uninstall aborted without deleting PVCs and Secrets.", terminal.WithInfoStyle()) + return 1 + } + } + + if err := c.deletePVCs(foundReleaseName, foundReleaseNamespace); err != nil { + c.UI.Output(err.Error(), terminal.WithErrorStyle()) + return 1 + } + + if err := c.deleteSecrets(foundReleaseName, foundReleaseNamespace); err != nil { + c.UI.Output(err.Error(), terminal.WithErrorStyle()) + return 1 + } + + if err := c.deleteServiceAccounts(foundReleaseName, foundReleaseNamespace); err != nil { + c.UI.Output(err.Error(), terminal.WithErrorStyle()) + return 1 + } + + if err := c.deleteRoles(foundReleaseName, foundReleaseNamespace); err != nil { + c.UI.Output(err.Error(), terminal.WithErrorStyle()) + return 1 + } + + if err := c.deleteRoleBindings(foundReleaseName, foundReleaseNamespace); err != nil { + c.UI.Output(err.Error(), terminal.WithErrorStyle()) + return 1 + } + + return 0 +} + +func (c *Command) Help() string { + c.once.Do(c.init) + s := "Usage: consul-k8s uninstall [flags]" + "\n" + "Uninstall Consul with options to delete data and resources associated with Consul installation." + "\n\n" + c.help + return s +} + +func (c *Command) Synopsis() string { + return "Uninstall Consul deployment." +} + +func (c *Command) findExistingInstallation(actionConfig *action.Configuration) (bool, string, string, error) { + lister := action.NewList(actionConfig) + // lister.All will search for helm installations in all states, such as deployed, pending, uninstalling, etc. + lister.All = true + if c.flagNamespace == defaultAllNamespaces { + lister.AllNamespaces = true + } + res, err := lister.Run() + if err != nil { + return false, "", "", fmt.Errorf("error finding existing installations: %s", err) + } + + found := false + foundReleaseName := "" + foundReleaseNamespace := "" + for _, rel := range res { + if rel.Chart.Metadata.Name == "consul" { + if c.flagNamespace != defaultAllNamespaces && c.flagNamespace == rel.Namespace { + // If we found a chart named "consul" and -namespace was specified, we only found the release if the + // release namespace matches the -namespace flag. + found = true + foundReleaseName = rel.Name + foundReleaseNamespace = rel.Namespace + break + } + if c.flagNamespace == defaultAllNamespaces { + found = true + foundReleaseName = rel.Name + foundReleaseNamespace = rel.Namespace + break + } + } + } + + return found, foundReleaseName, foundReleaseNamespace, nil +} + +// deletePVCs deletes any pvcs that have the label release={{foundReleaseName}} and waits for them to be deleted. +func (c *Command) deletePVCs(foundReleaseName, foundReleaseNamespace string) error { + var pvcNames []string + pvcSelector := metav1.ListOptions{LabelSelector: fmt.Sprintf("release=%s", foundReleaseName)} + pvcs, err := c.kubernetes.CoreV1().PersistentVolumeClaims(foundReleaseNamespace).List(c.Ctx, pvcSelector) + if err != nil { + return fmt.Errorf("deletePVCs: %s", err) + } + if len(pvcs.Items) == 0 { + c.UI.Output("No PVCs found.", terminal.WithSuccessStyle()) + return nil + } + for _, pvc := range pvcs.Items { + err := c.kubernetes.CoreV1().PersistentVolumeClaims(foundReleaseNamespace).Delete(c.Ctx, pvc.Name, metav1.DeleteOptions{}) + if err != nil { + return fmt.Errorf("deletePVCs: error deleting PVC %q: %s", pvc.Name, err) + } + pvcNames = append(pvcNames, pvc.Name) + } + err = backoff.Retry(func() error { + pvcs, err := c.kubernetes.CoreV1().PersistentVolumeClaims(foundReleaseNamespace).List(c.Ctx, pvcSelector) + if err != nil { + return fmt.Errorf("deletePVCs: %s", err) + } + if len(pvcs.Items) > 0 { + return fmt.Errorf("deletePVCs: pvcs still exist") + } + return nil + }, backoff.WithMaxRetries(backoff.NewConstantBackOff(100*time.Millisecond), 1800)) + if err != nil { + return fmt.Errorf("deletePVCs: timed out waiting for PVCs to be deleted") + } + if len(pvcNames) > 0 { + for _, pvc := range pvcNames { + c.UI.Output("Deleted PVC => %s", pvc, terminal.WithSuccessStyle()) + } + c.UI.Output("PVCs deleted.", terminal.WithSuccessStyle()) + } + return nil +} + +// deleteSecrets deletes any secrets that have foundReleaseName in their name. +func (c *Command) deleteSecrets(foundReleaseName, foundReleaseNamespace string) error { + var secretNames []string + secrets, err := c.kubernetes.CoreV1().Secrets(foundReleaseNamespace).List(c.Ctx, metav1.ListOptions{}) + if err != nil { + return fmt.Errorf("deleteSecrets: %s", err) + } + if len(secrets.Items) == 0 { + c.UI.Output("No Consul secrets found.", terminal.WithSuccessStyle()) + return nil + } + for _, secret := range secrets.Items { + if strings.HasPrefix(secret.Name, foundReleaseName) { + err := c.kubernetes.CoreV1().Secrets(foundReleaseNamespace).Delete(c.Ctx, secret.Name, metav1.DeleteOptions{}) + if err != nil { + return fmt.Errorf("deleteSecrets: error deleting Secret %q: %s", secret.Name, err) + } + secretNames = append(secretNames, secret.Name) + } + } + if len(secretNames) > 0 { + for _, secret := range secretNames { + c.UI.Output("Deleted Secret => %s", secret, terminal.WithSuccessStyle()) + } + c.UI.Output("Consul secrets deleted.", terminal.WithSuccessStyle()) + } + return nil +} + +// deleteServiceAccounts deletes service accounts that have the label release={{foundReleaseName}}. +func (c *Command) deleteServiceAccounts(foundReleaseName, foundReleaseNamespace string) error { + var serviceAccountNames []string + saSelector := metav1.ListOptions{LabelSelector: fmt.Sprintf("release=%s", foundReleaseName)} + sas, err := c.kubernetes.CoreV1().ServiceAccounts(foundReleaseNamespace).List(c.Ctx, saSelector) + if err != nil { + return fmt.Errorf("deleteServiceAccounts: %s", err) + } + if len(sas.Items) == 0 { + c.UI.Output("No Consul service accounts found.", terminal.WithSuccessStyle()) + return nil + } + for _, sa := range sas.Items { + err := c.kubernetes.CoreV1().ServiceAccounts(foundReleaseNamespace).Delete(c.Ctx, sa.Name, metav1.DeleteOptions{}) + if err != nil { + return fmt.Errorf("deleteServiceAccounts: error deleting ServiceAccount %q: %s", sa.Name, err) + } + serviceAccountNames = append(serviceAccountNames, sa.Name) + } + if len(serviceAccountNames) > 0 { + for _, sa := range serviceAccountNames { + c.UI.Output("Deleted Service Account => %s", sa, terminal.WithSuccessStyle()) + } + c.UI.Output("Consul service accounts deleted.", terminal.WithSuccessStyle()) + } + return nil +} + +// deleteRoles deletes roles that have the label release={{foundReleaseName}}. +func (c *Command) deleteRoles(foundReleaseName, foundReleaseNamespace string) error { + var roleNames []string + roleSelector := metav1.ListOptions{LabelSelector: fmt.Sprintf("release=%s", foundReleaseName)} + roles, err := c.kubernetes.RbacV1().Roles(foundReleaseNamespace).List(c.Ctx, roleSelector) + if err != nil { + return fmt.Errorf("deleteRoles: %s", err) + } + if len(roles.Items) == 0 { + c.UI.Output("No Consul roles found.", terminal.WithSuccessStyle()) + return nil + } + for _, role := range roles.Items { + err := c.kubernetes.RbacV1().Roles(foundReleaseNamespace).Delete(c.Ctx, role.Name, metav1.DeleteOptions{}) + if err != nil { + return fmt.Errorf("deleteRoles: error deleting Role %q: %s", role.Name, err) + } + roleNames = append(roleNames, role.Name) + } + if len(roleNames) > 0 { + for _, role := range roleNames { + c.UI.Output("Deleted Role => %s", role, terminal.WithSuccessStyle()) + } + c.UI.Output("Consul roles deleted.", terminal.WithSuccessStyle()) + } + return nil +} + +// deleteRoleBindings deletes rolebindings that have the label release={{foundReleaseName}}. +func (c *Command) deleteRoleBindings(foundReleaseName, foundReleaseNamespace string) error { + var rolebindingNames []string + rolebindingSelector := metav1.ListOptions{LabelSelector: fmt.Sprintf("release=%s", foundReleaseName)} + rolebindings, err := c.kubernetes.RbacV1().RoleBindings(foundReleaseNamespace).List(c.Ctx, rolebindingSelector) + if err != nil { + return fmt.Errorf("deleteRoleBindings: %s", err) + } + if len(rolebindings.Items) == 0 { + c.UI.Output("No Consul rolebindings found.", terminal.WithSuccessStyle()) + return nil + } + for _, rolebinding := range rolebindings.Items { + err := c.kubernetes.RbacV1().RoleBindings(foundReleaseNamespace).Delete(c.Ctx, rolebinding.Name, metav1.DeleteOptions{}) + if err != nil { + return fmt.Errorf("deleteRoleBindings: error deleting Role %q: %s", rolebinding.Name, err) + } + rolebindingNames = append(rolebindingNames, rolebinding.Name) + } + if len(rolebindingNames) > 0 { + for _, rolebinding := range rolebindingNames { + c.UI.Output("Deleted Role Binding => %s", rolebinding, terminal.WithSuccessStyle()) + } + c.UI.Output("Consul rolebindings deleted.", terminal.WithSuccessStyle()) + } + return nil +} diff --git a/cli/cmd/uninstall/uninstall_test.go b/cli/cmd/uninstall/uninstall_test.go new file mode 100644 index 0000000000..8473cd6e61 --- /dev/null +++ b/cli/cmd/uninstall/uninstall_test.go @@ -0,0 +1,197 @@ +package uninstall + +import ( + "context" + "os" + "testing" + + "github.com/hashicorp/consul-k8s/cli/cmd/common" + "github.com/hashicorp/go-hclog" + "github.com/stretchr/testify/require" + v1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func TestDeletePVCs(t *testing.T) { + c := getInitializedCommand(t) + c.kubernetes = fake.NewSimpleClientset() + pvc := &v1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "consul-server-test1", + Labels: map[string]string{ + "release": "consul", + }, + }, + } + pvc2 := &v1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "consul-server-test2", + Labels: map[string]string{ + "release": "consul", + }, + }, + } + pvc3 := &v1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "unrelated-pvc", + Labels: map[string]string{ + "release": "unrelated", + }, + }, + } + _, err := c.kubernetes.CoreV1().PersistentVolumeClaims("default").Create(context.Background(), pvc, metav1.CreateOptions{}) + require.NoError(t, err) + _, err = c.kubernetes.CoreV1().PersistentVolumeClaims("default").Create(context.Background(), pvc2, metav1.CreateOptions{}) + require.NoError(t, err) + _, err = c.kubernetes.CoreV1().PersistentVolumeClaims("default").Create(context.Background(), pvc3, metav1.CreateOptions{}) + require.NoError(t, err) + err = c.deletePVCs("consul", "default") + require.NoError(t, err) + pvcs, err := c.kubernetes.CoreV1().PersistentVolumeClaims("default").List(context.Background(), metav1.ListOptions{}) + require.NoError(t, err) + require.Len(t, pvcs.Items, 1) +} + +func TestDeleteSecrets(t *testing.T) { + c := getInitializedCommand(t) + c.kubernetes = fake.NewSimpleClientset() + secret := &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "consul-test-secret1", + Labels: map[string]string{ + "release": "consul", + }, + }, + } + secret2 := &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "consul-test-secret2", + Labels: map[string]string{ + "release": "consul", + }, + }, + } + _, err := c.kubernetes.CoreV1().Secrets("default").Create(context.Background(), secret, metav1.CreateOptions{}) + require.NoError(t, err) + _, err = c.kubernetes.CoreV1().Secrets("default").Create(context.Background(), secret2, metav1.CreateOptions{}) + require.NoError(t, err) + err = c.deleteSecrets("consul", "default") + require.NoError(t, err) + secrets, err := c.kubernetes.CoreV1().Secrets("default").List(context.Background(), metav1.ListOptions{}) + require.NoError(t, err) + require.Len(t, secrets.Items, 0) +} + +func TestDeleteServiceAccounts(t *testing.T) { + c := getInitializedCommand(t) + c.kubernetes = fake.NewSimpleClientset() + sa := &v1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "consul-test-sa1", + Labels: map[string]string{ + "release": "consul", + }, + }, + } + sa2 := &v1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "consul-test-sa2", + Labels: map[string]string{ + "release": "consul", + }, + }, + } + _, err := c.kubernetes.CoreV1().ServiceAccounts("default").Create(context.Background(), sa, metav1.CreateOptions{}) + require.NoError(t, err) + _, err = c.kubernetes.CoreV1().ServiceAccounts("default").Create(context.Background(), sa2, metav1.CreateOptions{}) + require.NoError(t, err) + err = c.deleteServiceAccounts("consul", "default") + require.NoError(t, err) + sas, err := c.kubernetes.CoreV1().ServiceAccounts("default").List(context.Background(), metav1.ListOptions{}) + require.NoError(t, err) + require.Len(t, sas.Items, 0) +} + +func TestDeleteRoles(t *testing.T) { + c := getInitializedCommand(t) + c.kubernetes = fake.NewSimpleClientset() + role := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: "consul-test-role1", + Labels: map[string]string{ + "release": "consul", + }, + }, + } + role2 := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: "consul-test-role2", + Labels: map[string]string{ + "release": "consul", + }, + }, + } + _, err := c.kubernetes.RbacV1().Roles("default").Create(context.Background(), role, metav1.CreateOptions{}) + require.NoError(t, err) + _, err = c.kubernetes.RbacV1().Roles("default").Create(context.Background(), role2, metav1.CreateOptions{}) + require.NoError(t, err) + err = c.deleteRoles("consul", "default") + require.NoError(t, err) + roles, err := c.kubernetes.RbacV1().Roles("default").List(context.Background(), metav1.ListOptions{}) + require.NoError(t, err) + require.Len(t, roles.Items, 0) +} + +func TestDeleteRoleBindings(t *testing.T) { + c := getInitializedCommand(t) + c.kubernetes = fake.NewSimpleClientset() + rolebinding := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "consul-test-role1", + Labels: map[string]string{ + "release": "consul", + }, + }, + } + rolebinding2 := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "consul-test-role2", + Labels: map[string]string{ + "release": "consul", + }, + }, + } + _, err := c.kubernetes.RbacV1().RoleBindings("default").Create(context.Background(), rolebinding, metav1.CreateOptions{}) + require.NoError(t, err) + _, err = c.kubernetes.RbacV1().RoleBindings("default").Create(context.Background(), rolebinding2, metav1.CreateOptions{}) + require.NoError(t, err) + err = c.deleteRoleBindings("consul", "default") + require.NoError(t, err) + rolebindings, err := c.kubernetes.RbacV1().RoleBindings("default").List(context.Background(), metav1.ListOptions{}) + require.NoError(t, err) + require.Len(t, rolebindings.Items, 0) +} + +// getInitializedCommand sets up a command struct for tests. +func getInitializedCommand(t *testing.T) *Command { + t.Helper() + log := hclog.New(&hclog.LoggerOptions{ + Name: "cli", + Level: hclog.Info, + Output: os.Stdout, + }) + ctx, _ := context.WithCancel(context.Background()) + + baseCommand := &common.BaseCommand{ + Ctx: ctx, + Log: log, + } + + c := &Command{ + BaseCommand: baseCommand, + } + c.init() + return c +} diff --git a/cli/commands.go b/cli/commands.go index 81640ef88a..2725c5d4bf 100644 --- a/cli/commands.go +++ b/cli/commands.go @@ -5,6 +5,7 @@ import ( "github.com/hashicorp/consul-k8s/cli/cmd/common" "github.com/hashicorp/consul-k8s/cli/cmd/install" + "github.com/hashicorp/consul-k8s/cli/cmd/uninstall" "github.com/hashicorp/go-hclog" "github.com/mitchellh/cli" ) @@ -22,6 +23,11 @@ func initializeCommands(ctx context.Context, log hclog.Logger) (*common.BaseComm BaseCommand: baseCommand, }, nil }, + "uninstall": func() (cli.Command, error) { + return &uninstall.Command{ + BaseCommand: baseCommand, + }, nil + }, } return baseCommand, commands diff --git a/cli/go.mod b/cli/go.mod index 723e092b47..38ed649c60 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -4,6 +4,7 @@ go 1.16 require ( github.com/bgentry/speakeasy v0.1.0 + github.com/cenkalti/backoff v2.2.1+incompatible github.com/fatih/color v1.9.0 github.com/golang/protobuf v1.5.2 // indirect github.com/hashicorp/go-hclog v0.16.2 @@ -21,6 +22,7 @@ require ( helm.sh/helm/v3 v3.6.1 k8s.io/api v0.21.2 k8s.io/apimachinery v0.21.2 + k8s.io/cli-runtime v0.21.0 k8s.io/client-go v0.21.2 rsc.io/letsencrypt v0.0.3 // indirect sigs.k8s.io/yaml v1.2.0 diff --git a/cli/go.sum b/cli/go.sum index 0e79467fc8..7ccb6c8963 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -125,6 +125,7 @@ github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b/go.mod h1:obH5gd0Bsq github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0 h1:nvj0OLI3YqYXer/kZD8Ri1aaunCxIEsOst1BVJswV0o= github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ= +github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=