diff --git a/docs/cmd_reference.md b/docs/cmd_reference.md index d72571da..6660398c 100644 --- a/docs/cmd_reference.md +++ b/docs/cmd_reference.md @@ -48,7 +48,10 @@ This lists available CMD options in Helmsman: keep releases that are managed by Helmsman from the used DSFs in the command, and are no longer tracked in your desired state. `--kubeconfig` - path to the kubeconfig file to use for CLI requests. + path to the kubeconfig file to use for CLI requests. Defalts to false if the helm diff plugin is installed. + + `--kubectl-diff` + Use kubectl diff instead of helm diff `--migrate-context` Updates the context name for all apps defined in the DSF and applies Helmsman labels. Using this flag is required if you want to change context name after it has been set. diff --git a/internal/app/cli.go b/internal/app/cli.go index c947670c..61ce5233 100644 --- a/internal/app/cli.go +++ b/internal/app/cli.go @@ -67,6 +67,7 @@ type cli struct { parallel int alwaysUpgrade bool noUpdate bool + kubectlDiff bool } func printUsage() { @@ -107,13 +108,14 @@ func (c *cli) parse() { flag.BoolVar(&c.substEnvValues, "subst-env-values", false, "turn on environment substitution in values files.") flag.BoolVar(&c.noSSMSubst, "no-ssm-subst", false, "turn off SSM parameter substitution globally") flag.BoolVar(&c.substSSMValues, "subst-ssm-values", false, "turn on SSM parameter substitution in values files.") - flag.BoolVar(&c.updateDeps, "update-deps", false, "run 'helm dep up' for local chart") + flag.BoolVar(&c.updateDeps, "update-deps", false, "run 'helm dep up' for local charts") flag.BoolVar(&c.forceUpgrades, "force-upgrades", false, "use --force when upgrading helm releases. May cause resources to be recreated.") flag.BoolVar(&c.renameReplace, "replace-on-rename", false, "Uninstall the existing release when a chart with a different name is used.") flag.BoolVar(&c.noCleanup, "no-cleanup", false, "keeps any credentials files that has been downloaded on the host where helmsman runs.") flag.BoolVar(&c.migrateContext, "migrate-context", false, "updates the context name for all apps defined in the DSF and applies Helmsman labels. Using this flag is required if you want to change context name after it has been set.") flag.BoolVar(&c.alwaysUpgrade, "always-upgrade", false, "upgrade release even if no changes are found") flag.BoolVar(&c.noUpdate, "no-update", false, "skip updating helm repos") + flag.BoolVar(&c.kubectlDiff, "kubectl-diff", false, "use kubectl diff instead of helm diff. Defalts to false if the helm diff plugin is installed.") flag.Usage = printUsage flag.Parse() @@ -174,8 +176,9 @@ func (c *cli) parse() { log.Fatal("" + helmBin + " is not installed/configured correctly. Aborting!") } - if !helmPluginExists("diff") { - log.Fatal("helm diff plugin is not installed/configured correctly. Aborting!") + if !c.kubectlDiff && !helmPluginExists("diff") { + c.kubectlDiff = true + log.Warning("helm diff not found, using kubectl diff") } if !c.noEnvSubst { diff --git a/internal/app/command.go b/internal/app/command.go index 7116b440..fd1db320 100644 --- a/internal/app/command.go +++ b/internal/app/command.go @@ -10,13 +10,16 @@ import ( ) // Command type representing all executable commands Helmsman needs -// to execute in order to inspect the environment/ releases/ charts etc. +// to execute in order to inspect the environment|releases|charts etc. type Command struct { Cmd string Args []string Description string } +// CmdPipe is a os/exec.Commnad wrapper for UNIX pipe +type CmdPipe []Command + type ExitStatus struct { code int errors string @@ -56,20 +59,7 @@ func (c *Command) RetryExec(attempts int) (ExitStatus, error) { return result, fmt.Errorf("%s, failed after %d attempts with: %w", c.Description, attempts, err) } -func (c *Command) newExitError(code int, stdout, stderr bytes.Buffer, cause error) error { - return fmt.Errorf( - "%s failed with non-zero exit code %d: %w\noutput: %s", - c.Description, code, cause, - fmt.Sprintf( - "\n--- stdout ---\n%s\n--- stderr ---\n%s", - strings.TrimSpace(stdout.String()), - strings.TrimSpace(stderr.String()), - ), - ) -} - -// Exec executes the executable command and returns the exit code and execution result -func (c *Command) Exec() (ExitStatus, error) { +func (c *Command) command() *exec.Cmd { // Only use non-empty string args var args []string @@ -82,8 +72,13 @@ func (c *Command) Exec() (ExitStatus, error) { log.Verbose(c.Description) log.Debug(c.String()) - cmd := exec.Command(c.Cmd, args...) + return exec.Command(c.Cmd, args...) +} + +// Exec executes the executable command and returns the exit code and execution result +func (c *Command) Exec() (ExitStatus, error) { var stdout, stderr bytes.Buffer + cmd := c.command() cmd.Stdout = &stdout cmd.Stderr = &stderr @@ -105,11 +100,107 @@ func (c *Command) Exec() (ExitStatus, error) { if exiterr, ok := err.(*exec.ExitError); ok { res.code = exiterr.ExitCode() } - err = c.newExitError(res.code, stdout, stderr, err) + err = newExitError(c.Description, res.code, res.output, res.errors, err) } return res, err } +// Exec pipes the executable commands and returns the exit code and execution result +func (p CmdPipe) Exec() (ExitStatus, error) { + var ( + stdout, stderr bytes.Buffer + stack []*exec.Cmd + ) + + l := len(p) - 1 + if l < 0 { + // nonthing to do here + return ExitStatus{}, nil + } + if l == 0 { + // it's just one command we can just run it + return p[0].Exec() + } + + for i, c := range p { + stack = append(stack, c.command()) + stack[i].Stderr = &stderr + if i > 0 { + stack[i].Stdin, _ = stack[i-1].StdoutPipe() + } + } + stack[l].Stdout = &stdout + + err := call(stack) + res := ExitStatus{ + output: strings.TrimSpace(stdout.String()), + errors: strings.TrimSpace(stderr.String()), + } + if err != nil { + res.code = 1 + if exiterr, ok := err.(*exec.ExitError); ok { + res.code = exiterr.ExitCode() + } + err = newExitError(p[l].Description, res.code, res.output, res.errors, err) + } + return res, err +} + +// RetryExec runs piped commands with retry +func (p CmdPipe) RetryExec(attempts int) (ExitStatus, error) { + var ( + result ExitStatus + err error + ) + + l := len(p) - 1 + for i := 0; i < attempts; i++ { + result, err = p.Exec() + if err == nil { + return result, nil + } + if i < (attempts - 1) { + time.Sleep(time.Duration(math.Pow(2, float64(2+i))) * time.Second) + log.Infof("Retrying %s due to error: %v", p[l].Description, err) + } + } + + return result, fmt.Errorf("%s, failed after %d attempts with: %w", p[l].Description, attempts, err) +} + +func call(stack []*exec.Cmd) (err error) { + if stack[0].Process == nil { + if err = stack[0].Start(); err != nil { + return err + } + } + if len(stack) > 1 { + if err = stack[1].Start(); err != nil { + return err + } + defer func() { + if err == nil { + err = call(stack[1:]) + } else { + stack[1].Wait() + } + }() + } + return stack[0].Wait() +} + +func newExitError(cmd string, code int, stdout, stderr string, cause error) error { + return fmt.Errorf( + "%s failed with non-zero exit code %d: %w\noutput: %s", + cmd, code, cause, + fmt.Sprintf( + "\n--- stdout ---\n%s\n--- stderr ---\n%s", + strings.TrimSpace(stdout), + strings.TrimSpace(stderr), + ), + ) +} + // ToolExists returns true if the tool is present in the environment and false otherwise. // It takes as input the tool's command to check if it is recognizable or not. e.g. helm or kubectl func ToolExists(tool string) bool { diff --git a/internal/app/command_test.go b/internal/app/command_test.go index 295a74b4..7c9e596d 100644 --- a/internal/app/command_test.go +++ b/internal/app/command_test.go @@ -1,6 +1,7 @@ package app import ( + "fmt" "testing" ) @@ -92,14 +93,124 @@ func TestCommandExec(t *testing.T) { Description: tt.input.desc, } got, err := c.Exec() - if err != tt.want.err { - t.Errorf("command.exec() unexpected error got = %v, want %v", err, tt.want.err) + if err != nil && tt.want.err == nil { + t.Errorf("command.exec() unexpected error got:\n%v want:\n%v", err, tt.want.err) + } + if err != nil && tt.want.err != nil { + if err.Error() != tt.want.err.Error() { + t.Errorf("command.exec() unexpected error got:\n%v want:\n%v", err, tt.want.err) + } + } + if got.code != tt.want.code { + t.Errorf("command.exec() unexpected code got = %v, want = %v", got.code, tt.want.code) + } + if got.output != tt.want.output { + t.Errorf("command.exec() unexpected output got:\n%v want:\n%v", got.output, tt.want.output) + } + }) + } +} + +func TestPipeExec(t *testing.T) { + type expected struct { + code int + err error + output string + } + tests := []struct { + name string + input CmdPipe + want expected + }{ + { + name: "echo", + input: CmdPipe{ + Command{ + Cmd: "echo", + Args: []string{"-e", `first string\nsecond string\nthird string`}, + Description: "muliline echo", + }, + }, + want: expected{ + code: 0, + output: "first string\nsecond string\nthird string", + err: nil, + }, + }, { + name: "line count", + input: CmdPipe{ + Command{ + Cmd: "echo", + Args: []string{"-e", `first string\nsecond string\nthird string`}, + Description: "muliline echo", + }, + Command{ + Cmd: "wc", + Args: []string{"-l"}, + Description: "line count", + }, + }, + want: expected{ + code: 0, + output: "3", + err: nil, + }, + }, { + name: "grep", + input: CmdPipe{ + Command{ + Cmd: "echo", + Args: []string{"-e", `first string\nsecond string\nthird string`}, + Description: "muliline echo", + }, + Command{ + Cmd: "grep", + Args: []string{"second"}, + Description: "grep", + }, + }, + want: expected{ + code: 0, + output: "second string", + err: nil, + }, + }, { + name: "grep no matches", + input: CmdPipe{ + Command{ + Cmd: "echo", + Args: []string{"-e", `first string\nsecond string\nthird string`}, + Description: "muliline echo", + }, + Command{ + Cmd: "grep", + Args: []string{"fourth"}, + Description: "grep", + }, + }, + want: expected{ + code: 1, + output: "", + err: newExitError("grep", 1, "", "", fmt.Errorf("exit status 1")), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.input.Exec() + if err != nil && tt.want.err == nil { + t.Errorf("command.exec() unexpected error got:\n%v want:\n%v", err, tt.want.err) + } + if err != nil && tt.want.err != nil { + if err.Error() != tt.want.err.Error() { + t.Errorf("command.exec() unexpected error got:\n%v want:\n%v", err, tt.want.err) + } } if got.code != tt.want.code { - t.Errorf("command.exec() unexpected code got = %v, want %v", got.code, tt.want.code) + t.Errorf("command.exec() unexpected code got = %v, want = %v", got.code, tt.want.code) } if got.output != tt.want.output { - t.Errorf("command.exec() unexpected output got = %v, want %v", got.output, tt.want.output) + t.Errorf("command.exec() unexpected output got:\n%v want:\n%v", got.output, tt.want.output) } }) } diff --git a/internal/app/release.go b/internal/app/release.go index 552ddcbc..eeeaa424 100644 --- a/internal/app/release.go +++ b/internal/app/release.go @@ -165,20 +165,37 @@ func (r *release) uninstall(p *plan, optionalNamespace ...string) { // diffRelease diffs an existing release with the specified values.yaml func (r *release) diff() (string, error) { - colorFlag := "" - diffContextFlag := []string{} - suppressDiffSecretsFlag := "--suppress-secrets" - if flags.noColors { - colorFlag = "--no-color" - } - if flags.diffContext != -1 { - diffContextFlag = []string{"--context", strconv.Itoa(flags.diffContext)} + var args []string + + if !flags.kubectlDiff { + args = []string{"diff", "--suppress-secrets"} + if flags.noColors { + args = append(args, "--no-color") + } + if flags.diffContext != -1 { + args = append(args, "--context", strconv.Itoa(flags.diffContext)) + } + args = concat(args, r.getHelmArgsFor("diff")) + } else { + args = r.getHelmArgsFor("template") } - cmd := helmCmd(concat([]string{"diff", colorFlag, suppressDiffSecretsFlag}, diffContextFlag, r.getHelmArgsFor("diff")), "Diffing release [ "+r.Name+" ] in namespace [ "+r.Namespace+" ]") + desc := "Diffing release [ " + r.Name + " ] in namespace [ " + r.Namespace + " ]" + cmd := CmdPipe{helmCmd(args, desc)} + + if flags.kubectlDiff { + cmd = append(cmd, kubectl([]string{"diff", "--namespace", r.Namespace, "-f", "-"}, desc)) + } res, err := cmd.RetryExec(3) if err != nil { + if flags.kubectlDiff && res.code <= 1 { + // kubectl diff exit status: + // 0 No differences were found. + // 1 Differences were found. + // >1 Kubectl or diff failed with an error. + return res.output, nil + } return "", fmt.Errorf("command failed: %w", err) } @@ -385,6 +402,8 @@ func (r *release) getHelmArgsFor(action string, optionalNamespaceOverride ...str ns = optionalNamespaceOverride[0] } switch action { + case "template": + return concat([]string{"template", r.Name, r.Chart, "--version", r.Version, "--namespace", r.Namespace, "--skip-tests", "--no-hooks"}, r.getValuesFiles(), r.getSetValues(), r.getSetStringValues(), r.getSetFileValues(), r.getPostRenderer()) case "install", "upgrade": return concat([]string{"upgrade", r.Name, r.Chart, "--install", "--version", r.Version, "--namespace", r.Namespace}, r.getValuesFiles(), r.getSetValues(), r.getSetStringValues(), r.getSetFileValues(), r.getHelmFlags(), r.getPostRenderer()) case "diff":