feat: support for cascading workflow/job/step env

This commit is contained in:
Marc 2024-08-20 23:35:49 -04:00
parent 3b70f47676
commit f6a93c0b55
Signed by: marc
GPG key ID: 048E042F22B5DC79
9 changed files with 199 additions and 26 deletions

View file

@ -9,7 +9,7 @@ syntax](https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for
- [ ] run-name - [ ] run-name
- [ ] on - [ ] on
- [ ] permissions - [ ] permissions
- [ ] env - [x] env
- [ ] defaults - [ ] defaults
- [x] jobs - [x] jobs
- [x] jobs.<job_id>.name - [x] jobs.<job_id>.name
@ -20,7 +20,7 @@ syntax](https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for
- [ ] jobs.<job_id>.environment - [ ] jobs.<job_id>.environment
- [ ] jobs.<job_id>.concurrency - [ ] jobs.<job_id>.concurrency
- [ ] jobs.<job_id>.outputs - [ ] jobs.<job_id>.outputs
- [ ] jobs.<job_id>.env - [x] jobs.<job_id>.env
- [x] jobs.<job_id>.defaults - [x] jobs.<job_id>.defaults
- [ ] jobs.<job_id>.run.shell - [ ] jobs.<job_id>.run.shell
- [x] jobs.<job_id>.run.working-directory - [x] jobs.<job_id>.run.working-directory
@ -42,7 +42,7 @@ syntax](https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for
- [x] jobs.<job_id>.steps[*].working-directory - [x] jobs.<job_id>.steps[*].working-directory
- [ ] jobs.<job_id>.steps[*].shell - [ ] jobs.<job_id>.steps[*].shell
- [ ] jobs.<job_id>.steps[*].with - [ ] jobs.<job_id>.steps[*].with
- [ ] jobs.<job_id>.steps[*].env - [x] jobs.<job_id>.steps[*].env
- [X] jobs.<job_id>.steps[*].continue-on-error - [X] jobs.<job_id>.steps[*].continue-on-error
- [ ] jobs.<job_id>.steps[*].timeout-minutes - [ ] jobs.<job_id>.steps[*].timeout-minutes

View file

@ -8,7 +8,7 @@ type ContainerDriver interface {
Pull(string) error Pull(string) error
Start(string, string) error Start(string, string) error
Stop(string) error Stop(string) error
Exec(containerId string, command string, cwd string) CommandResult Exec(containerId string, command string, cwd string, env map[string]string) CommandResult
} }
// Represents the outcome of a command call made by the driver. // Represents the outcome of a command call made by the driver.

View file

@ -49,12 +49,12 @@ func (d *MockDriver) Stop(uri string) error {
} }
func (d *MockDriver) Exec(containerName string, command string, cwd string) CommandResult { func (d *MockDriver) Exec(containerName string, command string, cwd string, env map[string]string) CommandResult {
if _, init := d.calls["Exec"]; !init { if _, init := d.calls["Exec"]; !init {
d.calls["Exec"] = []MockCall{} d.calls["Exec"] = []MockCall{}
} }
args := []string{containerName, command, cwd} args := []string{containerName, command, cwd, fmt.Sprintf("%#v", env)}
d.calls["Exec"] = append(d.calls["Exec"], MockCall{fname: "Exec", args: args}) d.calls["Exec"] = append(d.calls["Exec"], MockCall{fname: "Exec", args: args})
mockKeys := []string{ mockKeys := []string{

View file

@ -79,13 +79,34 @@ func (d PodmanDriver) Stop(containerName string) error {
return nil return nil
} }
func (d PodmanDriver) Exec(containerId string, command string, cwd string) CommandResult { func (d PodmanDriver) Exec(containerId string, command string, cwd string, env map[string]string) CommandResult {
cmd := exec.Command("podman", "exec", "--workdir", cwd, containerId, "bash", "-c", command) envArgs := []string{}
for key, value := range env {
envArgs = append(envArgs, fmt.Sprintf("-e=%s=%s", key, value))
}
commandArgs := []string{
"exec",
"--workdir",
cwd,
}
commandArgs = append(commandArgs, envArgs...)
commandArgs = append(commandArgs, containerId,
"bash",
"-c",
command,
)
cmd := exec.Command("podman", commandArgs...)
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
err := cmd.Run() err := cmd.Run()
fmt.Printf("%#v", cmd)
return CommandResult{ return CommandResult{
Error: err, Error: err,
ExitCode: cmd.ProcessState.ExitCode(), ExitCode: cmd.ProcessState.ExitCode(),

View file

@ -103,8 +103,8 @@ func (r Runner) runJob(jobContext context.Context, jobTracker *TaskTracker, jobW
// //
// If the command raises an error while in the container or fails to run // If the command raises an error while in the container or fails to run
// the command at all, an error is returned, otherwise nil. // the command at all, an error is returned, otherwise nil.
func (r *Runner) RunCommandInContainer(containerId string, command string, stepCwd string) error { func (r *Runner) RunCommandInContainer(containerId string, command string, stepCwd string, stepEnv map[string]string) error {
result := r.Driver.Exec(containerId, command, stepCwd) result := r.Driver.Exec(containerId, command, stepCwd, stepEnv)
if result.Error != nil { if result.Error != nil {
return result.Error return result.Error
@ -133,12 +133,14 @@ func (r *Runner) RunJobInContainer(imageUri string, containerId string, jobConte
logger.Info("Started %s", containerId) logger.Info("Started %s", containerId)
for stepIndex, step := range job.Steps { for stepIndex, step := range job.Steps {
stepCwd := jobContext.Value("workflow").(workflow.Workflow).GetWorkingDirectory(job.Name, stepIndex) stepCwd := jobContext.Value("workflow").(workflow.Workflow).GetWorkingDirectory(job.Name, stepIndex)
stepEnv := jobContext.Value("workflow").(workflow.Workflow).GetEnv(job.Name, stepIndex)
logger.Info("Run: %s", step.Run) logger.Info("Run: %s", step.Run)
logger.Info("Using working directory %s", stepCwd) logger.Info("Using working directory %s", stepCwd)
var stepError error var stepError error
if step.Run != "" { if step.Run != "" {
stepError = r.RunCommandInContainer(containerId, step.Run, stepCwd) stepError = r.RunCommandInContainer(containerId, step.Run, stepCwd, stepEnv)
} }
if stepError != nil && !step.ContinueOnError { if stepError != nil && !step.ContinueOnError {

View file

@ -5,6 +5,7 @@ import (
logger "courgette/internal/logging" logger "courgette/internal/logging"
workflow "courgette/internal/workflow" workflow "courgette/internal/workflow"
"errors" "errors"
"fmt"
"testing" "testing"
) )
@ -14,13 +15,13 @@ func init() {
func TestRunnerRunCommandInContainerReturnsErrorFromDriver(t *testing.T) { func TestRunnerRunCommandInContainerReturnsErrorFromDriver(t *testing.T) {
mockDriver := NewMockDriver() mockDriver := NewMockDriver()
mockDriver.WithMockedCall("Exec", CommandResult{ExitCode: 0, Error: errors.New("test")}, "test-container", "test-command", ".") mockDriver.WithMockedCall("Exec", CommandResult{ExitCode: 0, Error: errors.New("test")}, "test-container", "test-command", ".", fmt.Sprintf("%#v", map[string]string{}))
runner := Runner{ runner := Runner{
Driver: &mockDriver, Driver: &mockDriver,
} }
err := runner.RunCommandInContainer("test-container", "test-command", ".") err := runner.RunCommandInContainer("test-container", "test-command", ".", map[string]string{})
if err == nil { if err == nil {
t.Errorf("Expected error, got nil.") t.Errorf("Expected error, got nil.")
@ -29,13 +30,13 @@ func TestRunnerRunCommandInContainerReturnsErrorFromDriver(t *testing.T) {
func TestRunnerRunCommandInContainerReturnsErrorIfCommandExitCodeNonzero(t *testing.T) { func TestRunnerRunCommandInContainerReturnsErrorIfCommandExitCodeNonzero(t *testing.T) {
mockDriver := NewMockDriver() mockDriver := NewMockDriver()
mockDriver.WithMockedCall("Exec", CommandResult{ExitCode: 1, Error: nil}, "test-container", "test-command", ".") mockDriver.WithMockedCall("Exec", CommandResult{ExitCode: 1, Error: nil}, "test-container", "test-command", ".", fmt.Sprintf("%#v", map[string]string{}))
runner := Runner{ runner := Runner{
Driver: &mockDriver, Driver: &mockDriver,
} }
err := runner.RunCommandInContainer("test-container", "test-command", ".") err := runner.RunCommandInContainer("test-container", "test-command", ".", map[string]string{})
if err == nil { if err == nil {
t.Errorf("Expected error, got nil.") t.Errorf("Expected error, got nil.")
@ -44,7 +45,7 @@ func TestRunnerRunCommandInContainerReturnsErrorIfCommandExitCodeNonzero(t *test
func TestRunJobInContainerSchedulesStoppingContainers(t *testing.T) { func TestRunJobInContainerSchedulesStoppingContainers(t *testing.T) {
mockDriver := NewMockDriver() mockDriver := NewMockDriver()
mockDriver.WithMockedCall("Exec", CommandResult{ExitCode: 1, Error: nil}, "test-container", "test-command", ".") mockDriver.WithMockedCall("Exec", CommandResult{ExitCode: 1, Error: nil}, "test-container", "test-command", ".", fmt.Sprintf("%#v", map[string]string{}))
runner := NewRunner(&mockDriver, map[string]string{}) runner := NewRunner(&mockDriver, map[string]string{})

View file

@ -4,6 +4,7 @@ import (
"context" "context"
workflow "courgette/internal/workflow" workflow "courgette/internal/workflow"
"errors" "errors"
"fmt"
"testing" "testing"
) )
@ -70,7 +71,7 @@ jobs:
workflow, _ := workflow.FromYamlBytes([]byte(workflowSample)) workflow, _ := workflow.FromYamlBytes([]byte(workflowSample))
mockDriver := NewMockDriver() mockDriver := NewMockDriver()
mockDriver.WithMockedCall("Exec", CommandResult{Error: errors.New("exit 1!"), ExitCode: 1}, "testContainer", "exit 1") mockDriver.WithMockedCall("Exec", CommandResult{Error: errors.New("exit 1!"), ExitCode: 1}, "testContainer", "exit 1", fmt.Sprintf("%#v", map[string]string{}))
runner := NewRunner(&mockDriver, map[string]string{"test": "test"}) runner := NewRunner(&mockDriver, map[string]string{"test": "test"})
jobContext := context.WithValue(context.Background(), "workflow", *workflow) jobContext := context.WithValue(context.Background(), "workflow", *workflow)
@ -98,7 +99,7 @@ jobs:
workflow, _ := workflow.FromYamlBytes([]byte(workflowSample)) workflow, _ := workflow.FromYamlBytes([]byte(workflowSample))
mockDriver := NewMockDriver() mockDriver := NewMockDriver()
mockDriver.WithMockedCall("Exec", CommandResult{Error: errors.New("exit 1!"), ExitCode: 1}, "testContainer", "exit 1") mockDriver.WithMockedCall("Exec", CommandResult{Error: errors.New("exit 1!"), ExitCode: 1}, "testContainer", "exit 1", fmt.Sprintf("%#v", map[string]string{}))
runner := NewRunner(&mockDriver, map[string]string{"test": "test"}) runner := NewRunner(&mockDriver, map[string]string{"test": "test"})
jobContext := context.WithValue(context.Background(), "workflow", *workflow) jobContext := context.WithValue(context.Background(), "workflow", *workflow)
@ -130,7 +131,7 @@ jobs:
workflow, _ := workflow.FromYamlBytes([]byte(workflowSample)) workflow, _ := workflow.FromYamlBytes([]byte(workflowSample))
mockDriver := NewMockDriver() mockDriver := NewMockDriver()
mockDriver.WithMockedCall("Exec", CommandResult{Error: errors.New("exit 1!"), ExitCode: 1}, "testContainer", "exit 1", ".") mockDriver.WithMockedCall("Exec", CommandResult{Error: errors.New("exit 1!"), ExitCode: 1}, "testContainer", "exit 1", ".", fmt.Sprintf("%#v", map[string]string{}))
runner := NewRunner(&mockDriver, map[string]string{"test": "test"}) runner := NewRunner(&mockDriver, map[string]string{"test": "test"})
jobContext := context.WithValue(context.Background(), "workflow", *workflow) jobContext := context.WithValue(context.Background(), "workflow", *workflow)
@ -161,7 +162,7 @@ jobs:
workflow, _ := workflow.FromYamlBytes([]byte(workflowSample)) workflow, _ := workflow.FromYamlBytes([]byte(workflowSample))
mockDriver := NewMockDriver() mockDriver := NewMockDriver()
mockDriver.WithMockedCall("Exec", CommandResult{Error: errors.New("exit 1!"), ExitCode: 1}, "testContainer", "exit 1", ".") mockDriver.WithMockedCall("Exec", CommandResult{Error: errors.New("exit 1!"), ExitCode: 1}, "testContainer", "exit 1", ".", fmt.Sprintf("%#v", map[string]string{}))
runner := NewRunner(&mockDriver, map[string]string{"test": "test"}) runner := NewRunner(&mockDriver, map[string]string{"test": "test"})
jobContext := context.WithValue(context.Background(), "workflow", *workflow) jobContext := context.WithValue(context.Background(), "workflow", *workflow)

View file

@ -2,6 +2,7 @@ package workflow
import ( import (
"errors" "errors"
"maps"
) )
type Job struct { type Job struct {
@ -11,7 +12,8 @@ type Job struct {
// Job name; this isn't guaranteed to be unique. // Job name; this isn't guaranteed to be unique.
Name string `yaml:"name"` Name string `yaml:"name"`
// If truthy, job-level failure does not bubble up to the workflow. // If truthy, job-level failure does not bubble up to the workflow.
ContinueOnError bool `yaml:"continue-on-error"` ContinueOnError bool `yaml:"continue-on-error"`
Env map[string]string `yaml:"env"`
Defaults struct { Defaults struct {
Run struct { Run struct {
WorkingDirectory string `yaml:"working-directory"` WorkingDirectory string `yaml:"working-directory"`
@ -38,9 +40,10 @@ func (j Job) Validate() []error {
} }
type Step struct { type Step struct {
Run string `yaml:"run"` Run string `yaml:"run"`
WorkingDirectory string `yaml:"working-directory"` WorkingDirectory string `yaml:"working-directory"`
ContinueOnError bool `yaml:"continue-on-error"` ContinueOnError bool `yaml:"continue-on-error"`
Env map[string]string `yaml:"env"`
} }
func (s Step) Validate() []error { func (s Step) Validate() []error {
@ -55,7 +58,8 @@ func (s Step) Validate() []error {
type Workflow struct { type Workflow struct {
SourcePath string SourcePath string
Jobs map[string]Job `yaml:"jobs"` Jobs map[string]Job `yaml:"jobs"`
Env map[string]string `yaml:"env"`
} }
// Returns the given workflow's job+step working directory inside the container // Returns the given workflow's job+step working directory inside the container
@ -79,6 +83,22 @@ func (w Workflow) GetWorkingDirectory(jobName string, stepIndex int) string {
return "." return "."
} }
// Returns the merged map of environment variants defined by:
// - Workflow-level "env"
// - Job-level "env"
// - Step-level "env"
// The environment is merged in order, and name collisions overwrite
// previous values such that jobs can overwrite workflows, and steps, jobs.
func (w Workflow) GetEnv(jobName string, stepIndex int) map[string]string {
finalEnv := map[string]string{}
maps.Copy(finalEnv, w.Env)
maps.Copy(finalEnv, w.Jobs[jobName].Env)
maps.Copy(finalEnv, w.Jobs[jobName].Steps[stepIndex].Env)
return finalEnv
}
func (w Workflow) Validate() []error { func (w Workflow) Validate() []error {
validationErrors := []error{} validationErrors := []error{}

View file

@ -1,6 +1,7 @@
package workflow package workflow
import ( import (
"reflect"
"testing" "testing"
) )
@ -78,3 +79,130 @@ jobs:
} }
} }
func TestWorkflowEnv(t *testing.T) {
sample := `
env:
TEST: 1
jobs:
jobA:
runs-on: default
steps:
- run: echo "test"
`
workflow, _ := FromYamlBytes([]byte(sample))
env := workflow.GetEnv("jobA", 0)
if !reflect.DeepEqual(env, map[string]string{"TEST": "1"}) {
t.Errorf("Unexpected env: %#v", env)
}
}
func TestJobEnv(t *testing.T) {
sample := `
jobs:
jobA:
env:
TEST: 1
runs-on: default
steps:
- run: echo "test"
`
workflow, _ := FromYamlBytes([]byte(sample))
env := workflow.GetEnv("jobA", 0)
if !reflect.DeepEqual(env, map[string]string{"TEST": "1"}) {
t.Errorf("Unexpected env: %#v", env)
}
}
func TestStepEnv(t *testing.T) {
sample := `
jobs:
jobA:
runs-on: default
steps:
- run: echo "test"
env:
TEST: 1
`
workflow, _ := FromYamlBytes([]byte(sample))
env := workflow.GetEnv("jobA", 0)
if !reflect.DeepEqual(env, map[string]string{"TEST": "1"}) {
t.Errorf("Unexpected env: %#v", env)
}
}
func TestJobEnvOverwritesWorkflowEnv(t *testing.T) {
sample := `
env:
TEST: 1
jobs:
jobA:
env:
TEST: 2
runs-on: default
steps:
- run: echo "test"
`
workflow, _ := FromYamlBytes([]byte(sample))
env := workflow.GetEnv("jobA", 0)
if !reflect.DeepEqual(env, map[string]string{"TEST": "2"}) {
t.Errorf("Unexpected env: %#v", env)
}
}
func TestStepEnvOverwritesWorkflowEnv(t *testing.T) {
sample := `
env:
TEST: 1
jobs:
jobA:
runs-on: default
steps:
- run: echo "test"
env:
TEST: 2
`
workflow, _ := FromYamlBytes([]byte(sample))
env := workflow.GetEnv("jobA", 0)
if !reflect.DeepEqual(env, map[string]string{"TEST": "2"}) {
t.Errorf("Unexpected env: %#v", env)
}
}
func TestStepEnvOverwritesJobEnv(t *testing.T) {
sample := `
jobs:
jobA:
env:
TEST: 1
runs-on: default
steps:
- run: echo "test"
env:
TEST: 2
`
workflow, _ := FromYamlBytes([]byte(sample))
env := workflow.GetEnv("jobA", 0)
if !reflect.DeepEqual(env, map[string]string{"TEST": "2"}) {
t.Errorf("Unexpected env: %#v", env)
}
}