diff --git a/cli/cmd/install/install.go b/cli/cmd/install/install.go index ab2787f800..20b9f942ad 100644 --- a/cli/cmd/install/install.go +++ b/cli/cmd/install/install.go @@ -1,7 +1,6 @@ package install import ( - "embed" "errors" "fmt" "net/http" @@ -203,6 +202,12 @@ func (c *Command) init() { Default: "", Usage: "Set the Kubernetes context to use.", }) + f.StringVar(&flag.StringVar{ + Name: flagHCPResourceID, + Target: &c.flagHCPResourceID, + Default: "", + Usage: "Set the HCP resource_id when using the 'cloud' preset.", + }) c.help = c.set.Help() } @@ -346,14 +351,6 @@ func (c *Command) Run(args []string) int { release.Configuration = values - // If an enterprise license secret was provided, check that the secret exists and that the enterprise Consul image is set. - if helmVals.Global.EnterpriseLicense.SecretName != "" { - if err := c.checkValidEnterprise(rel.Configuration.Global.EnterpriseLicense.SecretName); err != nil { - c.UI.Output(err.Error(), terminal.WithErrorStyle()) - return 1 - } - c.UI.Output("Valid enterprise Consul secret found.", terminal.WithSuccessStyle()) - } err = c.installConsul(valuesYaml, vals, settings, uiLogger) if err != nil { c.UI.Output(err.Error(), terminal.WithErrorStyle()) @@ -361,7 +358,28 @@ func (c *Command) Run(args []string) int { } if c.flagDemo { - err = c.installDemoApp(settings, uiLogger) + timeout, err := time.ParseDuration(c.flagTimeout) + if err != nil { + c.UI.Output(err.Error(), terminal.WithErrorStyle()) + return 1 + } + options := &helm.InstallOptions{ + ReleaseName: common.ConsulDemoAppReleaseName, + ReleaseType: common.ReleaseTypeConsulDemo, + Namespace: c.flagNamespace, + Values: make(map[string]interface{}), + Settings: settings, + EmbeddedChart: consulChart.DemoHelmChart, + ChartDirName: "demo", + UILogger: uiLogger, + DryRun: c.flagDryRun, + AutoApprove: c.flagAutoApprove, + Wait: c.flagWait, + Timeout: timeout, + UI: c.UI, + HelmActionsRunner: c.helmActionsRunner, + } + err = helm.InstallDemoApp(options) if err != nil { c.UI.Output(err.Error(), terminal.WithErrorStyle()) return 1 @@ -375,6 +393,7 @@ func (c *Command) Run(args []string) int { return 0 } + func (c *Command) installConsul(valuesYaml []byte, vals map[string]interface{}, settings *helmCLI.EnvSettings, uiLogger action.DebugLog) error { // Print out the installation summary. c.UI.Output("Consul Installation Summary", terminal.WithHeaderStyle()) @@ -392,105 +411,32 @@ func (c *Command) installConsul(valuesYaml []byte, vals map[string]interface{}, // aren't double prefixed with "consul-consul-...". vals = common.MergeMaps(config.ConvertToMap(config.GlobalNameConsul), vals) - err := c.installHelmRelease(common.DefaultReleaseName, vals, - common.ReleaseTypeConsul, settings, &consulChart.ConsulHelmChart, - common.TopLevelChartDirName, uiLogger) + timeout, err := time.ParseDuration(c.flagTimeout) if err != nil { return err } - - c.UI.Output("Consul installed in namespace %q.", c.flagNamespace, terminal.WithSuccessStyle()) - return nil -} - -// installDemoApp will perform the following actions -// - Print out the installation summary. -// - Setup action configuration for Helm Go SDK function calls. -// - Setup the installation action. -// - Load the Helm chart. -// - Run the install. -func (c *Command) installDemoApp(settings *helmCLI.EnvSettings, uiLogger action.DebugLog) error { - const consulDemoChartPath = "demo" - c.UI.Output(fmt.Sprintf("%s Installation Summary", - cases.Title(language.English).String(common.ReleaseTypeConsulDemo)), - terminal.WithHeaderStyle()) - c.UI.Output("Name: %s", common.ConsulDemoAppReleaseName, terminal.WithInfoStyle()) - c.UI.Output("Namespace: %s", c.flagNamespace, terminal.WithInfoStyle()) - c.UI.Output("\n", terminal.WithInfoStyle()) - - err := c.installHelmRelease(common.ConsulDemoAppReleaseName, make(map[string]interface{}), common.ReleaseTypeConsulDemo, - settings, &consulChart.DemoHelmChart, consulDemoChartPath, uiLogger) + installOptions := &helm.InstallOptions{ + ReleaseName: common.DefaultReleaseName, + ReleaseType: common.ReleaseTypeConsul, + Namespace: c.flagNamespace, + Values: vals, + Settings: settings, + EmbeddedChart: consulChart.ConsulHelmChart, + ChartDirName: common.TopLevelChartDirName, + UILogger: uiLogger, + DryRun: c.flagDryRun, + AutoApprove: c.flagAutoApprove, + Wait: c.flagWait, + Timeout: timeout, + UI: c.UI, + HelmActionsRunner: c.helmActionsRunner, + } + + err = helm.InstallHelmRelease(installOptions) if err != nil { return err } - c.UI.Output("Accessing %s UI", cases.Title(language.English).String(common.ReleaseTypeConsulDemo), terminal.WithHeaderStyle()) - port := "8080" - portForwardCmd := fmt.Sprintf("kubectl port-forward deploy/frontend %s:80", port) - if c.flagNamespace != "default" { - portForwardCmd += fmt.Sprintf(" --namespace %s", c.flagNamespace) - } - c.UI.Output(portForwardCmd, terminal.WithInfoStyle()) - c.UI.Output("Browse to http://localhost:%s.", port, terminal.WithInfoStyle()) - return nil -} - -func (c *Command) installHelmRelease(releaseName string, vals map[string]interface{}, - releaseType string, settings *helmCLI.EnvSettings, embeddedChart *embed.FS, - chartDirName string, uiLogger action.DebugLog) error { - if c.flagDryRun { - return nil - } - - if !c.flagAutoApprove { - confirmation, err := c.UI.Input(&terminal.Input{ - Prompt: "Proceed with installation? (y/N)", - Style: terminal.InfoStyle, - Secret: false, - }) - - if err != nil { - return err - } - if common.Abort(confirmation) { - c.UI.Output("Install aborted. Use the command `consul-k8s install -help` to learn how to customize your installation.", - terminal.WithInfoStyle()) - return err - } - } - - c.UI.Output("Installing %s", releaseType, terminal.WithHeaderStyle()) - - // Setup action configuration for Helm Go SDK function calls. - actionConfig := new(action.Configuration) - actionConfig, err := helm.InitActionConfig(actionConfig, c.flagNamespace, settings, uiLogger) - if err != nil { - return err - } - - // Setup the installation action. - install := action.NewInstall(actionConfig) - install.ReleaseName = releaseName - install.Namespace = c.flagNamespace - install.CreateNamespace = true - install.Wait = c.flagWait - install.Timeout = c.timeoutDuration - - // Load the Helm chart. - chart, err := helm.LoadChart(*embeddedChart, chartDirName) - if err != nil { - return err - } - c.UI.Output("Downloaded charts", terminal.WithSuccessStyle()) - - // Run the install. - if c.helmActionsRunner == nil { - c.helmActionsRunner = &helm.ActionRunner{} - } - if _, err = c.helmActionsRunner.Install(install, chart, vals); err != nil { - return err - } - c.UI.Output("%s installed in namespace %q.", releaseType, c.flagNamespace, terminal.WithSuccessStyle()) return nil } diff --git a/cli/cmd/install/install_test.go b/cli/cmd/install/install_test.go index ebd27a9a6b..1d07ce84de 100644 --- a/cli/cmd/install/install_test.go +++ b/cli/cmd/install/install_test.go @@ -1,9 +1,12 @@ package install import ( + "bytes" "context" + "errors" "flag" "fmt" + "io" "os" "testing" @@ -17,6 +20,9 @@ import ( "github.com/posener/complete" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/chart" + helmRelease "helm.sh/helm/v3/pkg/release" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" @@ -24,7 +30,7 @@ import ( ) func TestCheckForPreviousPVCs(t *testing.T) { - c := getInitializedCommand(t) + c := getInitializedCommand(t, nil) c.kubernetes = fake.NewSimpleClientset() createPVC(t, "consul-server-test1", "default", c.kubernetes) @@ -136,7 +142,7 @@ func TestCheckForPreviousSecrets(t *testing.T) { for name, tc := range cases { t.Run(name, func(t *testing.T) { - c := getInitializedCommand(t) + c := getInitializedCommand(t, nil) c.kubernetes = fake.NewSimpleClientset() c.kubernetes.CoreV1().Secrets("consul").Create(context.Background(), tc.secret, metav1.CreateOptions{}) @@ -184,7 +190,7 @@ func TestValidateFlags(t *testing.T) { } for _, testCase := range testCases { - c := getInitializedCommand(t) + c := getInitializedCommand(t, nil) t.Run(testCase.description, func(t *testing.T) { if err := c.validateFlags(testCase.input); err == nil { t.Errorf("Test case should have failed.") @@ -194,17 +200,22 @@ func TestValidateFlags(t *testing.T) { } // getInitializedCommand sets up a command struct for tests. -func getInitializedCommand(t *testing.T) *Command { +func getInitializedCommand(t *testing.T, buf io.Writer) *Command { t.Helper() log := hclog.New(&hclog.LoggerOptions{ Name: "cli", Level: hclog.Info, Output: os.Stdout, }) - + var ui terminal.UI + if buf != nil { + ui = terminal.NewUI(context.Background(), buf) + } else { + ui = terminal.NewBasicUI(context.Background()) + } baseCommand := &common.BaseCommand{ Log: log, - UI: terminal.NewBasicUI(context.TODO()), + UI: ui, } c := &Command{ @@ -215,7 +226,7 @@ func getInitializedCommand(t *testing.T) *Command { } func TestCheckValidEnterprise(t *testing.T) { - c := getInitializedCommand(t) + c := getInitializedCommand(t, nil) c.kubernetes = fake.NewSimpleClientset() secret := &v1.Secret{ ObjectMeta: metav1.ObjectMeta{ @@ -353,7 +364,7 @@ func TestValidateCloudPresets(t *testing.T) { for _, testCase := range testCases { testCase.preProcessingFunc() - c := getInitializedCommand(t) + c := getInitializedCommand(t, nil) t.Run(testCase.description, func(t *testing.T) { err := c.validateFlags(testCase.input) if testCase.expectError { @@ -386,7 +397,7 @@ func TestGetPreset(t *testing.T) { } for _, tc := range testCases { - c := getInitializedCommand(t) + c := getInitializedCommand(t, nil) t.Run(tc.description, func(t *testing.T) { p, err := c.getPreset(tc.presetName) require.NoError(t, err) @@ -403,137 +414,288 @@ func TestGetPreset(t *testing.T) { } func TestInstall(t *testing.T) { - c := getInitializedCommand(t) - c.kubernetes = fake.NewSimpleClientset() - c.helmActionsRunner = &helm.MockActionRunner{} - returnCode := c.Run([]string{ - "--auto-approve", - }) - require.Equal(t, 0, returnCode) -} - -func TestInstall_alreadyInstalled(t *testing.T) { - c := getInitializedCommand(t) - c.kubernetes = fake.NewSimpleClientset() - c.helmActionsRunner = &helm.MockActionRunner{ - CheckForInstallationsReponse: func(options *helm.CheckForInstallationsOptions) (bool, string, string, error) { - return true, "consul", "consul", nil + var k8s kubernetes.Interface + licenseSecretName := "consul-license" + cases := map[string]struct { + input []string + messages []string + helmActionsRunner *helm.MockActionRunner + preProcessingFunc func() + expectedReturnCode int + expectCheckedForConsulInstallations bool + expectCheckedForConsulDemoInstallations bool + expectConsulInstalled bool + expectConsulDemoInstalled bool + }{ + "install with no arguments returns success": { + input: []string{}, + messages: []string{ + "\n==> Checking if Consul can be installed\n ✓ No existing Consul installations found.\n ✓ No existing Consul persistent volume claims found\n ✓ No existing Consul secrets found.\n", + "\n==> Consul Installation Summary\n Name: consul\n Namespace: consul\n \n No overrides provided, using the default Helm values.\n", + "\n==> Installing Consul\n ✓ Downloaded charts.\n ✓ Consul installed in namespace \"consul\".\n", + }, + helmActionsRunner: &helm.MockActionRunner{}, + expectedReturnCode: 0, + expectCheckedForConsulInstallations: true, + expectCheckedForConsulDemoInstallations: false, + expectConsulInstalled: true, + expectConsulDemoInstalled: false, }, - } - returnCode := c.Run([]string{ - "--auto-approve", - }) - require.Equal(t, 1, returnCode) -} - -func TestInstall_existingPVCs(t *testing.T) { - c := getInitializedCommand(t) - c.kubernetes = fake.NewSimpleClientset() - createPVC(t, "consul-server-test1", "default", c.kubernetes) - - c.helmActionsRunner = &helm.MockActionRunner{} - returnCode := c.Run([]string{ - "--auto-approve", - }) - require.Equal(t, 1, returnCode) -} - -func TestInstall_existingSecrets(t *testing.T) { - c := getInitializedCommand(t) - c.kubernetes = fake.NewSimpleClientset() - c.helmActionsRunner = &helm.MockActionRunner{} - secret := &v1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "consul-secret", - Labels: map[string]string{common.CLILabelKey: common.CLILabelValue}, + "install when consul installation errors returns error": { + input: []string{}, + messages: []string{ + "\n==> Checking if Consul can be installed\n ✓ No existing Consul installations found.\n ✓ No existing Consul persistent volume claims found\n ✓ No existing Consul secrets found.\n", + "\n==> Consul Installation Summary\n Name: consul\n Namespace: consul\n \n No overrides provided, using the default Helm values.\n", + "\n==> Installing Consul\n ✓ Downloaded charts.\n ! Helm returned an error.\n", + }, + helmActionsRunner: &helm.MockActionRunner{ + InstallFunc: func(install *action.Install, chrt *chart.Chart, vals map[string]interface{}) (*helmRelease.Release, error) { + return nil, errors.New("Helm returned an error.") + }, + }, + expectedReturnCode: 1, + expectCheckedForConsulInstallations: true, + expectCheckedForConsulDemoInstallations: false, + expectConsulInstalled: false, + expectConsulDemoInstalled: false, }, - } - createSecret(t, secret, "consul", c.kubernetes) - returnCode := c.Run([]string{ - "--auto-approve", - }) - require.Equal(t, 1, returnCode) -} - -func TestInstall_enterpriseInstallWithSecret(t *testing.T) { - c := getInitializedCommand(t) - c.kubernetes = fake.NewSimpleClientset() - c.helmActionsRunner = &helm.MockActionRunner{} - secretName := "consul-license" - secret := &v1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: secretName, + "install with no arguments when consul installation already exists returns error": { + input: []string{ + "--auto-approve", + }, + messages: []string{ + "\n==> Checking if Consul can be installed\n ! Cannot install Consul. A Consul cluster is already installed in namespace consul with name consul.\n Use the command `consul-k8s uninstall` to uninstall Consul from the cluster.\n", + }, + helmActionsRunner: &helm.MockActionRunner{ + CheckForInstallationsFunc: func(options *helm.CheckForInstallationsOptions) (bool, string, string, error) { + return true, "consul", "consul", nil + }, + }, + expectedReturnCode: 1, + expectCheckedForConsulInstallations: true, + expectCheckedForConsulDemoInstallations: false, + expectConsulInstalled: false, + expectConsulDemoInstalled: false, + }, + "install with no arguments when PVCs exist returns error": { + input: []string{}, + messages: []string{ + "\n==> Checking if Consul can be installed\n ✓ No existing Consul installations found.\n ! found persistent volume claims from previous installations, delete before reinstalling: consul/consul-server-test1\n", + }, + helmActionsRunner: &helm.MockActionRunner{}, + preProcessingFunc: func() { + createPVC(t, "consul-server-test1", "consul", k8s) + }, + expectedReturnCode: 1, + expectCheckedForConsulInstallations: true, + expectCheckedForConsulDemoInstallations: false, + expectConsulInstalled: false, + expectConsulDemoInstalled: false, + }, + "install with no arguments when secrets exist returns error": { + input: []string{ + "--auto-approve", + }, + messages: []string{ + "\n==> Checking if Consul can be installed\n ✓ No existing Consul installations found.\n ✓ No existing Consul persistent volume claims found\n ! Found Consul secrets, possibly from a previous installation.\nDelete existing Consul secrets from Kubernetes:\n\nkubectl delete secret consul-secret --namespace consul\n\n", + }, + helmActionsRunner: &helm.MockActionRunner{}, + preProcessingFunc: func() { + secret := &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "consul-secret", + Labels: map[string]string{common.CLILabelKey: common.CLILabelValue}, + }, + } + createSecret(t, secret, "consul", k8s) + }, + expectedReturnCode: 1, + expectCheckedForConsulInstallations: true, + expectCheckedForConsulDemoInstallations: false, + expectConsulInstalled: false, + expectConsulDemoInstalled: false, + }, + "enterprise install when license secret exists returns success": { + input: []string{ + "--set", fmt.Sprintf("global.enterpriseLicense.secretName=%s", licenseSecretName), + }, + messages: []string{ + "\n==> Checking if Consul can be installed\n ✓ No existing Consul installations found.\n ✓ No existing Consul persistent volume claims found\n ✓ No existing Consul secrets found.\n ✓ Valid enterprise Consul secret found.\n", + "\n==> Consul Installation Summary\n Name: consul\n Namespace: consul\n \n Helm value overrides\n -------------------\n global:\n enterpriseLicense:\n secretName: consul-license\n \n", + "\n==> Installing Consul\n ✓ Downloaded charts.\n ✓ Consul installed in namespace \"consul\".\n", + }, + helmActionsRunner: &helm.MockActionRunner{}, + preProcessingFunc: func() { + secret := &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: licenseSecretName, + }, + } + createSecret(t, secret, "consul", k8s) + }, + expectedReturnCode: 0, + expectCheckedForConsulInstallations: true, + expectCheckedForConsulDemoInstallations: false, + expectConsulInstalled: true, + expectConsulDemoInstalled: false, + }, + "enterprise install when license secret does not exist returns error": { + input: []string{ + "--set", fmt.Sprintf("global.enterpriseLicense.secretName=%s", licenseSecretName), + }, + messages: []string{ + "\n==> Checking if Consul can be installed\n ✓ No existing Consul installations found.\n ✓ No existing Consul persistent volume claims found\n ✓ No existing Consul secrets found.\n ! enterprise license secret \"consul-license\" is not found in the \"consul\" namespace; please make sure that the secret exists in the \"consul\" namespace\n"}, + helmActionsRunner: &helm.MockActionRunner{}, + expectedReturnCode: 1, + expectCheckedForConsulInstallations: true, + expectCheckedForConsulDemoInstallations: false, + expectConsulInstalled: false, + expectConsulDemoInstalled: false, + }, + "install for quickstart preset returns success": { + input: []string{ + "-preset", "quickstart", + }, + messages: []string{ + "\n==> Checking if Consul can be installed\n ✓ No existing Consul installations found.\n ✓ No existing Consul persistent volume claims found\n ✓ No existing Consul secrets found.\n", + "\n==> Consul Installation Summary\n Name: consul\n Namespace: consul\n \n Helm value overrides\n -------------------\n connectInject:\n enabled: true\n metrics:\n defaultEnableMerging: true\n defaultEnabled: true\n enableGatewayMetrics: true\n controller:\n enabled: true\n global:\n metrics:\n enableAgentMetrics: true\n enabled: true\n name: consul\n prometheus:\n enabled: true\n server:\n replicas: 1\n ui:\n enabled: true\n service:\n enabled: true\n \n", + "\n==> Installing Consul\n ✓ Downloaded charts.\n ✓ Consul installed in namespace \"consul\".\n", + }, + helmActionsRunner: &helm.MockActionRunner{}, + expectedReturnCode: 0, + expectCheckedForConsulInstallations: true, + expectCheckedForConsulDemoInstallations: false, + expectConsulInstalled: true, + expectConsulDemoInstalled: false, + }, + "install for secure preset returns success": { + input: []string{ + "-preset", "secure", + }, + messages: []string{ + "\n==> Checking if Consul can be installed\n ✓ No existing Consul installations found.\n ✓ No existing Consul persistent volume claims found\n ✓ No existing Consul secrets found.\n", + "\n==> Consul Installation Summary\n Name: consul\n Namespace: consul\n \n Helm value overrides\n -------------------\n connectInject:\n enabled: true\n controller:\n enabled: true\n global:\n acls:\n manageSystemACLs: true\n gossipEncryption:\n autoGenerate: true\n name: consul\n tls:\n enableAutoEncrypt: true\n enabled: true\n server:\n replicas: 1\n \n", + "\n==> Installing Consul\n ✓ Downloaded charts.\n ✓ Consul installed in namespace \"consul\".\n", + }, + helmActionsRunner: &helm.MockActionRunner{}, + expectedReturnCode: 0, + expectCheckedForConsulInstallations: true, + expectCheckedForConsulDemoInstallations: false, + expectConsulInstalled: true, + expectConsulDemoInstalled: false, + }, + "install with demo flag returns success": { + input: []string{ + "-demo", + }, + messages: []string{ + "\n==> Checking if Consul can be installed\n ✓ No existing Consul installations found.\n ✓ No existing Consul persistent volume claims found\n ✓ No existing Consul secrets found.\n", + "\n==> Checking if Consul Demo Application can be installed\n ✓ No existing Consul demo application installations found.\n", + "\n==> Consul Installation Summary\n Name: consul\n Namespace: consul\n \n No overrides provided, using the default Helm values.\n", + "\n==> Installing Consul\n ✓ Downloaded charts.\n ✓ Consul installed in namespace \"consul\".\n", + "\n==> Consul Demo Application Installation Summary\n Name: consul-demo\n Namespace: consul\n \n \n", + "\n==> Installing Consul demo application\n ✓ Downloaded charts.\n ✓ Consul demo application installed in namespace \"consul\".\n", + "\n==> Accessing Consul Demo Application UI\n kubectl port-forward deploy/frontend 8080:80 --namespace consul\n Browse to http://localhost:8080.\n", + }, + helmActionsRunner: &helm.MockActionRunner{}, + expectedReturnCode: 0, + expectCheckedForConsulInstallations: true, + expectCheckedForConsulDemoInstallations: true, + expectConsulInstalled: true, + expectConsulDemoInstalled: true, + }, + "install with demo flag when consul demo installation errors returns error": { + input: []string{ + "-demo", + }, + messages: []string{ + "\n==> Checking if Consul can be installed\n ✓ No existing Consul installations found.\n ✓ No existing Consul persistent volume claims found\n ✓ No existing Consul secrets found.\n", + "\n==> Checking if Consul Demo Application can be installed\n ✓ No existing Consul demo application installations found.\n", + "\n==> Consul Installation Summary\n Name: consul\n Namespace: consul\n \n No overrides provided, using the default Helm values.\n", + "\n==> Installing Consul\n ✓ Downloaded charts.\n ✓ Consul installed in namespace \"consul\".\n ✓ Consul installed in namespace \"consul\".\n", + "\n==> Consul Demo Application Installation Summary\n Name: consul-demo\n Namespace: consul\n \n \n", + "\n==> Installing Consul demo application\n ✓ Downloaded charts.\n ! Helm returned an error.\n", + }, + helmActionsRunner: &helm.MockActionRunner{ + InstallFunc: func(install *action.Install, chrt *chart.Chart, vals map[string]interface{}) (*helmRelease.Release, error) { + if install.ReleaseName == "consul" { + return &helmRelease.Release{Name: install.ReleaseName}, nil + } + return nil, errors.New("Helm returned an error.") + }, + }, + expectedReturnCode: 1, + expectCheckedForConsulInstallations: true, + expectCheckedForConsulDemoInstallations: true, + expectConsulInstalled: true, + expectConsulDemoInstalled: false, + }, + "install with demo flag when demo is already installed returns error and does not install consul or the demo": { + input: []string{ + "-demo", + }, + messages: []string{ + "\n==> Checking if Consul can be installed\n ✓ No existing Consul installations found.\n ✓ No existing Consul persistent volume claims found\n ✓ No existing Consul secrets found.\n", + "\n==> Checking if Consul Demo Application can be installed\n ! Cannot install Consul demo application. A Consul demo application cluster is already installed in namespace consul-demo with name consul-demo.\n Use the command `consul-k8s uninstall` to uninstall the Consul demo application from the cluster.\n", + }, + helmActionsRunner: &helm.MockActionRunner{ + CheckForInstallationsFunc: func(options *helm.CheckForInstallationsOptions) (bool, string, string, error) { + if options.ReleaseName == "consul" { + return false, "", "", nil + } else { + return true, "consul-demo", "consul-demo", nil + } + }, + }, + expectedReturnCode: 1, + expectCheckedForConsulInstallations: true, + expectCheckedForConsulDemoInstallations: true, + expectConsulInstalled: false, + expectConsulDemoInstalled: false, + }, + "install with --dry-run flag returns success": { + input: []string{ + "--dry-run", + }, + messages: []string{ + "\n==> Performing dry run install. No changes will be made to the cluster.\n", + "\n==> Checking if Consul can be installed\n ✓ No existing Consul installations found.\n ✓ No existing Consul persistent volume claims found\n ✓ No existing Consul secrets found.\n", + "\n==> Consul Installation Summary\n Name: consul\n Namespace: consul\n \n No overrides provided, using the default Helm values.\n ✓ Consul installed in namespace \"consul\".\n Dry run complete. No changes were made to the Kubernetes cluster.\n Installation can proceed with this configuration.\n", + }, + helmActionsRunner: &helm.MockActionRunner{}, + expectedReturnCode: 0, + expectCheckedForConsulInstallations: true, + expectCheckedForConsulDemoInstallations: false, + expectConsulInstalled: false, + expectConsulDemoInstalled: false, }, } - createSecret(t, secret, "consul", c.kubernetes) - returnCode := c.Run([]string{ - "--auto-approve", - "--set", fmt.Sprintf("global.enterpriseLicense.secretName=%s", secretName), - }) - - require.Equal(t, 0, returnCode) -} - -func TestInstall_enterpriseInstallWithoutSecretSecret(t *testing.T) { - c := getInitializedCommand(t) - c.kubernetes = fake.NewSimpleClientset() - c.helmActionsRunner = &helm.MockActionRunner{} - secretName := "consul-license" - returnCode := c.Run([]string{ - "--auto-approve", - "--set", fmt.Sprintf("global.enterpriseLicense.secretName=%s", secretName), - }) - require.Equal(t, 1, returnCode) -} - -func TestInstall_DemoFlag(t *testing.T) { - c := getInitializedCommand(t) - c.kubernetes = fake.NewSimpleClientset() - c.helmActionsRunner = &helm.MockActionRunner{} - returnCode := c.Run([]string{ - "-demo", "--auto-approve", - }) - require.Equal(t, 0, returnCode) -} - -func TestInstall_DemoFlagWhenDemoAlreadyInstalled(t *testing.T) { - c := getInitializedCommand(t) - c.kubernetes = fake.NewSimpleClientset() - c.helmActionsRunner = &helm.MockActionRunner{ - CheckForInstallationsReponse: func(options *helm.CheckForInstallationsOptions) (bool, string, string, error) { - if options.ReleaseName == "consul-demo" { - return true, "consul", "consul", nil - } else { - return true, "", "", nil + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + buf := new(bytes.Buffer) + c := getInitializedCommand(t, buf) + k8s = fake.NewSimpleClientset() + c.kubernetes = k8s + mock := tc.helmActionsRunner + c.helmActionsRunner = mock + if tc.preProcessingFunc != nil { + tc.preProcessingFunc() } - }, + input := append([]string{ + "--auto-approve", + }, tc.input...) + returnCode := c.Run(input) + require.Equal(t, tc.expectedReturnCode, returnCode) + require.Equal(t, tc.expectCheckedForConsulInstallations, mock.CheckedForConsulInstallations) + require.Equal(t, tc.expectCheckedForConsulDemoInstallations, mock.CheckedForConsulDemoInstallations) + require.Equal(t, tc.expectConsulInstalled, mock.ConsulInstalled) + require.Equal(t, tc.expectConsulDemoInstalled, mock.ConsulDemoInstalled) + output := buf.String() + for _, msg := range tc.messages { + require.Contains(t, output, msg) + } + }) } - returnCode := c.Run([]string{ - "-demo", "--auto-approve", - }) - require.Equal(t, 1, returnCode) -} - -func TestInstall_SecurePreset(t *testing.T) { - c := getInitializedCommand(t) - c.kubernetes = fake.NewSimpleClientset() - c.helmActionsRunner = &helm.MockActionRunner{} - returnCode := c.Run([]string{ - "-preset", "quickstart", - "--auto-approve", - }) - require.Equal(t, 0, returnCode) -} - -func TestInstall_QuickstartPreset(t *testing.T) { - c := getInitializedCommand(t) - c.kubernetes = fake.NewSimpleClientset() - c.helmActionsRunner = &helm.MockActionRunner{} - returnCode := c.Run([]string{ - "-preset", "quickstart", - "--auto-approve", - }) - require.Equal(t, 0, returnCode) } func createPVC(t *testing.T, name string, namespace string, k8s kubernetes.Interface) { diff --git a/cli/cmd/status/status_test.go b/cli/cmd/status/status_test.go index b45ffef556..e9d622136c 100644 --- a/cli/cmd/status/status_test.go +++ b/cli/cmd/status/status_test.go @@ -4,11 +4,13 @@ import ( "context" "flag" "fmt" + "io" "os" "testing" "github.com/hashicorp/consul-k8s/cli/common" cmnFlag "github.com/hashicorp/consul-k8s/cli/common/flag" + "github.com/hashicorp/consul-k8s/cli/common/terminal" "github.com/hashicorp/go-hclog" "github.com/posener/complete" "github.com/stretchr/testify/assert" @@ -20,7 +22,7 @@ import ( // TestCheckConsulServers creates a fake stateful set and tests the checkConsulServers function. func TestCheckConsulServers(t *testing.T) { - c := getInitializedCommand(t) + c := getInitializedCommand(t, nil) c.kubernetes = fake.NewSimpleClientset() // First check that no stateful sets causes an error. @@ -100,7 +102,7 @@ func TestCheckConsulServers(t *testing.T) { // TestCheckConsulClients is very similar to TestCheckConsulServers() in structure. func TestCheckConsulClients(t *testing.T) { - c := getInitializedCommand(t) + c := getInitializedCommand(t, nil) c.kubernetes = fake.NewSimpleClientset() // No client daemon set should cause an error. @@ -170,16 +172,22 @@ func TestCheckConsulClients(t *testing.T) { } // getInitializedCommand sets up a command struct for tests. -func getInitializedCommand(t *testing.T) *Command { +func getInitializedCommand(t *testing.T, buf io.Writer) *Command { t.Helper() log := hclog.New(&hclog.LoggerOptions{ Name: "cli", Level: hclog.Info, Output: os.Stdout, }) - + var ui terminal.UI + if buf != nil { + ui = terminal.NewUI(context.Background(), buf) + } else { + ui = terminal.NewBasicUI(context.Background()) + } baseCommand := &common.BaseCommand{ Log: log, + UI: ui, } c := &Command{ diff --git a/cli/cmd/uninstall/uninstall.go b/cli/cmd/uninstall/uninstall.go index f7cd0cda5f..28a7e969b6 100644 --- a/cli/cmd/uninstall/uninstall.go +++ b/cli/cmd/uninstall/uninstall.go @@ -189,7 +189,7 @@ func (c *Command) Run(args []string) int { return 1 } - c.UI.Output(fmt.Sprintf("Existing %s Installation", cases.Title(language.English).String(common.ReleaseTypeConsulDemo)), terminal.WithHeaderStyle()) + c.UI.Output(fmt.Sprintf("Checking if %s can be uninstalled", common.ReleaseTypeConsulDemo), terminal.WithHeaderStyle()) foundConsulDemo, foundDemoReleaseName, foundDemoReleaseNamespace, err := c.findExistingInstallation(&helm.CheckForInstallationsOptions{ Settings: settings, ReleaseName: common.ConsulDemoAppReleaseName, @@ -199,20 +199,10 @@ func (c *Command) Run(args []string) int { if err != nil { c.UI.Output(err.Error(), terminal.WithErrorStyle()) return 1 - } else if foundConsulDemo { - err = c.uninstallHelmRelease(foundDemoReleaseName, foundDemoReleaseNamespace, common.ReleaseTypeConsulDemo, settings, uiLogger, actionConfig) - if err != nil { - c.UI.Output(err.Error(), terminal.WithErrorStyle()) - return 1 - } - } else { - c.UI.Output(fmt.Sprintf("No existing %s installation found", common.ReleaseTypeConsulDemo), terminal.WithInfoStyle()) + } else if !foundConsulDemo { + c.UI.Output(fmt.Sprintf("No existing %s installation found.", common.ReleaseTypeConsulDemo), terminal.WithInfoStyle()) } - c.UI.Output("Existing Consul Installation", terminal.WithHeaderStyle()) - // Search for Consul installation by calling `helm list`. Depends on what's already specified. - // Prompt for approval to uninstall Helm release. - // Actually call out to `helm delete`. found, foundReleaseName, foundReleaseNamespace, err := c.findExistingInstallation(&helm.CheckForInstallationsOptions{ Settings: settings, @@ -224,10 +214,23 @@ func (c *Command) Run(args []string) int { return 1 } - err = c.uninstallHelmRelease(foundReleaseName, foundReleaseNamespace, common.ReleaseTypeConsul, settings, uiLogger, actionConfig) - if err != nil { - c.UI.Output(err.Error(), terminal.WithErrorStyle()) - return 1 + if foundConsulDemo { + err = c.uninstallHelmRelease(foundDemoReleaseName, foundDemoReleaseNamespace, common.ReleaseTypeConsulDemo, settings, uiLogger, actionConfig) + if err != nil { + c.UI.Output(err.Error(), terminal.WithErrorStyle()) + return 1 + } + } else { + c.UI.Output(fmt.Sprintf("No existing %s installation found.", common.ReleaseTypeConsulDemo), terminal.WithInfoStyle()) + } + + c.UI.Output("Checking if Consul can be uninstalled", terminal.WithHeaderStyle()) + if found { + err = c.uninstallHelmRelease(foundReleaseName, foundReleaseNamespace, common.ReleaseTypeConsul, settings, uiLogger, actionConfig) + if err != nil { + c.UI.Output(err.Error(), terminal.WithErrorStyle()) + return 1 + } } // If -auto-approve=true and -wipe-data=false, we should only uninstall the release, and skip deleting resources. @@ -325,6 +328,8 @@ func (c *Command) uninstallHelmRelease(releaseName, namespace, releaseType strin c.UI.Output("Name: %s", releaseName, terminal.WithInfoStyle()) c.UI.Output("Namespace: %s", namespace, terminal.WithInfoStyle()) + // Prompt for approval to uninstall Helm release. + // Actually call out to `helm delete`. if !c.flagAutoApprove { confirmation, err := c.UI.Input(&terminal.Input{ Prompt: "Proceed with uninstall? (y/N)", @@ -401,7 +406,7 @@ func (c *Command) findExistingInstallation(options *helm.CheckForInstallationsOp } else { var notFoundError error if !options.SkipErrorWhenNotFound { - notFoundError = fmt.Errorf("could not find consul installation in namespace %s", c.flagNamespace) + notFoundError = fmt.Errorf("could not find %s installation in cluster", common.ReleaseTypeConsul) } return false, "", "", notFoundError } diff --git a/cli/cmd/uninstall/uninstall_test.go b/cli/cmd/uninstall/uninstall_test.go index 1e1e540785..f4ec79700d 100644 --- a/cli/cmd/uninstall/uninstall_test.go +++ b/cli/cmd/uninstall/uninstall_test.go @@ -1,9 +1,12 @@ package uninstall import ( + "bytes" "context" + "errors" "flag" "fmt" + "io" "os" "testing" @@ -15,15 +18,18 @@ import ( "github.com/posener/complete" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "helm.sh/helm/v3/pkg/action" + helmRelease "helm.sh/helm/v3/pkg/release" batchv1 "k8s.io/api/batch/v1" 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" "k8s.io/client-go/kubernetes/fake" ) func TestDeletePVCs(t *testing.T) { - c := getInitializedCommand(t) + c := getInitializedCommand(t, nil) c.kubernetes = fake.NewSimpleClientset() pvc := &v1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{ @@ -64,7 +70,7 @@ func TestDeletePVCs(t *testing.T) { } func TestDeleteSecrets(t *testing.T) { - c := getInitializedCommand(t) + c := getInitializedCommand(t, nil) c.kubernetes = fake.NewSimpleClientset() secret := &v1.Secret{ ObjectMeta: metav1.ObjectMeta{ @@ -107,7 +113,7 @@ func TestDeleteSecrets(t *testing.T) { } func TestDeleteServiceAccounts(t *testing.T) { - c := getInitializedCommand(t) + c := getInitializedCommand(t, nil) c.kubernetes = fake.NewSimpleClientset() sa := &v1.ServiceAccount{ ObjectMeta: metav1.ObjectMeta{ @@ -148,7 +154,7 @@ func TestDeleteServiceAccounts(t *testing.T) { } func TestDeleteRoles(t *testing.T) { - c := getInitializedCommand(t) + c := getInitializedCommand(t, nil) c.kubernetes = fake.NewSimpleClientset() role := &rbacv1.Role{ ObjectMeta: metav1.ObjectMeta{ @@ -189,7 +195,7 @@ func TestDeleteRoles(t *testing.T) { } func TestDeleteRoleBindings(t *testing.T) { - c := getInitializedCommand(t) + c := getInitializedCommand(t, nil) c.kubernetes = fake.NewSimpleClientset() rolebinding := &rbacv1.RoleBinding{ ObjectMeta: metav1.ObjectMeta{ @@ -230,7 +236,7 @@ func TestDeleteRoleBindings(t *testing.T) { } func TestDeleteJobs(t *testing.T) { - c := getInitializedCommand(t) + c := getInitializedCommand(t, nil) c.kubernetes = fake.NewSimpleClientset() job := &batchv1.Job{ ObjectMeta: metav1.ObjectMeta{ @@ -271,7 +277,7 @@ func TestDeleteJobs(t *testing.T) { } func TestDeleteClusterRoles(t *testing.T) { - c := getInitializedCommand(t) + c := getInitializedCommand(t, nil) c.kubernetes = fake.NewSimpleClientset() clusterrole := &rbacv1.ClusterRole{ ObjectMeta: metav1.ObjectMeta{ @@ -312,7 +318,7 @@ func TestDeleteClusterRoles(t *testing.T) { } func TestDeleteClusterRoleBindings(t *testing.T) { - c := getInitializedCommand(t) + c := getInitializedCommand(t, nil) c.kubernetes = fake.NewSimpleClientset() clusterrolebinding := &rbacv1.ClusterRoleBinding{ ObjectMeta: metav1.ObjectMeta{ @@ -353,17 +359,22 @@ func TestDeleteClusterRoleBindings(t *testing.T) { } // getInitializedCommand sets up a command struct for tests. -func getInitializedCommand(t *testing.T) *Command { +func getInitializedCommand(t *testing.T, buf io.Writer) *Command { t.Helper() log := hclog.New(&hclog.LoggerOptions{ Name: "cli", Level: hclog.Info, Output: os.Stdout, }) - + var ui terminal.UI + if buf != nil { + ui = terminal.NewUI(context.Background(), buf) + } else { + ui = terminal.NewBasicUI(context.Background()) + } baseCommand := &common.BaseCommand{ Log: log, - UI: terminal.NewBasicUI(context.TODO()), + UI: ui, } c := &Command{ @@ -404,25 +415,186 @@ func TestTaskCreateCommand_AutocompleteArgs(t *testing.T) { } func TestUninstall(t *testing.T) { - c := getInitializedCommand(t) - c.kubernetes = fake.NewSimpleClientset() - c.helmActionsRunner = &helm.MockActionRunner{ - CheckForInstallationsReponse: func(options *helm.CheckForInstallationsOptions) (bool, string, string, error) { - return true, "consul", "consul", nil + var k8s kubernetes.Interface + cases := map[string]struct { + input []string + messages []string + helmActionsRunner *helm.MockActionRunner + preProcessingFunc func() + expectedReturnCode int + expectCheckedForConsulInstallations bool + expectCheckedForConsulDemoInstallations bool + expectConsulUninstalled bool + expectConsulDemoUninstalled bool + }{ + "uninstall when consul installation exists returns success": { + input: []string{}, + messages: []string{ + "\n==> Checking if Consul demo application can be uninstalled\n No existing Consul demo application installation found.\n", + "\n==> Checking if Consul can be uninstalled\n ✓ Existing Consul installation found.\n", + "\n==> Consul Uninstall Summary\n Name: consul\n Namespace: consul\n ✓ Successfully uninstalled Consul Helm release.\n ✓ Skipping deleting PVCs, secrets, and service accounts.\n", + }, + helmActionsRunner: &helm.MockActionRunner{ + CheckForInstallationsFunc: func(options *helm.CheckForInstallationsOptions) (bool, string, string, error) { + if options.ReleaseName == "consul" { + return true, "consul", "consul", nil + } else { + return false, "", "", nil + } + }, + }, + expectedReturnCode: 0, + expectCheckedForConsulInstallations: true, + expectCheckedForConsulDemoInstallations: true, + expectConsulUninstalled: true, + expectConsulDemoUninstalled: false, + }, + "uninstall when consul installation does not exist returns error": { + input: []string{}, + messages: []string{ + "\n==> Checking if Consul demo application can be uninstalled\n No existing Consul demo application installation found.\n ! could not find Consul installation in cluster\n", + }, + helmActionsRunner: &helm.MockActionRunner{ + CheckForInstallationsFunc: func(options *helm.CheckForInstallationsOptions) (bool, string, string, error) { + if options.ReleaseName == "consul" { + return false, "", "", nil + } else { + return false, "", "", nil + } + }, + }, + expectedReturnCode: 1, + expectCheckedForConsulInstallations: true, + expectCheckedForConsulDemoInstallations: true, + expectConsulUninstalled: false, + expectConsulDemoUninstalled: false, + }, + "uninstall with -wipe-data flag processes other rescource and returns success": { + input: []string{ + "-wipe-data", + }, + messages: []string{ + "\n==> Checking if Consul demo application can be uninstalled\n No existing Consul demo application installation found.\n No existing Consul demo application installation found.\n", + "\n==> Checking if Consul can be uninstalled\n ✓ Existing Consul installation found.\n", + "\n==> Consul Uninstall Summary\n Name: consul\n Namespace: consul\n ✓ Successfully uninstalled Consul Helm release.\n", + "\n==> Other Consul Resources\n Deleting data for installation: \n Name: consul\n Namespace consul\n ✓ No PVCs found.\n ✓ No Consul secrets found.\n ✓ No Consul service accounts found.\n ✓ No Consul roles found.\n ✓ No Consul rolebindings found.\n ✓ No Consul jobs found.\n ✓ No Consul cluster roles found.\n ✓ No Consul cluster role bindings found.\n", + }, + helmActionsRunner: &helm.MockActionRunner{ + CheckForInstallationsFunc: func(options *helm.CheckForInstallationsOptions) (bool, string, string, error) { + if options.ReleaseName == "consul" { + return true, "consul", "consul", nil + } else { + return false, "", "", nil + } + }, + }, + expectedReturnCode: 0, + expectCheckedForConsulInstallations: true, + expectCheckedForConsulDemoInstallations: true, + expectConsulUninstalled: true, + expectConsulDemoUninstalled: false, + }, + "uninstall when both consul and consul demo installations exist returns success": { + input: []string{}, + messages: []string{ + "\n==> Checking if Consul demo application can be uninstalled\n ✓ Existing Consul demo application installation found.\n", + "\n==> Consul Demo Application Uninstall Summary\n Name: consul-demo\n Namespace: consul-demo\n ✓ Successfully uninstalled Consul demo application Helm release.\n", + "\n==> Checking if Consul can be uninstalled\n ✓ Existing Consul installation found.\n", + "\n==> Consul Uninstall Summary\n Name: consul\n Namespace: consul\n ✓ Successfully uninstalled Consul Helm release.\n ✓ Skipping deleting PVCs, secrets, and service accounts.\n", + }, + helmActionsRunner: &helm.MockActionRunner{ + CheckForInstallationsFunc: func(options *helm.CheckForInstallationsOptions) (bool, string, string, error) { + if options.ReleaseName == "consul" { + return true, "consul", "consul", nil + } else { + return true, "consul-demo", "consul-demo", nil + } + }, + }, + expectedReturnCode: 0, + expectCheckedForConsulInstallations: true, + expectCheckedForConsulDemoInstallations: true, + expectConsulUninstalled: true, + expectConsulDemoUninstalled: true, + }, + "uninstall when consul uninstall errors returns error": { + input: []string{}, + messages: []string{ + "\n==> Checking if Consul demo application can be uninstalled\n No existing Consul demo application installation found.\n", + "\n==> Checking if Consul can be uninstalled\n ✓ Existing Consul installation found.\n", + "\n==> Consul Uninstall Summary\n Name: consul\n Namespace: consul\n ! Helm returned an error.\n", + }, + helmActionsRunner: &helm.MockActionRunner{ + CheckForInstallationsFunc: func(options *helm.CheckForInstallationsOptions) (bool, string, string, error) { + if options.ReleaseName == "consul" { + return true, "consul", "consul", nil + } else { + return false, "", "", nil + } + }, + UninstallFunc: func(uninstall *action.Uninstall, name string) (*helmRelease.UninstallReleaseResponse, error) { + return nil, errors.New("Helm returned an error.") + }, + }, + expectedReturnCode: 1, + expectCheckedForConsulInstallations: true, + expectCheckedForConsulDemoInstallations: true, + expectConsulUninstalled: false, + expectConsulDemoUninstalled: false, + }, + "uninstall when consul demo is installed consul demo uninstall errors returns error": { + input: []string{}, + messages: []string{ + "\n==> Checking if Consul demo application can be uninstalled\n ✓ Existing Consul demo application installation found.\n", + "\n==> Consul Demo Application Uninstall Summary\n Name: consul-demo\n Namespace: consul-demo\n ! Helm returned an error.\n", + }, + helmActionsRunner: &helm.MockActionRunner{ + CheckForInstallationsFunc: func(options *helm.CheckForInstallationsOptions) (bool, string, string, error) { + if options.ReleaseName == "consul" { + return true, "consul", "consul", nil + } else { + return true, "consul-demo", "consul-demo", nil + } + }, + UninstallFunc: func(uninstall *action.Uninstall, name string) (*helmRelease.UninstallReleaseResponse, error) { + if name == "consul" { + return &helmRelease.UninstallReleaseResponse{}, nil + } else { + return nil, errors.New("Helm returned an error.") + } + }, + }, + expectedReturnCode: 1, + expectCheckedForConsulInstallations: true, + expectCheckedForConsulDemoInstallations: true, + expectConsulUninstalled: false, + expectConsulDemoUninstalled: false, }, } - returnCode := c.Run([]string{ - "--auto-approve", - }) - require.Equal(t, 0, returnCode) -} - -func TestUninstall_noExistingConsul(t *testing.T) { - c := getInitializedCommand(t) - c.kubernetes = fake.NewSimpleClientset() - c.helmActionsRunner = &helm.MockActionRunner{} - returnCode := c.Run([]string{ - "--auto-approve", - }) - require.Equal(t, 1, returnCode) + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + buf := new(bytes.Buffer) + c := getInitializedCommand(t, buf) + k8s = fake.NewSimpleClientset() + c.kubernetes = k8s + mock := tc.helmActionsRunner + c.helmActionsRunner = mock + if tc.preProcessingFunc != nil { + tc.preProcessingFunc() + } + input := append([]string{ + "--auto-approve", + }, tc.input...) + returnCode := c.Run(input) + require.Equal(t, tc.expectedReturnCode, returnCode) + require.Equal(t, tc.expectCheckedForConsulInstallations, mock.CheckedForConsulInstallations) + require.Equal(t, tc.expectCheckedForConsulDemoInstallations, mock.CheckedForConsulDemoInstallations) + require.Equal(t, tc.expectConsulUninstalled, mock.ConsulUninstalled) + require.Equal(t, tc.expectConsulDemoUninstalled, mock.ConsulDemoUninstalled) + output := buf.String() + for _, msg := range tc.messages { + require.Contains(t, output, msg) + } + }) + } } diff --git a/cli/cmd/upgrade/upgrade.go b/cli/cmd/upgrade/upgrade.go index 88ced7f85c..0e7d5ac2f0 100644 --- a/cli/cmd/upgrade/upgrade.go +++ b/cli/cmd/upgrade/upgrade.go @@ -17,7 +17,7 @@ import ( "github.com/hashicorp/consul-k8s/cli/helm" "github.com/hashicorp/consul-k8s/cli/preset" "github.com/posener/complete" - "helm.sh/helm/v3/pkg/action" + helmCLI "helm.sh/helm/v3/pkg/cli" "helm.sh/helm/v3/pkg/cli/values" "helm.sh/helm/v3/pkg/getter" @@ -52,10 +52,15 @@ const ( flagNameContext = "context" flagNameKubeconfig = "kubeconfig" + flagNameDemo = "demo" + defaultDemo = false + flagHCPResourceID = "hcp-resource-id" envHCPClientID = "HCP_CLIENT_ID" envHCPClientSecret = "HCP_CLIENT_SECRET" + + consulDemoChartPath = "demo" ) type Command struct { @@ -81,6 +86,7 @@ type Command struct { flagVerbose bool flagWait bool flagHCPResourceID string + flagDemo bool flagKubeConfig string flagKubeContext string @@ -172,6 +178,13 @@ func (c *Command) init() { Default: "", Usage: "Set the HCP resource_id when using the 'cloud' preset.", }) + f.BoolVar(&flag.BoolVar{ + Name: flagNameDemo, + Target: &c.flagDemo, + Default: defaultDemo, + Usage: fmt.Sprintf("Install %s immediately after installing %s.", + common.ReleaseTypeConsulDemo, common.ReleaseTypeConsul), + }) c.help = c.set.Help() } @@ -233,33 +246,45 @@ func (c *Command) Run(args []string) int { c.UI.Output("Checking if Consul can be upgraded", terminal.WithHeaderStyle()) uiLogger := c.createUILogger() - found, name, namespace, err := c.helmActionsRunner.CheckForInstallations(&helm.CheckForInstallationsOptions{ + found, consulName, consulNamespace, err := c.helmActionsRunner.CheckForInstallations(&helm.CheckForInstallationsOptions{ Settings: settings, ReleaseName: common.DefaultReleaseName, DebugLog: uiLogger, }) - if !found { - c.UI.Output("Cannot upgrade Consul. Existing Consul installation not found. Use the command `consul-k8s install` to install Consul.", terminal.WithErrorStyle()) - return 1 - } - c.UI.Output("Existing Consul installation found to be upgraded.", terminal.WithSuccessStyle()) - c.UI.Output("Name: %s\nNamespace: %s", name, namespace, terminal.WithInfoStyle()) - chart, err := helm.LoadChart(consulChart.ConsulHelmChart, common.TopLevelChartDirName) if err != nil { c.UI.Output(err.Error(), terminal.WithErrorStyle()) return 1 } - c.UI.Output("Loaded charts", terminal.WithSuccessStyle()) - - currentChartValues, err := helm.FetchChartValues(namespace, name, settings, uiLogger) - if err != nil { - c.UI.Output(err.Error(), terminal.WithErrorStyle()) + if !found { + c.UI.Output("Cannot upgrade Consul. Existing Consul installation not found. Use the command `consul-k8s install` to install Consul.", terminal.WithErrorStyle()) return 1 + } else { + c.UI.Output("Existing %s installation found to be upgraded.", common.ReleaseTypeConsul, terminal.WithSuccessStyle()) + c.UI.Output("Name: %s\nNamespace: %s", consulName, consulNamespace, terminal.WithInfoStyle()) + } + + c.UI.Output(fmt.Sprintf("Checking if %s can be upgraded", common.ReleaseTypeConsulDemo), terminal.WithHeaderStyle()) + // Ensure there is not an existing Consul demo installation which would cause a conflict. + foundDemo, demoName, demoNamespace, _ := c.helmActionsRunner.CheckForInstallations(&helm.CheckForInstallationsOptions{ + Settings: settings, + ReleaseName: common.ConsulDemoAppReleaseName, + DebugLog: uiLogger, + }) + if foundDemo { + c.UI.Output("Existing %s installation found to be upgraded.", common.ReleaseTypeConsulDemo, terminal.WithSuccessStyle()) + c.UI.Output("Name: %s\nNamespace: %s", demoName, demoNamespace, terminal.WithInfoStyle()) + } else { + if c.flagDemo { + c.UI.Output("No existing %s installation found, but -demo flag provided. %s will be installed in namespace %s.", + common.ConsulDemoAppReleaseName, common.ConsulDemoAppReleaseName, consulNamespace, terminal.WithInfoStyle()) + } else { + c.UI.Output("No existing %s installation found.", common.ReleaseTypeConsulDemo, terminal.WithInfoStyle()) + } } // Handle preset, value files, and set values logic. - chartValues, err := c.mergeValuesFlagsWithPrecedence(settings, namespace) + chartValues, err := c.mergeValuesFlagsWithPrecedence(settings, consulNamespace) if err != nil { c.UI.Output(err.Error(), terminal.WithErrorStyle()) return 1 @@ -270,66 +295,95 @@ func (c *Command) Run(args []string) int { // aren't double prefixed with "consul-consul-...". chartValues = common.MergeMaps(config.ConvertToMap(config.GlobalNameConsul), chartValues) - // Print out the upgrade summary. - if err = c.printDiff(currentChartValues, chartValues); err != nil { - c.UI.Output("Could not print the different between current and upgraded charts: %v", err, terminal.WithErrorStyle()) + timeout, err := time.ParseDuration(c.flagTimeout) + if err != nil { + c.UI.Output(err.Error(), terminal.WithErrorStyle()) return 1 } - - // Check if the user is OK with the upgrade unless the auto approve or dry run flags are true. - if !c.flagAutoApprove && !c.flagDryRun { - confirmation, err := c.UI.Input(&terminal.Input{ - Prompt: "Proceed with upgrade? (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("Upgrade aborted. Use the command `consul-k8s upgrade -help` to learn how to customize your upgrade.", - terminal.WithInfoStyle()) - return 1 - } - } - - if !c.flagDryRun { - c.UI.Output("Upgrading Consul", terminal.WithHeaderStyle()) - } else { - c.UI.Output("Performing Dry Run Upgrade", terminal.WithHeaderStyle()) - } - - // Setup action configuration for Helm Go SDK function calls. - actionConfig := new(action.Configuration) - actionConfig, err = helm.InitActionConfig(actionConfig, namespace, settings, uiLogger) + options := &helm.UpgradeOptions{ + ReleaseName: consulName, + ReleaseType: common.ReleaseTypeConsul, + ReleaseTypeName: common.ReleaseTypeConsul, + Namespace: consulNamespace, + Values: chartValues, + Settings: settings, + EmbeddedChart: consulChart.ConsulHelmChart, + ChartDirName: common.TopLevelChartDirName, + UILogger: uiLogger, + DryRun: c.flagDryRun, + AutoApprove: c.flagAutoApprove, + Wait: c.flagWait, + Timeout: timeout, + UI: c.UI, + HelmActionsRunner: c.helmActionsRunner, + } + + err = helm.UpgradeHelmRelease(options) if err != nil { c.UI.Output(err.Error(), terminal.WithErrorStyle()) return 1 } - // Setup the upgrade action. - upgrade := action.NewUpgrade(actionConfig) - upgrade.Namespace = namespace - upgrade.DryRun = c.flagDryRun - upgrade.Wait = c.flagWait - upgrade.Timeout = c.timeoutDuration - - // Run the upgrade. Note that the dry run config is passed into the upgrade action, so upgrade.Run is called even during a dry run. - _, err = upgrade.Run(common.DefaultReleaseName, chart, chartValues) + timeout, err = time.ParseDuration(c.flagTimeout) if err != nil { c.UI.Output(err.Error(), terminal.WithErrorStyle()) return 1 } + if foundDemo { + options := &helm.UpgradeOptions{ + ReleaseName: demoName, + ReleaseType: common.ReleaseTypeConsulDemo, + ReleaseTypeName: common.ConsulDemoAppReleaseName, + Namespace: demoNamespace, + Values: make(map[string]interface{}), + Settings: settings, + EmbeddedChart: consulChart.DemoHelmChart, + ChartDirName: consulDemoChartPath, + UILogger: uiLogger, + DryRun: c.flagDryRun, + AutoApprove: c.flagAutoApprove, + Wait: c.flagWait, + Timeout: timeout, + UI: c.UI, + HelmActionsRunner: c.helmActionsRunner, + } + + err = helm.UpgradeHelmRelease(options) + if err != nil { + c.UI.Output(err.Error(), terminal.WithErrorStyle()) + return 1 + } + } else if c.flagDemo { + + options := &helm.InstallOptions{ + ReleaseName: common.ConsulDemoAppReleaseName, + ReleaseType: common.ReleaseTypeConsulDemo, + Namespace: settings.Namespace(), + Values: make(map[string]interface{}), + Settings: settings, + EmbeddedChart: consulChart.DemoHelmChart, + ChartDirName: consulDemoChartPath, + UILogger: uiLogger, + DryRun: c.flagDryRun, + AutoApprove: c.flagAutoApprove, + Wait: c.flagWait, + Timeout: timeout, + UI: c.UI, + HelmActionsRunner: c.helmActionsRunner, + } + err = helm.InstallDemoApp(options) + if err != nil { + c.UI.Output(err.Error(), terminal.WithErrorStyle()) + return 1 + } + } + if c.flagDryRun { c.UI.Output("Dry run complete. No changes were made to the Kubernetes cluster.\n"+ "Upgrade can proceed with this configuration.", terminal.WithInfoStyle()) return 0 } - - c.UI.Output("Consul upgraded in namespace %q.", namespace, terminal.WithSuccessStyle()) return 0 } @@ -466,28 +520,6 @@ func (c *Command) createUILogger() func(string, ...interface{}) { } } -// printDiff marshals both maps to YAML and prints the diff between the two. -func (c *Command) printDiff(old, new map[string]interface{}) error { - diff, err := common.Diff(old, new) - if err != nil { - return err - } - - c.UI.Output("\nDifference between user overrides for current and upgraded charts"+ - "\n--------------------------------------------------------------", terminal.WithInfoStyle()) - for _, line := range strings.Split(diff, "\n") { - if strings.HasPrefix(line, "+") { - c.UI.Output(line, terminal.WithDiffAddedStyle()) - } else if strings.HasPrefix(line, "-") { - c.UI.Output(line, terminal.WithDiffRemovedStyle()) - } else { - c.UI.Output(line, terminal.WithDiffUnchangedStyle()) - } - } - - return nil -} - // getPreset is a factory function that, given a string, produces a struct that // implements the Preset interface. If the string is not recognized an error is // returned. diff --git a/cli/cmd/upgrade/upgrade_test.go b/cli/cmd/upgrade/upgrade_test.go index d59a4c1230..7ad1f7fb58 100644 --- a/cli/cmd/upgrade/upgrade_test.go +++ b/cli/cmd/upgrade/upgrade_test.go @@ -1,18 +1,29 @@ package upgrade import ( + "bytes" + "context" + "errors" "flag" "fmt" + "io" "os" "testing" "github.com/hashicorp/consul-k8s/cli/common" cmnFlag "github.com/hashicorp/consul-k8s/cli/common/flag" + "github.com/hashicorp/consul-k8s/cli/common/terminal" + "github.com/hashicorp/consul-k8s/cli/helm" "github.com/hashicorp/consul-k8s/cli/preset" "github.com/hashicorp/go-hclog" "github.com/posener/complete" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/chart" + helmRelease "helm.sh/helm/v3/pkg/release" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/fake" ) // TestValidateFlags tests the validate flags function. @@ -45,7 +56,7 @@ func TestValidateFlags(t *testing.T) { } for _, testCase := range testCases { - c := getInitializedCommand(t) + c := getInitializedCommand(t, nil) t.Run(testCase.description, func(t *testing.T) { if err := c.validateFlags(testCase.input); err == nil { t.Errorf("Test case should have failed.") @@ -55,16 +66,22 @@ func TestValidateFlags(t *testing.T) { } // getInitializedCommand sets up a command struct for tests. -func getInitializedCommand(t *testing.T) *Command { +func getInitializedCommand(t *testing.T, buf io.Writer) *Command { t.Helper() log := hclog.New(&hclog.LoggerOptions{ Name: "cli", Level: hclog.Info, Output: os.Stdout, }) - + var ui terminal.UI + if buf != nil { + ui = terminal.NewUI(context.Background(), buf) + } else { + ui = terminal.NewBasicUI(context.Background()) + } baseCommand := &common.BaseCommand{ Log: log, + UI: ui, } c := &Command{ @@ -124,7 +141,7 @@ func TestGetPreset(t *testing.T) { } for _, tc := range testCases { - c := getInitializedCommand(t) + c := getInitializedCommand(t, nil) t.Run(tc.description, func(t *testing.T) { p, err := c.getPreset(tc.presetName, "consul") require.NoError(t, err) @@ -166,12 +183,12 @@ func TestValidateCloudPresets(t *testing.T) { "Should error on cloud preset when HCP_CLIENT_ID is not provided.", []string{"-preset=cloud", "-hcp-resource-id=foobar"}, func() { - os.Setenv("HCP_CLIENT_ID", "") + os.Unsetenv("HCP_CLIENT_ID") os.Setenv("HCP_CLIENT_SECRET", "bar") }, func() { - os.Setenv("HCP_CLIENT_ID", "") - os.Setenv("HCP_CLIENT_SECRET", "") + os.Unsetenv("HCP_CLIENT_ID") + os.Unsetenv("HCP_CLIENT_SECRET") }, true, }, @@ -180,11 +197,11 @@ func TestValidateCloudPresets(t *testing.T) { []string{"-preset=cloud", "-hcp-resource-id=foobar"}, func() { os.Setenv("HCP_CLIENT_ID", "foo") - os.Setenv("HCP_CLIENT_SECRET", "") + os.Unsetenv("HCP_CLIENT_SECRET") }, func() { - os.Setenv("HCP_CLIENT_ID", "") - os.Setenv("HCP_CLIENT_SECRET", "") + os.Unsetenv("HCP_CLIENT_ID") + os.Unsetenv("HCP_CLIENT_SECRET") }, true, }, @@ -196,8 +213,8 @@ func TestValidateCloudPresets(t *testing.T) { os.Setenv("HCP_CLIENT_SECRET", "bar") }, func() { - os.Setenv("HCP_CLIENT_ID", "") - os.Setenv("HCP_CLIENT_SECRET", "") + os.Unsetenv("HCP_CLIENT_ID") + os.Unsetenv("HCP_CLIENT_SECRET") }, true, }, @@ -209,8 +226,8 @@ func TestValidateCloudPresets(t *testing.T) { os.Setenv("HCP_CLIENT_SECRET", "bar") }, func() { - os.Setenv("HCP_CLIENT_ID", "") - os.Setenv("HCP_CLIENT_SECRET", "") + os.Unsetenv("HCP_CLIENT_ID") + os.Unsetenv("HCP_CLIENT_SECRET") }, true, }, @@ -218,7 +235,7 @@ func TestValidateCloudPresets(t *testing.T) { for _, testCase := range testCases { testCase.preProcessingFunc() - c := getInitializedCommand(t) + c := getInitializedCommand(t, nil) t.Run(testCase.description, func(t *testing.T) { err := c.validateFlags(testCase.input) if testCase.expectError && err == nil { @@ -230,3 +247,306 @@ func TestValidateCloudPresets(t *testing.T) { testCase.postProcessingFunc() } } + +func TestUpgrade(t *testing.T) { + var k8s kubernetes.Interface + cases := map[string]struct { + input []string + messages []string + helmActionsRunner *helm.MockActionRunner + preProcessingFunc func() + expectedReturnCode int + expectCheckedForConsulInstallations bool + expectCheckedForConsulDemoInstallations bool + expectConsulUpgraded bool + expectConsulDemoUpgraded bool + expectConsulDemoInstalled bool + }{ + "upgrade when consul installation exists returns success": { + input: []string{}, + messages: []string{ + "\n==> Checking if Consul can be upgraded\n ✓ Existing Consul installation found to be upgraded.\n Name: consul\n Namespace: consul\n", + "\n==> Checking if Consul demo application can be upgraded\n No existing Consul demo application installation found.\n", + "\n==> Consul Upgrade Summary\n ✓ Downloaded charts.\n \n Difference between user overrides for current and upgraded charts\n --------------------------------------------------------------\n + global:\n + name: consul\n \n", + "\n==> Upgrading Consul\n ✓ Consul upgraded in namespace \"consul\".\n", + }, + helmActionsRunner: &helm.MockActionRunner{ + CheckForInstallationsFunc: func(options *helm.CheckForInstallationsOptions) (bool, string, string, error) { + if options.ReleaseName == "consul" { + return true, "consul", "consul", nil + } else { + return false, "", "", nil + } + }, + }, + expectedReturnCode: 0, + expectCheckedForConsulInstallations: true, + expectCheckedForConsulDemoInstallations: true, + expectConsulUpgraded: true, + expectConsulDemoUpgraded: false, + }, + "upgrade when consul installation does not exists returns error": { + input: []string{}, + messages: []string{ + "\n==> Checking if Consul can be upgraded\n ! Cannot upgrade Consul. Existing Consul installation not found. Use the command `consul-k8s install` to install Consul.\n", + }, + helmActionsRunner: &helm.MockActionRunner{ + CheckForInstallationsFunc: func(options *helm.CheckForInstallationsOptions) (bool, string, string, error) { + if options.ReleaseName == "consul" { + return false, "", "", nil + } else { + return false, "", "", nil + } + }, + }, + expectedReturnCode: 1, + expectCheckedForConsulInstallations: true, + expectCheckedForConsulDemoInstallations: false, + expectConsulUpgraded: false, + expectConsulDemoUpgraded: false, + }, + "upgrade when consul upgrade errors returns error": { + input: []string{}, + messages: []string{ + "\n==> Checking if Consul can be upgraded\n ✓ Existing Consul installation found to be upgraded.\n Name: consul\n Namespace: consul\n", + "\n==> Checking if Consul demo application can be upgraded\n No existing Consul demo application installation found.\n", + "\n==> Consul Upgrade Summary\n ✓ Downloaded charts.\n \n Difference between user overrides for current and upgraded charts\n --------------------------------------------------------------\n + global:\n + name: consul\n \n\n==> Upgrading Consul\n ! Helm returned an error.\n", + }, + helmActionsRunner: &helm.MockActionRunner{ + CheckForInstallationsFunc: func(options *helm.CheckForInstallationsOptions) (bool, string, string, error) { + if options.ReleaseName == "consul" { + return true, "consul", "consul", nil + } else { + return false, "", "", nil + } + }, + UpgradeFunc: func(upgrade *action.Upgrade, name string, chart *chart.Chart, vals map[string]interface{}) (*helmRelease.Release, error) { + return nil, errors.New("Helm returned an error.") + }, + }, + expectedReturnCode: 1, + expectCheckedForConsulInstallations: true, + expectCheckedForConsulDemoInstallations: true, + expectConsulUpgraded: false, + expectConsulDemoUpgraded: false, + }, + "upgrade when demo flag provided but no demo installation exists installs demo and returns success": { + input: []string{ + "-demo", + }, + messages: []string{ + "\n==> Checking if Consul can be upgraded\n ✓ Existing Consul installation found to be upgraded.\n Name: consul\n Namespace: consul\n", + "\n==> Checking if Consul demo application can be upgraded\n No existing consul-demo installation found, but -demo flag provided. consul-demo will be installed in namespace consul.\n", + "\n==> Consul Upgrade Summary\n ✓ Downloaded charts.\n \n Difference between user overrides for current and upgraded charts\n --------------------------------------------------------------\n + global:\n + name: consul\n \n", + "\n==> Upgrading Consul\n ✓ Consul upgraded in namespace \"consul\".\n", + "\n==> Consul Demo Application Installation Summary\n Name: consul-demo\n Namespace: consul\n \n \n", + "\n==> Installing Consul demo application\n ✓ Downloaded charts.\n ✓ Consul demo application installed in namespace \"consul\".\n", + "\n==> Accessing Consul Demo Application UI\n kubectl port-forward deploy/frontend 8080:80 --namespace consul\n Browse to http://localhost:8080.\n", + }, + helmActionsRunner: &helm.MockActionRunner{ + CheckForInstallationsFunc: func(options *helm.CheckForInstallationsOptions) (bool, string, string, error) { + if options.ReleaseName == "consul" { + return true, "consul", "consul", nil + } else { + return false, "", "", nil + } + }, + }, + expectedReturnCode: 0, + expectCheckedForConsulInstallations: true, + expectCheckedForConsulDemoInstallations: true, + expectConsulUpgraded: true, + expectConsulDemoUpgraded: false, + expectConsulDemoInstalled: true, + }, + "upgrade when demo flag provided and demo installation exists upgrades demo and returns success": { + input: []string{ + "-demo", + }, + messages: []string{ + "\n==> Checking if Consul can be upgraded\n ✓ Existing Consul installation found to be upgraded.\n Name: consul\n Namespace: consul\n", + "\n==> Checking if Consul demo application can be upgraded\n ✓ Existing Consul demo application installation found to be upgraded.\n Name: consul-demo\n Namespace: consul-demo\n", + "\n==> Consul Upgrade Summary\n ✓ Downloaded charts.\n \n Difference between user overrides for current and upgraded charts\n --------------------------------------------------------------\n + global:\n + name: consul\n \n", + "\n==> Upgrading Consul\n ✓ Consul upgraded in namespace \"consul\".\n", + "\n==> Consul-Demo Upgrade Summary\n ✓ Downloaded charts.\n \n Difference between user overrides for current and upgraded charts\n --------------------------------------------------------------\n \n", + "\n==> Upgrading consul-demo\n ✓ Consul-Demo upgraded in namespace \"consul-demo\".\n", + }, + helmActionsRunner: &helm.MockActionRunner{ + CheckForInstallationsFunc: func(options *helm.CheckForInstallationsOptions) (bool, string, string, error) { + if options.ReleaseName == "consul" { + return true, "consul", "consul", nil + } else { + return true, "consul-demo", "consul-demo", nil + } + }, + }, + expectedReturnCode: 0, + expectCheckedForConsulInstallations: true, + expectCheckedForConsulDemoInstallations: true, + expectConsulUpgraded: true, + expectConsulDemoUpgraded: true, + expectConsulDemoInstalled: false, + }, + "upgrade when demo flag not provided but demo installation exists upgrades demo and returns success": { + input: []string{}, + messages: []string{ + "\n==> Checking if Consul can be upgraded\n ✓ Existing Consul installation found to be upgraded.\n Name: consul\n Namespace: consul\n", + "\n==> Checking if Consul demo application can be upgraded\n ✓ Existing Consul demo application installation found to be upgraded.\n Name: consul-demo\n Namespace: consul-demo\n", + "\n==> Consul Upgrade Summary\n ✓ Downloaded charts.\n \n Difference between user overrides for current and upgraded charts\n --------------------------------------------------------------\n + global:\n + name: consul\n \n", + "\n==> Upgrading Consul\n ✓ Consul upgraded in namespace \"consul\".\n", + "\n==> Consul-Demo Upgrade Summary\n ✓ Downloaded charts.\n \n Difference between user overrides for current and upgraded charts\n --------------------------------------------------------------\n \n", + "\n==> Upgrading consul-demo\n ✓ Consul-Demo upgraded in namespace \"consul-demo\".\n", + }, + helmActionsRunner: &helm.MockActionRunner{ + CheckForInstallationsFunc: func(options *helm.CheckForInstallationsOptions) (bool, string, string, error) { + if options.ReleaseName == "consul" { + return true, "consul", "consul", nil + } else { + return true, "consul-demo", "consul-demo", nil + } + }, + }, + expectedReturnCode: 0, + expectCheckedForConsulInstallations: true, + expectCheckedForConsulDemoInstallations: true, + expectConsulUpgraded: true, + expectConsulDemoUpgraded: true, + expectConsulDemoInstalled: false, + }, + "upgrade when demo upgrade errors returns error with consul being upgraded but demo not being upgraded": { + input: []string{}, + messages: []string{ + "\n==> Checking if Consul can be upgraded\n ✓ Existing Consul installation found to be upgraded.\n Name: consul\n Namespace: consul\n", + "\n==> Checking if Consul demo application can be upgraded\n ✓ Existing Consul demo application installation found to be upgraded.\n Name: consul-demo\n Namespace: consul-demo\n", + "\n==> Consul Upgrade Summary\n ✓ Downloaded charts.\n \n Difference between user overrides for current and upgraded charts\n --------------------------------------------------------------\n + global:\n + name: consul\n \n", + "\n==> Upgrading Consul\n ✓ Consul upgraded in namespace \"consul\".\n", + "\n==> Consul-Demo Upgrade Summary\n ✓ Downloaded charts.\n \n Difference between user overrides for current and upgraded charts\n --------------------------------------------------------------\n \n", + "\n==> Upgrading consul-demo\n ! Helm returned an error.\n", + }, + helmActionsRunner: &helm.MockActionRunner{ + CheckForInstallationsFunc: func(options *helm.CheckForInstallationsOptions) (bool, string, string, error) { + if options.ReleaseName == "consul" { + return true, "consul", "consul", nil + } else { + return true, "consul-demo", "consul-demo", nil + } + }, + UpgradeFunc: func(upgrade *action.Upgrade, name string, chart *chart.Chart, vals map[string]interface{}) (*helmRelease.Release, error) { + if name == "consul" { + return &helmRelease.Release{}, nil + } else { + return nil, errors.New("Helm returned an error.") + } + }, + }, + expectedReturnCode: 1, + expectCheckedForConsulInstallations: true, + expectCheckedForConsulDemoInstallations: true, + expectConsulUpgraded: true, + expectConsulDemoUpgraded: false, + }, + "upgrade with quickstart preset when consul installation exists returns success": { + input: []string{ + "-preset", "quickstart", + }, + messages: []string{ + "\n==> Checking if Consul can be upgraded\n ✓ Existing Consul installation found to be upgraded.\n Name: consul\n Namespace: consul\n", + "\n==> Checking if Consul demo application can be upgraded\n No existing Consul demo application installation found.\n", + "\n==> Consul Upgrade Summary\n ✓ Downloaded charts.\n \n Difference between user overrides for current and upgraded charts\n --------------------------------------------------------------\n + connectInject:\n + enabled: true\n + metrics:\n + defaultEnableMerging: true\n + defaultEnabled: true\n + enableGatewayMetrics: true\n + controller:\n + enabled: true\n + global:\n + metrics:\n + enableAgentMetrics: true\n + enabled: true\n + name: consul\n + prometheus:\n + enabled: true\n + server:\n + replicas: 1\n + ui:\n + enabled: true\n + service:\n + enabled: true\n \n", + "\n==> Upgrading Consul\n ✓ Consul upgraded in namespace \"consul\".\n", + }, + helmActionsRunner: &helm.MockActionRunner{ + CheckForInstallationsFunc: func(options *helm.CheckForInstallationsOptions) (bool, string, string, error) { + if options.ReleaseName == "consul" { + return true, "consul", "consul", nil + } else { + return false, "", "", nil + } + }, + }, + expectedReturnCode: 0, + expectCheckedForConsulInstallations: true, + expectCheckedForConsulDemoInstallations: true, + expectConsulUpgraded: true, + expectConsulDemoUpgraded: false, + }, + "upgrade with secure preset when consul installation exists returns success": { + input: []string{ + "-preset", "secure", + }, + messages: []string{ + "\n==> Checking if Consul can be upgraded\n ✓ Existing Consul installation found to be upgraded.\n Name: consul\n Namespace: consul\n", + "\n==> Checking if Consul demo application can be upgraded\n No existing Consul demo application installation found.\n", + "\n==> Consul Upgrade Summary\n ✓ Downloaded charts.\n \n Difference between user overrides for current and upgraded charts\n --------------------------------------------------------------\n + connectInject:\n + enabled: true\n + controller:\n + enabled: true\n + global:\n + acls:\n + manageSystemACLs: true\n + gossipEncryption:\n + autoGenerate: true\n + name: consul\n + tls:\n + enableAutoEncrypt: true\n + enabled: true\n + server:\n + replicas: 1\n \n", + "\n==> Upgrading Consul\n ✓ Consul upgraded in namespace \"consul\".\n", + }, + helmActionsRunner: &helm.MockActionRunner{ + CheckForInstallationsFunc: func(options *helm.CheckForInstallationsOptions) (bool, string, string, error) { + if options.ReleaseName == "consul" { + return true, "consul", "consul", nil + } else { + return false, "", "", nil + } + }, + }, + expectedReturnCode: 0, + expectCheckedForConsulInstallations: true, + expectCheckedForConsulDemoInstallations: true, + expectConsulUpgraded: true, + expectConsulDemoUpgraded: false, + }, + "upgrade with --dry-run flag when consul installation exists returns success": { + input: []string{ + "--dry-run", + }, + messages: []string{ + " Performing dry run upgrade. No changes will be made to the cluster.\n", + "\n==> Checking if Consul can be upgraded\n ✓ Existing Consul installation found to be upgraded.\n Name: consul\n Namespace: consul\n", + "\n==> Checking if Consul demo application can be upgraded\n No existing Consul demo application installation found.\n", + "\n==> Consul Upgrade Summary\n ✓ Downloaded charts.\n \n Difference between user overrides for current and upgraded charts\n --------------------------------------------------------------\n + global:\n + name: consul\n \n", + "\n==> Performing Dry Run Upgrade\n Dry run complete. No changes were made to the Kubernetes cluster.\n Upgrade can proceed with this configuration.\n", + }, + helmActionsRunner: &helm.MockActionRunner{ + CheckForInstallationsFunc: func(options *helm.CheckForInstallationsOptions) (bool, string, string, error) { + if options.ReleaseName == "consul" { + return true, "consul", "consul", nil + } else { + return false, "", "", nil + } + }, + }, + expectedReturnCode: 0, + expectCheckedForConsulInstallations: true, + expectCheckedForConsulDemoInstallations: true, + expectConsulUpgraded: false, + expectConsulDemoUpgraded: false, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + buf := new(bytes.Buffer) + c := getInitializedCommand(t, buf) + k8s = fake.NewSimpleClientset() + c.kubernetes = k8s + mock := tc.helmActionsRunner + c.helmActionsRunner = mock + if tc.preProcessingFunc != nil { + tc.preProcessingFunc() + } + input := append([]string{ + "--auto-approve", + }, tc.input...) + returnCode := c.Run(input) + require.Equal(t, tc.expectedReturnCode, returnCode) + require.Equal(t, tc.expectCheckedForConsulInstallations, mock.CheckedForConsulInstallations) + require.Equal(t, tc.expectCheckedForConsulDemoInstallations, mock.CheckedForConsulDemoInstallations) + require.Equal(t, tc.expectConsulUpgraded, mock.ConsulUpgraded) + require.Equal(t, tc.expectConsulDemoUpgraded, mock.ConsulDemoUpgraded) + require.Equal(t, tc.expectConsulDemoInstalled, mock.ConsulDemoInstalled) + output := buf.String() + for _, msg := range tc.messages { + require.Contains(t, output, msg) + } + }) + } +} diff --git a/cli/helm/action.go b/cli/helm/action.go index b15029c4f1..d71014c762 100644 --- a/cli/helm/action.go +++ b/cli/helm/action.go @@ -1,6 +1,7 @@ package helm import ( + "embed" "fmt" "os" @@ -26,12 +27,27 @@ func InitActionConfig(actionConfig *action.Configuration, namespace string, sett return actionConfig, nil } +// HelmActionsRunner is a thin interface over existing Helm actions that normally +// require a Kubernetes cluster. This interface allows us to mock it in tests +// and get better coverage of CLI commands. type HelmActionsRunner interface { + // A thin wrapper around the Helm list function. + CheckForInstallations(options *CheckForInstallationsOptions) (bool, string, string, error) + // A thin wrapper around the Helm status function. + GetStatus(status *action.Status, name string) (*release.Release, error) + // A thin wrapper around the Helm install function. Install(install *action.Install, chrt *chart.Chart, vals map[string]interface{}) (*release.Release, error) + // A thin wrapper around the LoadChart function in consul-k8s CLI that reads the charts withing the embedded fle system. + LoadChart(chart embed.FS, chartDirName string) (*chart.Chart, error) + // A thin wrapper around the Helm uninstall function. Uninstall(uninstall *action.Uninstall, name string) (*release.UninstallReleaseResponse, error) - CheckForInstallations(options *CheckForInstallationsOptions) (bool, string, string, error) + // A thin wrapper around the Helm upgrade function. + Upgrade(upgrade *action.Upgrade, name string, chart *chart.Chart, vals map[string]interface{}) (*release.Release, error) } +// ActionRunner is the implementation of HelmActionsRunner interface that +// truly calls Helm sdk functions and requires a real Kubernetes cluster. It +// is the non-mock implementation of HelmActionsRunner that is used in the CLI. type ActionRunner struct{} func (h *ActionRunner) Uninstall(uninstall *action.Uninstall, name string) (*release.UninstallReleaseResponse, error) { @@ -78,3 +94,15 @@ func (h *ActionRunner) CheckForInstallations(options *CheckForInstallationsOptio } return false, "", "", notFoundError } + +func (h *ActionRunner) GetStatus(status *action.Status, name string) (*release.Release, error) { + return status.Run(name) +} + +func (h *ActionRunner) Upgrade(upgrade *action.Upgrade, name string, chart *chart.Chart, vals map[string]interface{}) (*release.Release, error) { + return upgrade.Run(name, chart, vals) +} + +func (h *ActionRunner) LoadChart(chart embed.FS, chartDirName string) (*chart.Chart, error) { + return LoadChart(chart, chartDirName) +} diff --git a/cli/helm/chart.go b/cli/helm/chart.go index 1a91ee19d5..f679ca591d 100644 --- a/cli/helm/chart.go +++ b/cli/helm/chart.go @@ -29,7 +29,7 @@ func LoadChart(chart embed.FS, chartDirName string) (*chart.Chart, error) { // FetchChartValues will attempt to fetch the values from the currently // installed Helm chart. -func FetchChartValues(namespace, name string, settings *helmCLI.EnvSettings, uiLogger action.DebugLog) (map[string]interface{}, error) { +func FetchChartValues(actionRunner HelmActionsRunner, namespace, name string, settings *helmCLI.EnvSettings, uiLogger action.DebugLog) (map[string]interface{}, error) { cfg := new(action.Configuration) cfg, err := InitActionConfig(cfg, namespace, settings, uiLogger) if err != nil { @@ -37,7 +37,7 @@ func FetchChartValues(namespace, name string, settings *helmCLI.EnvSettings, uiL } status := action.NewStatus(cfg) - release, err := status.Run(name) + release, err := actionRunner.GetStatus(status, name) if err != nil { return nil, err } diff --git a/cli/helm/install.go b/cli/helm/install.go new file mode 100644 index 0000000000..1bb5f3c886 --- /dev/null +++ b/cli/helm/install.go @@ -0,0 +1,140 @@ +package helm + +import ( + "embed" + "fmt" + "time" + + "github.com/hashicorp/consul-k8s/cli/common" + "github.com/hashicorp/consul-k8s/cli/common/terminal" + "golang.org/x/text/cases" + "golang.org/x/text/language" + "helm.sh/helm/v3/pkg/action" + helmCLI "helm.sh/helm/v3/pkg/cli" +) + +// InstallOptions is used when calling InstallHelmRelease. +type InstallOptions struct { + // ReleaseName is the name of the Helm release to be installed. + ReleaseName string + // ReleaseType is the helm upgrade type - consul vs consul-demo. + ReleaseType string + // Namespace is the Kubernetes namespace where the release is to be + // installed. + Namespace string + // Values the Helm chart values in a map form. + Values map[string]interface{} + // Settings is the Helm CLI environment settings. + Settings *helmCLI.EnvSettings + // Embedded chart specifies the Consul or Consul Demo Helm chart that has + // been embedded into the consul-k8s CLI. + EmbeddedChart embed.FS + // ChartDirName is the top level directory name fo the EmbeddedChart. + ChartDirName string + // UILogger is a DebugLog used to return messages from Helm to the UI. + UILogger action.DebugLog + // DryRun specifies whether the install/upgrade should actually modify the + // Kubernetes cluster. + DryRun bool + // AutoApprove will bypass any terminal prompts with an automatic yes. + AutoApprove bool + // Wait specifies whether the Helm install should wait until all pods + // are ready. + Wait bool + // Timeout is the duration that Helm will wait for the command to complete + // before it throws an error. + Timeout time.Duration + // UI is the terminal output representation that is used to prompt the user + // and output messages. + UI terminal.UI + // HelmActionsRunner is a thin interface around Helm actions for install, + // upgrade, and uninstall. + HelmActionsRunner HelmActionsRunner +} + +// InstallDemoApp will perform the following actions +// - Print out the installation summary. +// - Setup action configuration for Helm Go SDK function calls. +// - Setup the installation action. +// - Load the Helm chart. +// - Run the install. +func InstallDemoApp(options *InstallOptions) error { + options.UI.Output(fmt.Sprintf("%s Installation Summary", + cases.Title(language.English).String(common.ReleaseTypeConsulDemo)), + terminal.WithHeaderStyle()) + options.UI.Output("Name: %s", common.ConsulDemoAppReleaseName, terminal.WithInfoStyle()) + options.UI.Output("Namespace: %s", options.Settings.Namespace(), terminal.WithInfoStyle()) + options.UI.Output("\n", terminal.WithInfoStyle()) + + err := InstallHelmRelease(options) + if err != nil { + return err + } + + options.UI.Output("Accessing %s UI", cases.Title(language.English).String(common.ReleaseTypeConsulDemo), terminal.WithHeaderStyle()) + port := "8080" + portForwardCmd := fmt.Sprintf("kubectl port-forward deploy/frontend %s:80", port) + if options.Settings.Namespace() != "default" { + portForwardCmd += fmt.Sprintf(" --namespace %s", options.Settings.Namespace()) + } + options.UI.Output(portForwardCmd, terminal.WithInfoStyle()) + options.UI.Output("Browse to http://localhost:%s.", port, terminal.WithInfoStyle()) + return nil +} + +// InstallHelmRelease handles downloading the embedded helm chart, loading the +// values and runnning the Helm install command. +func InstallHelmRelease(options *InstallOptions) error { + if options.DryRun { + return nil + } + + if !options.AutoApprove { + confirmation, err := options.UI.Input(&terminal.Input{ + Prompt: "Proceed with installation? (y/N)", + Style: terminal.InfoStyle, + Secret: false, + }) + + if err != nil { + return err + } + if common.Abort(confirmation) { + options.UI.Output("Install aborted. Use the command `consul-k8s install -help` to learn how to customize your installation.", + terminal.WithInfoStyle()) + return err + } + } + + options.UI.Output("Installing %s", options.ReleaseType, terminal.WithHeaderStyle()) + + // Setup action configuration for Helm Go SDK function calls. + actionConfig := new(action.Configuration) + actionConfig, err := InitActionConfig(actionConfig, options.Namespace, options.Settings, options.UILogger) + if err != nil { + return err + } + + // Setup the installation action. + install := action.NewInstall(actionConfig) + install.ReleaseName = options.ReleaseName + install.Namespace = options.Namespace + install.CreateNamespace = true + install.Wait = options.Wait + install.Timeout = options.Timeout + + // Load the Helm chart. + chart, err := options.HelmActionsRunner.LoadChart(options.EmbeddedChart, options.ChartDirName) + if err != nil { + return err + } + options.UI.Output("Downloaded charts.", terminal.WithSuccessStyle()) + + // Run the install. + if _, err = options.HelmActionsRunner.Install(install, chart, options.Values); err != nil { + return err + } + + options.UI.Output("%s installed in namespace %q.", options.ReleaseType, options.Namespace, terminal.WithSuccessStyle()) + return nil +} diff --git a/cli/helm/install_test.go b/cli/helm/install_test.go new file mode 100644 index 0000000000..2cd98ca5a8 --- /dev/null +++ b/cli/helm/install_test.go @@ -0,0 +1,82 @@ +package helm + +import ( + "bytes" + "context" + "embed" + "errors" + "testing" + + "github.com/hashicorp/consul-k8s/cli/common" + "github.com/hashicorp/consul-k8s/cli/common/terminal" + "github.com/stretchr/testify/require" + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/chart" + helmCLI "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/release" +) + +func TestInstallDemoApp(t *testing.T) { + cases := map[string]struct { + messages []string + helmActionsRunner *MockActionRunner + expectError bool + }{ + "basic success": { + messages: []string{ + "\n==> Consul Demo Application Installation Summary\n Name: consul-demo\n Namespace: default\n \n \n", + "\n==> Installing Consul\n ✓ Downloaded charts.\n ✓ Consul installed in namespace \"consul-namespace\".\n", + "\n==> Accessing Consul Demo Application UI\n kubectl port-forward deploy/frontend 8080:80 --namespace consul-namespace\n Browse to http://localhost:8080.\n", + }, + helmActionsRunner: &MockActionRunner{}, + }, + "failure because LoadChart returns failure": { + messages: []string{ + "\n==> Consul Demo Application Installation Summary\n Name: consul-demo\n Namespace: default\n \n \n\n==> Installing Consul\n", + }, + helmActionsRunner: &MockActionRunner{ + LoadChartFunc: func(chrt embed.FS, chartDirName string) (*chart.Chart, error) { + return nil, errors.New("sad trombone!") + }, + }, + expectError: true, + }, + "failure because Install returns failure": { + messages: []string{ + "\n==> Consul Demo Application Installation Summary\n Name: consul-demo\n Namespace: default\n \n \n\n==> Installing Consul\n", + }, + helmActionsRunner: &MockActionRunner{ + InstallFunc: func(install *action.Install, chrt *chart.Chart, vals map[string]interface{}) (*release.Release, error) { + return nil, errors.New("sad trombone!") + }, + }, + expectError: true, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + buf := new(bytes.Buffer) + mock := tc.helmActionsRunner + options := &InstallOptions{ + HelmActionsRunner: mock, + UI: terminal.NewUI(context.Background(), buf), + UILogger: func(format string, v ...interface{}) {}, + ReleaseName: "consul-release", + ReleaseType: common.ReleaseTypeConsul, + Namespace: "consul-namespace", + Settings: helmCLI.New(), + AutoApprove: true, + } + err := InstallDemoApp(options) + if tc.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + output := buf.String() + for _, msg := range tc.messages { + require.Contains(t, output, msg) + } + }) + } +} diff --git a/cli/helm/mock.go b/cli/helm/mock.go index cee635d47a..05d3b6edb4 100644 --- a/cli/helm/mock.go +++ b/cli/helm/mock.go @@ -1,26 +1,136 @@ package helm import ( + "embed" + + "github.com/hashicorp/consul-k8s/cli/common" + "helm.sh/helm/v3/pkg/action" "helm.sh/helm/v3/pkg/chart" "helm.sh/helm/v3/pkg/release" ) type MockActionRunner struct { - CheckForInstallationsReponse func(options *CheckForInstallationsOptions) (bool, string, string, error) + CheckForInstallationsFunc func(options *CheckForInstallationsOptions) (bool, string, string, error) + GetStatusFunc func(status *action.Status, name string) (*release.Release, error) + InstallFunc func(install *action.Install, chrt *chart.Chart, vals map[string]interface{}) (*release.Release, error) + LoadChartFunc func(chrt embed.FS, chartDirName string) (*chart.Chart, error) + UninstallFunc func(uninstall *action.Uninstall, name string) (*release.UninstallReleaseResponse, error) + UpgradeFunc func(upgrade *action.Upgrade, name string, chart *chart.Chart, vals map[string]interface{}) (*release.Release, error) + CheckedForConsulInstallations bool + CheckedForConsulDemoInstallations bool + GotStatusConsulRelease bool + GotStatusConsulDemoRelease bool + ConsulInstalled bool + ConsulUninstalled bool + ConsulUpgraded bool + ConsulDemoInstalled bool + ConsulDemoUninstalled bool + ConsulDemoUpgraded bool } func (m *MockActionRunner) Install(install *action.Install, chrt *chart.Chart, vals map[string]interface{}) (*release.Release, error) { - return &release.Release{}, nil + var installFunc func(install *action.Install, chrt *chart.Chart, vals map[string]interface{}) (*release.Release, error) + if m.InstallFunc == nil { + installFunc = func(install *action.Install, chrt *chart.Chart, vals map[string]interface{}) (*release.Release, error) { + return &release.Release{}, nil + } + } else { + installFunc = m.InstallFunc + } + + release, err := installFunc(install, chrt, vals) + if err == nil { + if install.ReleaseName == common.DefaultReleaseName { + m.ConsulInstalled = true + } else if install.ReleaseName == common.ConsulDemoAppReleaseName { + m.ConsulDemoInstalled = true + } + } + return release, err } func (m *MockActionRunner) Uninstall(uninstall *action.Uninstall, name string) (*release.UninstallReleaseResponse, error) { - return &release.UninstallReleaseResponse{}, nil + var uninstallFunc func(uninstall *action.Uninstall, name string) (*release.UninstallReleaseResponse, error) + + if m.UninstallFunc == nil { + uninstallFunc = func(uninstall *action.Uninstall, name string) (*release.UninstallReleaseResponse, error) { + return &release.UninstallReleaseResponse{}, nil + } + } else { + uninstallFunc = m.UninstallFunc + } + + release, err := uninstallFunc(uninstall, name) + if err == nil { + if name == common.DefaultReleaseName { + m.ConsulUninstalled = true + } else if name == common.ConsulDemoAppReleaseName { + m.ConsulDemoUninstalled = true + } + } + return release, err } -func (h *MockActionRunner) CheckForInstallations(options *CheckForInstallationsOptions) (bool, string, string, error) { - if h.CheckForInstallationsReponse == nil { +func (m *MockActionRunner) CheckForInstallations(options *CheckForInstallationsOptions) (bool, string, string, error) { + if options.ReleaseName == common.DefaultReleaseName { + m.CheckedForConsulInstallations = true + } else if options.ReleaseName == common.ConsulDemoAppReleaseName { + m.CheckedForConsulDemoInstallations = true + } + + if m.CheckForInstallationsFunc == nil { return false, "", "", nil } - return h.CheckForInstallationsReponse(options) + return m.CheckForInstallationsFunc(options) +} + +func (m *MockActionRunner) GetStatus(status *action.Status, name string) (*release.Release, error) { + if name == common.DefaultReleaseName { + m.GotStatusConsulRelease = true + } else if name == common.ConsulDemoAppReleaseName { + m.GotStatusConsulDemoRelease = true + } + + if m.GetStatusFunc == nil { + return &release.Release{}, nil + } + return m.GetStatusFunc(status, name) +} + +func (m *MockActionRunner) Upgrade(upgrade *action.Upgrade, name string, chrt *chart.Chart, vals map[string]interface{}) (*release.Release, error) { + var upgradeFunc func(upgrade *action.Upgrade, name string, chrt *chart.Chart, vals map[string]interface{}) (*release.Release, error) + + if m.UpgradeFunc == nil { + upgradeFunc = func(upgrade *action.Upgrade, name string, chrt *chart.Chart, vals map[string]interface{}) (*release.Release, error) { + return &release.Release{}, nil + } + } else { + upgradeFunc = m.UpgradeFunc + } + + release, err := upgradeFunc(upgrade, name, chrt, vals) + if err == nil { + if name == common.DefaultReleaseName { + m.ConsulUpgraded = true + } else if name == common.ConsulDemoAppReleaseName { + m.ConsulDemoUpgraded = true + } + } + return release, err +} + +func (m *MockActionRunner) LoadChart(chrt embed.FS, chartDirName string) (*chart.Chart, error) { + var loadChartFunc func(chrt embed.FS, chartDirName string) (*chart.Chart, error) + + if m.LoadChartFunc == nil { + loadChartFunc = func(chrt embed.FS, chartDirName string) (*chart.Chart, error) { + return &chart.Chart{}, nil + } + } else { + loadChartFunc = m.LoadChartFunc + } + + release, err := loadChartFunc(chrt, chartDirName) + return release, err } diff --git a/cli/helm/upgrade.go b/cli/helm/upgrade.go new file mode 100644 index 0000000000..d2b8523c5f --- /dev/null +++ b/cli/helm/upgrade.go @@ -0,0 +1,149 @@ +package helm + +import ( + "embed" + "strings" + "time" + + "github.com/hashicorp/consul-k8s/cli/common" + "github.com/hashicorp/consul-k8s/cli/common/terminal" + "golang.org/x/text/cases" + "golang.org/x/text/language" + "helm.sh/helm/v3/pkg/action" + helmCLI "helm.sh/helm/v3/pkg/cli" +) + +// UpgradeOptions is used when calling UpgradeHelmRelease. +type UpgradeOptions struct { + // ReleaseName is the name of the installed Helm release to upgrade. + ReleaseName string + // ReleaseType is the helm upgrade type - consul vs consul-demo. + ReleaseType string + // ReleaseTypeName is a user friendly version of ReleaseType. The values + // are consul and consul demo application. + ReleaseTypeName string + // Namespace is the Kubernetes namespace where the release is installed. + Namespace string + // Values the Helm chart values in a map form. + Values map[string]interface{} + // Settings is the Helm CLI environment settings. + Settings *helmCLI.EnvSettings + // Embedded chart specifies the Consul or Consul Demo Helm chart that has + // been embedded into the consul-k8s CLI. + EmbeddedChart embed.FS + // ChartDirName is the top level directory name fo the EmbeddedChart. + ChartDirName string + // UILogger is a DebugLog used to return messages from Helm to the UI. + UILogger action.DebugLog + // DryRun specifies whether the upgrade should actually modify the + // Kubernetes cluster. + DryRun bool + // AutoApprove will bypass any terminal prompts with an automatic yes. + AutoApprove bool + // Wait specifies whether the Helm install should wait until all pods + // are ready. + Wait bool + // Timeout is the duration that Helm will wait for the command to complete + // before it throws an error. + Timeout time.Duration + // UI is the terminal output representation that is used to prompt the user + // and output messages. + UI terminal.UI + // HelmActionsRunner is a thin interface around Helm actions for install, + // upgrade, and uninstall. + HelmActionsRunner HelmActionsRunner +} + +// UpgradeHelmRelease handles downloading the embedded helm chart, loading the +// values, showing the diff between new and installed values, and runnning the +// Helm install command. +func UpgradeHelmRelease(options *UpgradeOptions) error { + options.UI.Output("%s Upgrade Summary", cases.Title(language.English).String(options.ReleaseTypeName), terminal.WithHeaderStyle()) + + chart, err := options.HelmActionsRunner.LoadChart(options.EmbeddedChart, options.ChartDirName) + if err != nil { + return err + } + options.UI.Output("Downloaded charts.", terminal.WithSuccessStyle()) + + currentChartValues, err := FetchChartValues(options.HelmActionsRunner, + options.Namespace, options.ReleaseName, options.Settings, options.UILogger) + if err != nil { + return err + } + + // Print out the upgrade summary. + if err = printDiff(currentChartValues, options.Values, options.UI); err != nil { + options.UI.Output("Could not print the different between current and upgraded charts: %v", err, terminal.WithErrorStyle()) + return err + } + + // Check if the user is OK with the upgrade unless the auto approve or dry run flags are true. + if !options.AutoApprove && !options.DryRun { + confirmation, err := options.UI.Input(&terminal.Input{ + Prompt: "Proceed with upgrade? (y/N)", + Style: terminal.InfoStyle, + Secret: false, + }) + + if err != nil { + return err + } + if common.Abort(confirmation) { + options.UI.Output("Upgrade aborted. Use the command `consul-k8s upgrade -help` to learn how to customize your upgrade.", + terminal.WithInfoStyle()) + return err + } + } + + if !options.DryRun { + options.UI.Output("Upgrading %s", options.ReleaseTypeName, terminal.WithHeaderStyle()) + } else { + options.UI.Output("Performing Dry Run Upgrade", terminal.WithHeaderStyle()) + return nil + } + + // Setup action configuration for Helm Go SDK function calls. + actionConfig := new(action.Configuration) + actionConfig, err = InitActionConfig(actionConfig, options.Namespace, options.Settings, options.UILogger) + if err != nil { + return err + } + + // Setup the upgrade action. + upgrade := action.NewUpgrade(actionConfig) + upgrade.Namespace = options.Namespace + upgrade.DryRun = options.DryRun + upgrade.Wait = options.Wait + upgrade.Timeout = options.Timeout + + // Run the upgrade. Note that the dry run config is passed into the upgrade action, so upgrade.Run is called even during a dry run. + _, err = options.HelmActionsRunner.Upgrade(upgrade, options.ReleaseName, chart, options.Values) + if err != nil { + return err + } + options.UI.Output("%s upgraded in namespace %q.", cases.Title(language.English).String(options.ReleaseTypeName), options.Namespace, terminal.WithSuccessStyle()) + return nil +} + +// printDiff marshals both maps to YAML and prints the diff between the two. +func printDiff(old, new map[string]interface{}, ui terminal.UI) error { + diff, err := common.Diff(old, new) + if err != nil { + return err + } + + ui.Output("\nDifference between user overrides for current and upgraded charts"+ + "\n--------------------------------------------------------------", terminal.WithInfoStyle()) + for _, line := range strings.Split(diff, "\n") { + if strings.HasPrefix(line, "+") { + ui.Output(line, terminal.WithDiffAddedStyle()) + } else if strings.HasPrefix(line, "-") { + ui.Output(line, terminal.WithDiffRemovedStyle()) + } else { + ui.Output(line, terminal.WithDiffUnchangedStyle()) + } + } + + return nil +} diff --git a/cli/helm/upgrade_test.go b/cli/helm/upgrade_test.go new file mode 100644 index 0000000000..9ffb7dc201 --- /dev/null +++ b/cli/helm/upgrade_test.go @@ -0,0 +1,117 @@ +package helm + +import ( + "bytes" + "context" + "embed" + "errors" + "testing" + + "github.com/hashicorp/consul-k8s/cli/common" + "github.com/hashicorp/consul-k8s/cli/common/terminal" + "github.com/stretchr/testify/require" + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/chart" + helmCLI "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/release" +) + +func TestUpgrade(t *testing.T) { + buf := new(bytes.Buffer) + mock := &MockActionRunner{ + CheckForInstallationsFunc: func(options *CheckForInstallationsOptions) (bool, string, string, error) { + if options.ReleaseName == "consul" { + return false, "", "", nil + } else { + return true, "consul-demo", "consul-demo", nil + } + }, + } + + options := &UpgradeOptions{ + HelmActionsRunner: mock, + UI: terminal.NewUI(context.Background(), buf), + UILogger: func(format string, v ...interface{}) {}, + ReleaseName: "consul-release", + ReleaseType: common.ReleaseTypeConsul, + Namespace: "consul-namespace", + Settings: helmCLI.New(), + AutoApprove: true, + } + + expectedMessages := []string{ + "\n==> Upgrade Summary\n ✓ Downloaded charts.\n \n Difference between user overrides for current and upgraded charts\n --------------------------------------------------------------\n \n", + "\n==> Upgrading \n ✓ upgraded in namespace \"consul-namespace\".\n", + } + err := UpgradeHelmRelease(options) + require.NoError(t, err) + output := buf.String() + for _, msg := range expectedMessages { + require.Contains(t, output, msg) + } +} + +func TestUpgradeHelmRelease(t *testing.T) { + cases := map[string]struct { + messages []string + helmActionsRunner *MockActionRunner + expectError bool + }{ + "basic success": { + messages: []string{ + "\n==> Consul Upgrade Summary\n ✓ Downloaded charts.\n \n Difference between user overrides for current and upgraded charts\n --------------------------------------------------------------\n \n", + "\n==> Upgrading Consul\n ✓ Consul upgraded in namespace \"consul-namespace\".\n", + }, + helmActionsRunner: &MockActionRunner{}, + }, + "failure because LoadChart returns failure": { + messages: []string{ + "\n==> Consul Upgrade Summary\n", + }, + helmActionsRunner: &MockActionRunner{ + LoadChartFunc: func(chrt embed.FS, chartDirName string) (*chart.Chart, error) { + return nil, errors.New("sad trombone!") + }, + }, + expectError: true, + }, + "failure because Upgrade returns failure": { + messages: []string{ + "\n==> Consul Upgrade Summary\n", + }, + helmActionsRunner: &MockActionRunner{ + UpgradeFunc: func(upgrade *action.Upgrade, name string, chart *chart.Chart, vals map[string]interface{}) (*release.Release, error) { + return nil, errors.New("sad trombone!") + }, + }, + expectError: true, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + buf := new(bytes.Buffer) + mock := tc.helmActionsRunner + options := &UpgradeOptions{ + HelmActionsRunner: mock, + UI: terminal.NewUI(context.Background(), buf), + UILogger: func(format string, v ...interface{}) {}, + ReleaseName: "consul-release", + ReleaseType: common.ReleaseTypeConsul, + ReleaseTypeName: common.ReleaseTypeConsul, + Namespace: "consul-namespace", + Settings: helmCLI.New(), + AutoApprove: true, + } + err := UpgradeHelmRelease(options) + if tc.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + output := buf.String() + for _, msg := range tc.messages { + require.Contains(t, output, msg) + } + }) + } +}