diff --git a/.changelog/2078.txt b/.changelog/2078.txt new file mode 100644 index 0000000000..2206de1128 --- /dev/null +++ b/.changelog/2078.txt @@ -0,0 +1,3 @@ +```release-note:improvement +cli: Add `consul-k8s config read` command that returns the helm configuration in yaml format. +``` diff --git a/cli/cmd/config/command.go b/cli/cmd/config/command.go new file mode 100644 index 0000000000..5e44677ff6 --- /dev/null +++ b/cli/cmd/config/command.go @@ -0,0 +1,26 @@ +package config + +import ( + "fmt" + + "github.com/hashicorp/consul-k8s/cli/common" + "github.com/mitchellh/cli" +) + +// ConfigCommand provides a synopsis for the config subcommands (e.g. read). +type ConfigCommand struct { + *common.BaseCommand +} + +// Run prints out information about the subcommands. +func (c *ConfigCommand) Run([]string) int { + return cli.RunResultHelp +} + +func (c *ConfigCommand) Help() string { + return fmt.Sprintf("%s\n\nUsage: consul-k8s config ", c.Synopsis()) +} + +func (c *ConfigCommand) Synopsis() string { + return "Operate on configuration" +} diff --git a/cli/cmd/config/read/command.go b/cli/cmd/config/read/command.go new file mode 100644 index 0000000000..e2258bd013 --- /dev/null +++ b/cli/cmd/config/read/command.go @@ -0,0 +1,199 @@ +package read + +import ( + "errors" + "fmt" + "sync" + + "github.com/posener/complete" + + "github.com/hashicorp/consul-k8s/cli/common" + "github.com/hashicorp/consul-k8s/cli/common/flag" + "github.com/hashicorp/consul-k8s/cli/common/terminal" + "github.com/hashicorp/consul-k8s/cli/helm" + "helm.sh/helm/v3/pkg/action" + helmCLI "helm.sh/helm/v3/pkg/cli" + "k8s.io/client-go/kubernetes" + "sigs.k8s.io/yaml" +) + +const ( + flagNameKubeConfig = "kubeconfig" + flagNameKubeContext = "context" +) + +type ReadCommand struct { + *common.BaseCommand + + helmActionsRunner helm.HelmActionsRunner + + kubernetes kubernetes.Interface + + set *flag.Sets + + flagKubeConfig string + flagKubeContext string + + once sync.Once + help string +} + +func (c *ReadCommand) init() { + c.set = flag.NewSets() + + f := c.set.NewSet("Global Options") + f.StringVar(&flag.StringVar{ + Name: "kubeconfig", + Aliases: []string{"c"}, + Target: &c.flagKubeConfig, + Default: "", + Usage: "Path to kubeconfig file.", + }) + f.StringVar(&flag.StringVar{ + Name: "context", + Target: &c.flagKubeContext, + Default: "", + Usage: "Kubernetes context to use.", + }) + + c.help = c.set.Help() +} + +// Run checks the status of a Consul installation on Kubernetes. +func (c *ReadCommand) Run(args []string) int { + c.once.Do(c.init) + if c.helmActionsRunner == nil { + c.helmActionsRunner = &helm.ActionRunner{} + } + + c.Log.ResetNamed("config read") + defer common.CloseWithError(c.BaseCommand) + + if err := c.set.Parse(args); err != nil { + c.UI.Output(err.Error()) + return 1 + } + + if err := c.validateFlags(); err != nil { + c.UI.Output(err.Error()) + return 1 + } + + // helmCLI.New() will create a settings object which is used by the Helm Go SDK calls. + settings := helmCLI.New() + if c.flagKubeConfig != "" { + settings.KubeConfig = c.flagKubeConfig + } + if c.flagKubeContext != "" { + settings.KubeContext = c.flagKubeContext + } + + if err := c.setupKubeClient(settings); err != nil { + c.UI.Output(err.Error(), terminal.WithErrorStyle()) + return 1 + } + + // Setup logger to stream Helm library logs. + var uiLogger = func(s string, args ...interface{}) { + logMsg := fmt.Sprintf(s, args...) + c.UI.Output(logMsg, terminal.WithLibraryStyle()) + } + + _, releaseName, namespace, err := c.helmActionsRunner.CheckForInstallations(&helm.CheckForInstallationsOptions{ + Settings: settings, + ReleaseName: common.DefaultReleaseName, + DebugLog: uiLogger, + }) + if err != nil { + c.UI.Output(err.Error(), terminal.WithErrorStyle()) + return 1 + } + + if err := c.checkHelmInstallation(settings, uiLogger, releaseName, namespace); err != nil { + c.UI.Output(err.Error(), terminal.WithErrorStyle()) + return 1 + } + + return 0 +} + +// validateFlags checks the command line flags and values for errors. +func (c *ReadCommand) validateFlags() error { + if len(c.set.Args()) > 0 { + return errors.New("should have no non-flag arguments") + } + return nil +} + +// AutocompleteFlags returns a mapping of supported flags and autocomplete +// options for this command. The map key for the Flags map should be the +// complete flag such as "-foo" or "--foo". +func (c *ReadCommand) AutocompleteFlags() complete.Flags { + return complete.Flags{ + fmt.Sprintf("-%s", flagNameKubeConfig): complete.PredictFiles("*"), + fmt.Sprintf("-%s", flagNameKubeContext): complete.PredictNothing, + } +} + +// AutocompleteArgs returns the argument predictor for this command. +// Since argument completion is not supported, this will return +// complete.PredictNothing. +func (c *ReadCommand) AutocompleteArgs() complete.Predictor { + return complete.PredictNothing +} + +// checkHelmInstallation uses the helm Go SDK to depict the status of a named release. This function then prints +// the version of the release, it's status (unknown, deployed, uninstalled, ...), and the overwritten values. +func (c *ReadCommand) checkHelmInstallation(settings *helmCLI.EnvSettings, uiLogger action.DebugLog, releaseName, namespace string) error { + // Need a specific action config to call helm status, where namespace comes from the previous call to list. + statusConfig := new(action.Configuration) + statusConfig, err := helm.InitActionConfig(statusConfig, namespace, settings, uiLogger) + if err != nil { + return err + } + + statuser := action.NewStatus(statusConfig) + rel, err := c.helmActionsRunner.GetStatus(statuser, releaseName) + if err != nil { + return fmt.Errorf("couldn't check for installations: %s", err) + } + + valuesYaml, err := yaml.Marshal(rel.Config) + if err != nil { + return err + } + c.UI.Output(string(valuesYaml)) + + return nil +} + +// setupKubeClient to use for non Helm SDK calls to the Kubernetes API The Helm SDK will use +// settings.RESTClientGetter for its calls as well, so this will use a consistent method to +// target the right cluster for both Helm SDK and non Helm SDK calls. +func (c *ReadCommand) setupKubeClient(settings *helmCLI.EnvSettings) error { + if c.kubernetes == nil { + restConfig, err := settings.RESTClientGetter().ToRESTConfig() + if err != nil { + c.UI.Output("Error retrieving Kubernetes authentication: %v", err, terminal.WithErrorStyle()) + return err + } + c.kubernetes, err = kubernetes.NewForConfig(restConfig) + if err != nil { + c.UI.Output("Error initializing Kubernetes client: %v", err, terminal.WithErrorStyle()) + return err + } + } + + return nil +} + +// Help returns a description of the command and how it is used. +func (c *ReadCommand) Help() string { + c.once.Do(c.init) + return c.Synopsis() + "\n\nUsage: consul-k8s config read [flags]\n\n" + c.help +} + +// Synopsis returns a one-line command summary. +func (c *ReadCommand) Synopsis() string { + return "Returns the helm config of a Consul installation on Kubernetes." +} diff --git a/cli/cmd/config/read/command_test.go b/cli/cmd/config/read/command_test.go new file mode 100644 index 0000000000..a3716cf3c1 --- /dev/null +++ b/cli/cmd/config/read/command_test.go @@ -0,0 +1,149 @@ +package read + +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/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" + helmTime "helm.sh/helm/v3/pkg/time" + "k8s.io/client-go/kubernetes/fake" +) + +func TestConfigRead(t *testing.T) { + nowTime := helmTime.Now() + cases := map[string]struct { + messages []string + helmActionsRunner *helm.MockActionRunner + expectedReturnCode int + }{ + "empty config": { + messages: []string{"\n"}, + + helmActionsRunner: &helm.MockActionRunner{ + GetStatusFunc: func(status *action.Status, name string) (*helmRelease.Release, error) { + return &helmRelease.Release{ + Name: "consul", Namespace: "consul", + Info: &helmRelease.Info{LastDeployed: nowTime, Status: "READY"}, + Chart: &chart.Chart{Metadata: &chart.Metadata{Version: "1.0.0"}}, + Config: make(map[string]interface{})}, nil + }, + }, + expectedReturnCode: 0, + }, + "error": { + messages: []string{"error", "\n"}, + + helmActionsRunner: &helm.MockActionRunner{ + GetStatusFunc: func(status *action.Status, name string) (*helmRelease.Release, error) { + return nil, errors.New("error") + }, + }, + expectedReturnCode: 1, + }, + "some config": { + messages: []string{"global: \"true\"", "\n"}, + + helmActionsRunner: &helm.MockActionRunner{ + GetStatusFunc: func(status *action.Status, name string) (*helmRelease.Release, error) { + return &helmRelease.Release{ + Name: "consul", Namespace: "consul", + Info: &helmRelease.Info{LastDeployed: nowTime, Status: "READY"}, + Chart: &chart.Chart{ + Metadata: &chart.Metadata{ + Version: "1.0.0", + }, + }, + Config: map[string]interface{}{"global": "true"}, + }, nil + }, + }, + expectedReturnCode: 0, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + buf := new(bytes.Buffer) + c := getInitializedCommand(t, buf) + c.kubernetes = fake.NewSimpleClientset() + c.helmActionsRunner = tc.helmActionsRunner + returnCode := c.Run([]string{}) + require.Equal(t, tc.expectedReturnCode, returnCode) + output := buf.String() + for _, msg := range tc.messages { + require.Contains(t, output, msg) + } + }) + } +} + +func TestTaskCreateCommand_AutocompleteFlags(t *testing.T) { + t.Parallel() + cmd := getInitializedCommand(t, nil) + + predictor := cmd.AutocompleteFlags() + + // Test that we get the expected number of predictions + args := complete.Args{Last: "-"} + res := predictor.Predict(args) + + // Grab the list of flags from the Flag object + flags := make([]string, 0) + cmd.set.VisitSets(func(name string, set *cmnFlag.Set) { + set.VisitAll(func(flag *flag.Flag) { + flags = append(flags, fmt.Sprintf("-%s", flag.Name)) + }) + }) + + // Verify that there is a prediction for each flag associated with the command + assert.Equal(t, len(flags), len(res)) + assert.ElementsMatch(t, flags, res, "flags and predictions didn't match, make sure to add "+ + "new flags to the command AutoCompleteFlags function") +} + +func TestTaskCreateCommand_AutocompleteArgs(t *testing.T) { + cmd := getInitializedCommand(t, nil) + c := cmd.AutocompleteArgs() + assert.Equal(t, complete.PredictNothing, c) +} + +// getInitializedCommand sets up a command struct for tests. +func getInitializedCommand(t *testing.T, buf io.Writer) *ReadCommand { + 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 := &ReadCommand{ + BaseCommand: baseCommand, + } + c.init() + return c +} diff --git a/cli/commands.go b/cli/commands.go index fe4c47400e..fa8e320451 100644 --- a/cli/commands.go +++ b/cli/commands.go @@ -3,6 +3,8 @@ package main import ( "context" + "github.com/hashicorp/consul-k8s/cli/cmd/config" + config_read "github.com/hashicorp/consul-k8s/cli/cmd/config/read" "github.com/hashicorp/consul-k8s/cli/cmd/install" "github.com/hashicorp/consul-k8s/cli/cmd/proxy" "github.com/hashicorp/consul-k8s/cli/cmd/proxy/list" @@ -76,6 +78,16 @@ func initializeCommands(ctx context.Context, log hclog.Logger) (*common.BaseComm BaseCommand: baseCommand, }, nil }, + "config": func() (cli.Command, error) { + return &config.ConfigCommand{ + BaseCommand: baseCommand, + }, nil + }, + "config read": func() (cli.Command, error) { + return &config_read.ReadCommand{ + BaseCommand: baseCommand, + }, nil + }, "troubleshoot": func() (cli.Command, error) { return &troubleshoot.TroubleshootCommand{ BaseCommand: baseCommand,