Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add config read command #2078

Merged
merged 7 commits into from
May 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changelog/2078.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:improvement
cli: Add `consul-k8s config read` command that returns the helm configuration in yaml format.
```
26 changes: 26 additions & 0 deletions cli/cmd/config/command.go
Original file line number Diff line number Diff line change
@@ -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 <subcommand>", c.Synopsis())
}

func (c *ConfigCommand) Synopsis() string {
return "Operate on configuration"
}
199 changes: 199 additions & 0 deletions cli/cmd/config/read/command.go
Original file line number Diff line number Diff line change
@@ -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."
}
149 changes: 149 additions & 0 deletions cli/cmd/config/read/command_test.go
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we have tests where this is used? It feels like this was set up with an idea of looking for some errors, so it would be great to see some added. but if not, this could jut be removed.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could be worth validating the output in the "some config" case, and make sure you see the config you're setting

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jmurret added!
@ndhanushkodi That was an oversight on my part, I added the actual config check.

require.Contains(t, output, msg)
}
})
}
}

func TestTaskCreateCommand_AutocompleteFlags(t *testing.T) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious, are changes in this PR to prediction behavior? It feels like prediction tests should not he to go into ever command if predictions are global and the command does not do anything new.

(not looking for a code change, but just curious if these are here because other commands had them)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah most of the other commands have this boilerplate in it. I think it could be fine to keep or remove in this PR, but probably we should have a separate task to pull out the autocomplete code elsewhere

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
}
Loading