From 76bd6eece1c721f7938bbff3766312b58de6232f Mon Sep 17 00:00:00 2001 From: Marc Cataford Date: Sun, 18 Aug 2024 01:28:20 -0400 Subject: [PATCH] feat: add step continue-in-error support --- WORKFLOW_SUPPORT.md | 2 +- internal/logging/logger.go | 10 +++ internal/runner/mock_container_driver.go | 32 +++++-- internal/runner/runner.go | 4 +- internal/runner/runner_flow_test.go | 6 +- internal/runner/runner_job_test.go | 103 +++++++++++++++++++++++ internal/workflow/models.go | 1 + 7 files changed, 144 insertions(+), 14 deletions(-) create mode 100644 internal/runner/runner_job_test.go diff --git a/WORKFLOW_SUPPORT.md b/WORKFLOW_SUPPORT.md index 4a6e295..68c97af 100644 --- a/WORKFLOW_SUPPORT.md +++ b/WORKFLOW_SUPPORT.md @@ -43,7 +43,7 @@ syntax](https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for - [ ] jobs..steps[*].shell - [ ] jobs..steps[*].with - [ ] jobs..steps[*].env - - [ ] jobs..steps[*].continue-on-error + - [X] jobs..steps[*].continue-on-error - [ ] jobs..steps[*].timeout-minutes ## Behaviours diff --git a/internal/logging/logger.go b/internal/logging/logger.go index 724f906..dab832b 100644 --- a/internal/logging/logger.go +++ b/internal/logging/logger.go @@ -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...) + } +} diff --git a/internal/runner/mock_container_driver.go b/internal/runner/mock_container_driver.go index 30a7337..aeb6133 100644 --- a/internal/runner/mock_container_driver.go +++ b/internal/runner/mock_container_driver.go @@ -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 with arguments to return . +// +// 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 +} diff --git a/internal/runner/runner.go b/internal/runner/runner.go index cf36cb6..da2b987 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -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) } } diff --git a/internal/runner/runner_flow_test.go b/internal/runner/runner_flow_test.go index 164d585..189d357 100644 --- a/internal/runner/runner_flow_test.go +++ b/internal/runner/runner_flow_test.go @@ -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{}) diff --git a/internal/runner/runner_job_test.go b/internal/runner/runner_job_test.go new file mode 100644 index 0000000..1c376e5 --- /dev/null +++ b/internal/runner/runner_job_test.go @@ -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) + } +} diff --git a/internal/workflow/models.go b/internal/workflow/models.go index 00950eb..133deef 100644 --- a/internal/workflow/models.go +++ b/internal/workflow/models.go @@ -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 {