Skip to content

Commit

Permalink
Allow mixins to ignore a failed command (getporter#1846)
Browse files Browse the repository at this point in the history
* sync go mod

Signed-off-by: Carolyn Van Slyck <[email protected]>

* Allow mixins to ignore a failed command

Add the HasErrorHandling interface to the shared pkg/exec/builder
package used by all the mixins. When implemented, a mixin can examine a
failed command and optionaly handle it.

The exec mixin now supports ignoring failed commands, which demonstrates
how to use builder.IgnoreErrorHandler struct to use the same error
handling behavior.

Closes getporter#592

Signed-off-by: Carolyn Van Slyck <[email protected]>
  • Loading branch information
carolynvs authored Jan 7, 2022
1 parent bef31c1 commit 151d9b9
Show file tree
Hide file tree
Showing 11 changed files with 403 additions and 22 deletions.
46 changes: 35 additions & 11 deletions docs/content/mixins/exec.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,26 +19,36 @@ porter mixin install exec
```yaml
exec:
description: "Description of the command"
command: cmd
arguments:
command: cmd # The command to run, must be on the PATH
arguments: # arguments to pass to the command
- arg1
- arg2
flags:
flags: # flags to pass to the command, porter determines if it is a long (--flag) or short flag (-f)
a: flag-value
long-flag: true
repeated-flag:
repeated-flag: # Use an array if a flag must be specified multiple times with different values
- flag-value1
- flag-value2
suffix-arguments:
suffix-arguments: # These arguments are specified after any flags are passed
- suffix-arg1
suppress-output: false
outputs:
suppress-output: false # Do not print the command output to the console
ignoreError: # Conditions when execution should continue even if the command fails
all: true # Ignore all errors
exitCodes: # Ignore failed commands that return the following exit codes
- 1
- 2
output: # Ignore failed commands based on the contents of stderr
contains: # Ignore when stderr contains a substring
- "SUBSTRING IN STDERR"
regex: # Ignore when stderr matches a regular expression
- "GOLANG_REGULAR_EXPRESSION"
outputs: # Collect values from the command and make it available as an output
- name: NAME
jsonPath: JSONPATH
jsonPath: JSONPATH # Scrape stdout with a json path expression
- name: NAME
regex: GOLANG_REGULAR_EXPRESSION
regex: GOLANG_REGULAR_EXPRESSION # Scrape stdout with a regular expression
- name: NAME
path: FILEPATH
path: FILEPATH # Save the contents of a file
```
This is executed as:
Expand All @@ -50,14 +60,28 @@ $ cmd arg1 arg2 -a flag-value --long-flag true --repeated-flag flag-value1 --rep
### Suppress Output

The `suppress-output` field controls whether output from the mixin should be
prevented from printing to the console. By default this value is false, using
prevented from printing to the console. By default, this value is false, using
Porter's default behavior of hiding known sensitive values. When
`suppress-output: true` all output from the mixin (stderr and stdout) are hidden.

Step outputs (below) are still collected when output is suppressed. This allows
you to prevent sensitive data from being exposed while still collecting it from
a command and using it in your bundle.

### Ignore Error

In some cases, you may need to have the bundle continue executing when a mixin command fails.
For example when the command fails because the resource already exists.

You can ignore errors based on:

* All - Ignore all errors from the command.
* ExitCodes - Ignore errors when one of the specified exit codes are returned.
* Output Contains - Ignore errors when the command's stderr contains the specified string.
* Output Regex - Ignore errors when the command's stderr matches the specified regular expression (in Go syntax).

Porter only prints out that an error was ignored in debug mode.

### Outputs

The mixin supports outputs of various types:
Expand Down
2 changes: 2 additions & 0 deletions pkg/context/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ func NewTestCommand(c *Context) CommandBuilder {
cmd.Env = []string{
fmt.Sprintf("%s=true", test.MockedCommandEnv),
fmt.Sprintf("%s=%s", test.ExpectedCommandEnv, c.Getenv(test.ExpectedCommandEnv)),
fmt.Sprintf("%s=%s", test.ExpectedCommandExitCodeEnv, c.Getenv(test.ExpectedCommandExitCodeEnv)),
fmt.Sprintf("%s=%s", test.ExpectedCommandErrorEnv, c.Getenv(test.ExpectedCommandErrorEnv)),
}

return cmd
Expand Down
3 changes: 3 additions & 0 deletions pkg/exec/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ type Instruction struct {
Flags builder.Flags `yaml:"flags,omitempty"`
Outputs []Output `yaml:"outputs,omitempty"`
SuppressOutput bool `yaml:"suppress-output,omitempty"`

// Allow the user to ignore some errors
builder.IgnoreErrorHandler `yaml:"ignoreError,omitempty"`
}

func (s Step) GetCommand() string {
Expand Down
93 changes: 93 additions & 0 deletions pkg/exec/builder/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package builder

import (
"fmt"
"regexp"
"strings"

portercontext "get.porter.sh/porter/pkg/context"
)

var _ HasErrorHandling = IgnoreErrorHandler{}

// IgnoreErrorHandler implements HasErrorHandling for the exec mixin
// and can be used by any other mixin to get the same error handling behavior.
type IgnoreErrorHandler struct {
// All ignores any error that happens when the command is run.
All bool `yaml:"all,omitempty"`

// ExitCodes ignores any exit codes in the list.
ExitCodes []int `yaml:"exitCodes,omitempty"`

// Output determines if the error should be ignored based on the command
// output.
Output IgnoreErrorWithOutput `yaml:"output,omitempty"`
}

type IgnoreErrorWithOutput struct {
// Contains specifies that the error is ignored when stderr contains the
// specified substring.
Contains []string `yaml:"contains,omitempty"`

// Regex specifies that the error is ignored when stderr matches the
// specified regular expression.
Regex []string `yaml:"regex,omitempty"`
}

func (h IgnoreErrorHandler) HandleError(cxt *portercontext.Context, err ExitError, stdout string, stderr string) error {
// We shouldn't be called when there is no error but just in case, let's check
if err == nil || err.ExitCode() == 0 {
return nil
}

if cxt.Debug {
fmt.Fprintf(cxt.Err, "Evaluating mixin command error %s with the mixin's error handler\n", err.Error())
}

// Check if the command should always be allowed to "pass"
if h.All {
if cxt.Debug {
fmt.Fprintln(cxt.Err, "Ignoring mixin command error because All was specified in the mixin step definition")
}
return nil
}

// Check if the exit code was allowed
exitCode := err.ExitCode()
for _, code := range h.ExitCodes {
if exitCode == code {
if cxt.Debug {
fmt.Fprintf(cxt.Err, "Ignoring mixin command error (exit code: %d) because it was included in the allowed ExitCodes list defined in the mixin step definition\n", exitCode)
}
return nil
}
}

// Check if the output contains a hint that it should be allowed to pass
for _, allowError := range h.Output.Contains {
if strings.Contains(stderr, allowError) {
if cxt.Debug {
fmt.Fprintf(cxt.Err, "Ignoring mixin command error because the error contained the substring %q defined in the mixin step definition\n", allowError)
}
return nil
}
}

// Check if the output matches an allowed regular expression
for _, allowMatch := range h.Output.Regex {
expression, regexErr := regexp.Compile(allowMatch)
if regexErr != nil {
fmt.Fprintf(cxt.Err, "Could not ignore failed command because the Regex specified by the mixin step definition (%q) is invalid:%s\n", allowMatch, regexErr.Error())
return err
}

if expression.MatchString(stderr) {
if cxt.Debug {
fmt.Fprintf(cxt.Err, "Ignoring mixin command error because the error matched the Regex %q defined in the mixin step definition\n", allowMatch)
}
return nil
}
}

return err
}
93 changes: 93 additions & 0 deletions pkg/exec/builder/errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package builder

import (
"testing"

"get.porter.sh/porter/pkg/context"
"github.com/stretchr/testify/require"
)

var _ ExitError = TestExitError{}

type TestExitError struct {
exitCode int
}

func (t TestExitError) Error() string {
return "an error occurred"
}

func (t TestExitError) ExitCode() int {
return t.exitCode
}

func TestIgnoreErrorHandler_HandleError(t *testing.T) {
t.Run("no error", func(t *testing.T) {
cxt := context.NewTestContext(t)
h := IgnoreErrorHandler{}
err := h.HandleError(cxt.Context, nil, "", "")
require.NoError(t, err)
})

t.Run("error - passthrough", func(t *testing.T) {
cxt := context.NewTestContext(t)
h := IgnoreErrorHandler{}
origErr := &TestExitError{1}
err := h.HandleError(cxt.Context, origErr, "", "")
require.Same(t, origErr, err)
})

t.Run("all", func(t *testing.T) {
cxt := context.NewTestContext(t)
h := IgnoreErrorHandler{All: true}
err := h.HandleError(cxt.Context, TestExitError{1}, "", "")
require.NoError(t, err)
})

t.Run("allowed exit code", func(t *testing.T) {
cxt := context.NewTestContext(t)
h := IgnoreErrorHandler{ExitCodes: []int{2, 1, 4}}
err := h.HandleError(cxt.Context, TestExitError{1}, "", "")
require.NoError(t, err)
})

t.Run("disallowed exit code", func(t *testing.T) {
cxt := context.NewTestContext(t)
h := IgnoreErrorHandler{ExitCodes: []int{2, 1, 4}}
origErr := &TestExitError{10}
err := h.HandleError(cxt.Context, origErr, "", "")
require.Same(t, origErr, err, "The original error should be preserved")
})

t.Run("stderr contains", func(t *testing.T) {
cxt := context.NewTestContext(t)
h := IgnoreErrorHandler{Output: IgnoreErrorWithOutput{Contains: []string{"already exists"}}}
origErr := &TestExitError{10}
err := h.HandleError(cxt.Context, origErr, "", "The specified thing already exists")
require.NoError(t, err)
})

t.Run("stderr does not contain", func(t *testing.T) {
cxt := context.NewTestContext(t)
h := IgnoreErrorHandler{Output: IgnoreErrorWithOutput{Contains: []string{"already exists"}}}
origErr := &TestExitError{10}
err := h.HandleError(cxt.Context, origErr, "", "Something went wrong")
require.Same(t, origErr, err)
})

t.Run("stderr matches regex", func(t *testing.T) {
cxt := context.NewTestContext(t)
h := IgnoreErrorHandler{Output: IgnoreErrorWithOutput{Regex: []string{"(exists|EXISTS)"}}}
origErr := &TestExitError{10}
err := h.HandleError(cxt.Context, origErr, "", "something EXISTS")
require.NoError(t, err)
})

t.Run("stderr does not match regex", func(t *testing.T) {
cxt := context.NewTestContext(t)
h := IgnoreErrorHandler{Output: IgnoreErrorWithOutput{Regex: []string{"(exists|EXISTS)"}}}
origErr := &TestExitError{10}
err := h.HandleError(cxt.Context, origErr, "", "something mumble mumble")
require.Same(t, origErr, err)
})
}
39 changes: 32 additions & 7 deletions pkg/exec/builder/execute.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import (
"bytes"
"fmt"
"io"
"io/ioutil"
"os/exec"
"strings"

"get.porter.sh/porter/pkg/context"
Expand Down Expand Up @@ -46,6 +46,18 @@ type SuppressesOutput interface {
SuppressesOutput() bool
}

// HasErrorHandling is implemented by mixin commands that want to handle errors
// themselves, and possibly allow failed commands to either pass, or to improve
// the displayed error message
type HasErrorHandling interface {
HandleError(cxt *context.Context, err ExitError, stdout string, stderr string) error
}

type ExitError interface {
error
ExitCode() int
}

// ExecuteSingleStepAction runs the command represented by an ExecutableAction, where only
// a single step is allowed to be defined in the Action (which is what happens when Porter
// executes steps one at a time).
Expand Down Expand Up @@ -125,21 +137,23 @@ func ExecuteStep(cxt *context.Context, step ExecutableStep) (string, error) {

// Setup output streams for command
// If Step suppresses output, update streams accordingly
output := &bytes.Buffer{}
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
suppressOutput := false
if suppressable, ok := step.(SuppressesOutput); ok {
suppressOutput = suppressable.SuppressesOutput()
}

if suppressOutput {
cmd.Stdout = io.MultiWriter(ioutil.Discard, output)
cmd.Stderr = ioutil.Discard
// We still capture the output, but we won't print it
cmd.Stdout = stdout
cmd.Stderr = stderr
if cxt.Debug {
fmt.Fprintf(cxt.Err, "DEBUG: output suppressed for command %s\n", prettyCmd)
}
} else {
cmd.Stdout = io.MultiWriter(cxt.Out, output)
cmd.Stderr = cxt.Err
cmd.Stdout = io.MultiWriter(cxt.Out, stdout)
cmd.Stderr = io.MultiWriter(cxt.Err, stderr)
if cxt.Debug {
fmt.Fprintln(cxt.Err, prettyCmd)
}
Expand All @@ -151,11 +165,22 @@ func ExecuteStep(cxt *context.Context, step ExecutableStep) (string, error) {
}

err = cmd.Wait()

// Check if the command knows how to handle and recover from its own errors
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
if handler, ok := step.(HasErrorHandling); ok {
err = handler.HandleError(cxt, exitErr, stdout.String(), stderr.String())
}
}
}

// Ok, now check if we still have a problem
if err != nil {
return "", errors.Wrap(err, fmt.Sprintf("error running command %s", prettyCmd))
}

return output.String(), nil
return stdout.String(), nil
}

var whitespace = string([]rune{space, newline, tab})
Expand Down
Loading

0 comments on commit 151d9b9

Please sign in to comment.