Skip to content

Commit

Permalink
Merge pull request #609 from Praqma/kubectl-diff
Browse files Browse the repository at this point in the history
feat: allow using kubectl diff instead of helm diff
  • Loading branch information
luisdavim authored May 19, 2021
2 parents b05d8e7 + 07a1b8b commit 5f66bc6
Show file tree
Hide file tree
Showing 5 changed files with 261 additions and 34 deletions.
5 changes: 4 additions & 1 deletion docs/cmd_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 6 additions & 3 deletions internal/app/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ type cli struct {
parallel int
alwaysUpgrade bool
noUpdate bool
kubectlDiff bool
}

func printUsage() {
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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 {
Expand Down
125 changes: 108 additions & 17 deletions internal/app/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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 {
Expand Down
119 changes: 115 additions & 4 deletions internal/app/command_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package app

import (
"fmt"
"testing"
)

Expand Down Expand Up @@ -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)
}
})
}
Expand Down
Loading

0 comments on commit 5f66bc6

Please sign in to comment.