feat: add step continue-in-error support

This commit is contained in:
Marc 2024-08-18 01:28:20 -04:00
parent c876477891
commit 76bd6eece1
Signed by: marc
GPG key ID: 048E042F22B5DC79
7 changed files with 144 additions and 14 deletions

View file

@ -43,7 +43,7 @@ syntax](https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for
- [ ] jobs.<job_id>.steps[*].shell
- [ ] jobs.<job_id>.steps[*].with
- [ ] jobs.<job_id>.steps[*].env
- [ ] jobs.<job_id>.steps[*].continue-on-error
- [X] jobs.<job_id>.steps[*].continue-on-error
- [ ] jobs.<job_id>.steps[*].timeout-minutes
## Behaviours

View file

@ -13,6 +13,7 @@ import (
type Logger struct {
Info log.Logger
Error log.Logger
Warn log.Logger
}
var Log Logger
@ -23,6 +24,7 @@ var Log Logger
func ConfigureLogger() {
Log = Logger{
Info: *log.New(os.Stdout, "[INFO] ", log.Ldate|log.Ltime),
Warn: *log.New(os.Stdout, "[WARN] ", log.Ldate|log.Ltime),
Error: *log.New(os.Stderr, "[ERROR] ", log.Ldate|log.Ltime),
}
}
@ -42,3 +44,11 @@ func Error(message string, args ...any) {
Log.Error.Printf(message, args...)
}
}
func Warn(message string, args ...any) {
if len(args) == 0 {
Log.Warn.Print(message)
} else {
Log.Warn.Printf(message, args...)
}
}

View file

@ -1,25 +1,26 @@
package runner
import (
"strings"
)
type MockCall struct {
fname string
args []string
}
type MockDriver struct {
calls map[string][]MockCall
mockResult *CommandResult
calls map[string][]MockCall
mockedCalls map[string]map[string]CommandResult
}
func NewMockDriver() MockDriver {
return MockDriver{
calls: map[string][]MockCall{},
calls: map[string][]MockCall{},
mockedCalls: map[string]map[string]CommandResult{},
}
}
func (d *MockDriver) WithCommandResult(c *CommandResult) {
d.mockResult = c
}
func (d *MockDriver) Pull(uri string) error {
if _, init := d.calls["Pull"]; !init {
d.calls["Pull"] = []MockCall{}
@ -53,9 +54,22 @@ func (d *MockDriver) Exec(containerName string, command string, cwd string) Comm
}
d.calls["Exec"] = append(d.calls["Exec"], MockCall{fname: "Exec", args: []string{containerName, command, cwd}})
if d.mockResult != nil {
return *d.mockResult
if _, mocked := d.mockedCalls["Exec"][strings.Join([]string{containerName, command, cwd}, " ")]; mocked {
return d.mockedCalls["Exec"][strings.Join([]string{containerName, command, cwd}, " ")]
}
return CommandResult{}
}
// Mocks a call to <fn> with arguments <args> to return <returnValue>.
//
// The mocked call is reused as long as it's defined within the mock driver.
func (d *MockDriver) WithMockedCall(fn string, returnValue CommandResult, args ...string) {
mockKey := strings.Join(args, " ")
if _, initialized := d.mockedCalls[fn]; !initialized {
d.mockedCalls[fn] = map[string]CommandResult{}
}
d.mockedCalls[fn][mockKey] = returnValue
}

View file

@ -130,8 +130,10 @@ func (r *Runner) RunJobInContainer(imageUri string, containerId string, jobConte
stepError = r.RunCommandInContainer(containerId, step.Run, stepCwd)
}
if stepError != nil {
if stepError != nil && !step.ContinueOnError {
return stepError
} else if stepError != nil {
logger.Warn("Step errored by continued: %s", stepError)
}
}

View file

@ -14,7 +14,7 @@ func init() {
func TestRunnerRunCommandInContainerReturnsErrorFromDriver(t *testing.T) {
mockDriver := NewMockDriver()
mockDriver.WithCommandResult(&CommandResult{ExitCode: 0, Error: errors.New("test")})
mockDriver.WithMockedCall("Exec", CommandResult{ExitCode: 0, Error: errors.New("test")}, "test-container", "test-command", ".")
runner := Runner{
Driver: &mockDriver,
@ -29,7 +29,7 @@ func TestRunnerRunCommandInContainerReturnsErrorFromDriver(t *testing.T) {
func TestRunnerRunCommandInContainerReturnsErrorIfCommandExitCodeNonzero(t *testing.T) {
mockDriver := NewMockDriver()
mockDriver.WithCommandResult(&CommandResult{ExitCode: 1, Error: nil})
mockDriver.WithMockedCall("Exec", CommandResult{ExitCode: 1, Error: nil}, "test-container", "test-command", ".")
runner := Runner{
Driver: &mockDriver,
@ -44,7 +44,7 @@ func TestRunnerRunCommandInContainerReturnsErrorIfCommandExitCodeNonzero(t *test
func TestRunJobInContainerSchedulesStoppingContainers(t *testing.T) {
mockDriver := NewMockDriver()
mockDriver.WithCommandResult(&CommandResult{ExitCode: 1, Error: nil})
mockDriver.WithMockedCall("Exec", CommandResult{ExitCode: 1, Error: nil}, "test-container", "test-command", ".")
runner := NewRunner(&mockDriver, map[string]string{})

View file

@ -0,0 +1,103 @@
package runner
import (
"context"
workflow "courgette/internal/workflow"
"errors"
"testing"
)
func TestJobStepSkipsErrorIfContinueOnErrorTruthy(t *testing.T) {
workflowSample := `
jobs:
jobA:
runs-on: test
steps:
- run: exit 1
continue-on-error: true
- run: echo "Continued!"
`
workflow, _ := workflow.FromYamlBytes([]byte(workflowSample))
mockDriver := NewMockDriver()
mockDriver.WithMockedCall("Exec", CommandResult{Error: errors.New("exit 1!"), ExitCode: 1}, "testContainer", "exit 1")
runner := NewRunner(&mockDriver, map[string]string{"test": "test"})
jobContext := context.WithValue(context.Background(), "workflow", *workflow)
jobContext = context.WithValue(jobContext, "currentJob", workflow.Jobs["jobA"])
runErr := runner.RunJobInContainer("testUri", "testContainer", jobContext)
if runErr != nil {
t.Errorf("Did not expect error, got %+v", runErr)
}
execCallCount := len(mockDriver.calls["Exec"])
if execCallCount != 2 {
t.Errorf("Expected 2 calls to Exec, got %d", execCallCount)
}
}
func TestJobStepExitsOnErrorIfContinueOnErrorFalsy(t *testing.T) {
workflowSample := `
jobs:
jobA:
runs-on: test
steps:
- run: exit 1
continue-on-error: false
- run: echo "Continued!"
`
workflow, _ := workflow.FromYamlBytes([]byte(workflowSample))
mockDriver := NewMockDriver()
mockDriver.WithMockedCall("Exec", CommandResult{Error: errors.New("exit 1!"), ExitCode: 1}, "testContainer", "exit 1", ".")
runner := NewRunner(&mockDriver, map[string]string{"test": "test"})
jobContext := context.WithValue(context.Background(), "workflow", *workflow)
jobContext = context.WithValue(jobContext, "currentJob", workflow.Jobs["jobA"])
runErr := runner.RunJobInContainer("testUri", "testContainer", jobContext)
if runErr == nil {
t.Error("Expected error, got nil")
}
execCallCount := len(mockDriver.calls["Exec"])
if execCallCount != 1 {
t.Errorf("Expected 1 calls to Exec, got %d", execCallCount)
}
}
func TestJobStepExitsOnErrorIfContinueOnErrorUndefined(t *testing.T) {
workflowSample := `
jobs:
jobA:
runs-on: test
steps:
- run: exit 1
- run: echo "Continued!"
`
workflow, _ := workflow.FromYamlBytes([]byte(workflowSample))
mockDriver := NewMockDriver()
mockDriver.WithMockedCall("Exec", CommandResult{Error: errors.New("exit 1!"), ExitCode: 1}, "testContainer", "exit 1", ".")
runner := NewRunner(&mockDriver, map[string]string{"test": "test"})
jobContext := context.WithValue(context.Background(), "workflow", *workflow)
jobContext = context.WithValue(jobContext, "currentJob", workflow.Jobs["jobA"])
runErr := runner.RunJobInContainer("testUri", "testContainer", jobContext)
if runErr == nil {
t.Error("Expect error, got nil")
}
execCallCount := len(mockDriver.calls["Exec"])
if execCallCount != 1 {
t.Errorf("Expected 1 calls to Exec, got %d", execCallCount)
}
}

View file

@ -38,6 +38,7 @@ func (j Job) Validate() []error {
type Step struct {
Run string `yaml:"run"`
WorkingDirectory string `yaml:"working-directory"`
ContinueOnError bool `yaml:"continue-on-error"`
}
func (s Step) Validate() []error {