Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 21 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,11 +131,11 @@ go test ./...
? github.com/arkmq-org/markdown-runner/runnercontext [no test files]
? github.com/arkmq-org/markdown-runner/view [no test files]
ok github.com/arkmq-org/markdown-runner 0.005s
ok github.com/arkmq-org/markdown-runner/chunk 0.112s
ok github.com/arkmq-org/markdown-runner/chunk (cached)
ok github.com/arkmq-org/markdown-runner/config (cached)
ok github.com/arkmq-org/markdown-runner/parser (cached)
ok github.com/arkmq-org/markdown-runner/runner 0.013s
ok github.com/arkmq-org/markdown-runner/stage 0.110s
ok github.com/arkmq-org/markdown-runner/runner (cached)
ok github.com/arkmq-org/markdown-runner/stage (cached)
```

Integration tests can be run with:
Expand Down Expand Up @@ -632,7 +632,8 @@ tool that this a disposable code fence that can be overridden in the future.
Every chunk is started with the environment of the parent process that started
it. If as chunk whom runtime is bash is executed, all the variables it adds to
its own env via `export` will get added to the environment of subsequent
chunks.
chunks. Similarly, variables that are `unset` in a bash chunk will be removed
from the environment of subsequent chunks.

The following diagram illustrates this flow:

Expand Down Expand Up @@ -737,19 +738,33 @@ export TEST="some value"
````

The chunk below is able to perform some comparisons with value of
`SOME_VARIABLE`
`SOME_VARIABLE`. It's also possible to unset this variable from the env for
subsequent chunks.

````markdown
```bash {"stage":"test2", "runtime":"bash", "label": "Verify environment variable"}
```bash {"stage":"test2", "runtime":"bash", "label": "Verify exported variable"}
if [ "$SOME_VARIABLE" == "This is some content" ]; then
echo "same string"
unset SOME_VARIABLE
fi
```
```shell markdown_runner
same string
```
````


````markdown
```bash {"stage":"test2", "runtime":"bash", "label": "Verify unset variable"}
if [ ! -v SOME_VARIABLE ]; then
echo "SOME_VARIABLE is truly unset."
fi
```
```shell markdown_runner
SOME_VARIABLE is truly unset.
```
````

#### Executing in parallel

Parallelism can be important for some workflows, when for instance two processes
Expand Down
14 changes: 8 additions & 6 deletions chunk/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"strings"

Expand Down Expand Up @@ -155,15 +154,14 @@ func (command *RunningCommand) Wait() error {
}
command.Ctx.RView.StopCommand(command.id, true, command.CmdPrettyName)

// During a bash runtime the user might want to export new variables.
// During a bash runtime the user might want to export new variables or unset existing ones.
// Our job here is to recover them to build the new environment for the next chunk
if command.IsBash {
stoudtLines := strings.Split(command.Stdout, "\n")
// reinitialize the env
command.Ctx.Cfg.Env = []string{}
command.Ctx.Cfg.Env = append(command.Ctx.Cfg.Env, os.Environ()...)
var newLines []string
extractVariables := false
var bashEnvVars []string

for _, line := range stoudtLines {
// the environment gets separated by the output from a special string
if line == "### ENV ###" {
Expand All @@ -173,13 +171,17 @@ func (command *RunningCommand) Wait() error {
if extractVariables {
parts := strings.Split(line, "=")
if len(parts) > 1 {
command.Ctx.Cfg.Env = append(command.Ctx.Cfg.Env, line)
bashEnvVars = append(bashEnvVars, line)
}
} else {
// if not it's output we want to keep for the user
newLines = append(newLines, line)
}
}

// This includes all environment variables that should be available to subsequent chunks.
command.Ctx.Cfg.Env = bashEnvVars

if len(newLines) > 0 {
command.Stdout = strings.Join(newLines, "\n")
command.Stdout = command.Stdout + "\n"
Expand Down
148 changes: 137 additions & 11 deletions chunk/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,27 +76,153 @@ func TestRunningCommand(t *testing.T) {
assert.Error(t, err, "Expected an error when executing a command without a spinner initialized")
})

t.Run("bash env extraction", func(t *testing.T) {
t.Run("bash env export preserves original environment", func(t *testing.T) {
tmpDirs := make(map[string]string)
cfg := &config.Config{MinutesToTimeout: 1}

// Set up initial environment with some original variables
originalEnv := []string{
"ORIGINAL_VAR=original_value",
"PATH=/usr/bin:/bin",
"HOME=/home/test",
"WORKING_DIR=/test/dir",
}

cfg := &config.Config{MinutesToTimeout: 1, Env: originalEnv}
ui := view.NewView("mock")

// Export a new variable using the real bash chunk approach
chunk := ExecutableChunk{
Runtime: "bash",
Content: []string{"export NEW_VAR=new_value"},
Context: &runnercontext.Context{
Cfg: cfg,
RView: ui,
},
}
cmdStr := "bash -c 'echo \"### ENV ###\" && echo \"TEST_ENV_VAR=test_value\"'"
command, err := chunk.AddCommandToExecute(cmdStr, tmpDirs)
assert.NoError(t, err, "Expected no error when adding command to execute")
command.IsBash = true
err = command.Execute()
assert.NoError(t, err, "Expected no error when executing bash command")
found := slices.ContainsFunc(command.Ctx.Cfg.Env, func(env string) bool {
return env == "TEST_ENV_VAR=test_value"
err := chunk.PrepareForExecution(tmpDirs)
assert.NoError(t, err, "Expected no error when preparing chunk for execution")

err = chunk.ExecuteSequential()
assert.NoError(t, err, "Expected no error when executing bash chunk")

// Verify original environment variables are preserved
foundOriginal := slices.ContainsFunc(chunk.Context.Cfg.Env, func(env string) bool {
return env == "ORIGINAL_VAR=original_value"
})
assert.True(t, foundOriginal, "Expected ORIGINAL_VAR to be preserved")

foundPath := slices.ContainsFunc(chunk.Context.Cfg.Env, func(env string) bool {
return strings.HasPrefix(env, "PATH=")
})
assert.True(t, foundPath, "Expected PATH to be preserved")

foundHome := slices.ContainsFunc(chunk.Context.Cfg.Env, func(env string) bool {
return env == "HOME=/home/test"
})
assert.True(t, foundHome, "Expected HOME to be preserved")

foundWorkingDir := slices.ContainsFunc(chunk.Context.Cfg.Env, func(env string) bool {
return env == "WORKING_DIR=/test/dir"
})
assert.True(t, foundWorkingDir, "Expected WORKING_DIR to be preserved")

// Verify new variable was added
foundNew := slices.ContainsFunc(chunk.Context.Cfg.Env, func(env string) bool {
return strings.HasPrefix(env, "NEW_VAR=")
})
assert.True(t, foundNew, "Expected NEW_VAR to be added")
})

t.Run("bash env unset removes variables selectively", func(t *testing.T) {
tmpDirs := make(map[string]string)

// Set up environment with variables to test unset behavior
initialEnv := []string{
"KEEP_VAR=keep_this",
"REMOVE_VAR=remove_this",
"PATH=/usr/bin:/bin",
}

cfg := &config.Config{MinutesToTimeout: 1, Env: initialEnv}
ui := view.NewView("mock")

// Unset one variable while keeping others
chunk := ExecutableChunk{
Runtime: "bash",
Content: []string{"unset REMOVE_VAR"},
Context: &runnercontext.Context{
Cfg: cfg,
RView: ui,
},
}
err := chunk.PrepareForExecution(tmpDirs)
assert.NoError(t, err, "Expected no error when preparing chunk for execution")

err = chunk.ExecuteSequential()
assert.NoError(t, err, "Expected no error when executing bash chunk")

// Verify the targeted variable was unset
foundRemoved := slices.ContainsFunc(chunk.Context.Cfg.Env, func(env string) bool {
return strings.HasPrefix(env, "REMOVE_VAR=")
})
assert.False(t, foundRemoved, "Expected REMOVE_VAR to be removed after unset")

// Verify other variables are still preserved
foundKeep := slices.ContainsFunc(chunk.Context.Cfg.Env, func(env string) bool {
return env == "KEEP_VAR=keep_this"
})
assert.True(t, foundKeep, "Expected KEEP_VAR to be preserved after unset")

foundPath := slices.ContainsFunc(chunk.Context.Cfg.Env, func(env string) bool {
return strings.HasPrefix(env, "PATH=")
})
assert.True(t, foundPath, "Expected PATH to be preserved after unset")
})

t.Run("bash env unset critical variables", func(t *testing.T) {
tmpDirs := make(map[string]string)

// Set up environment with critical variables
initialEnv := []string{
"HOME=/home/test",
"PATH=/usr/bin:/bin",
"KEEP_VAR=keep_this",
}

cfg := &config.Config{MinutesToTimeout: 1, Env: initialEnv}
ui := view.NewView("mock")

// Unset HOME to verify critical environment variables can be unset
chunk := ExecutableChunk{
Runtime: "bash",
Content: []string{"unset HOME"},
Context: &runnercontext.Context{
Cfg: cfg,
RView: ui,
},
}
err := chunk.PrepareForExecution(tmpDirs)
assert.NoError(t, err, "Expected no error when preparing chunk for execution")

err = chunk.ExecuteSequential()
assert.NoError(t, err, "Expected no error when executing bash chunk")

// Verify HOME was unset
foundHome := slices.ContainsFunc(chunk.Context.Cfg.Env, func(env string) bool {
return strings.HasPrefix(env, "HOME=")
})
assert.False(t, foundHome, "Expected HOME to be removed after unset")

// Verify other variables are still preserved
foundKeep := slices.ContainsFunc(chunk.Context.Cfg.Env, func(env string) bool {
return env == "KEEP_VAR=keep_this"
})
assert.True(t, foundKeep, "Expected KEEP_VAR to be preserved after unsetting HOME")

foundPath := slices.ContainsFunc(chunk.Context.Cfg.Env, func(env string) bool {
return strings.HasPrefix(env, "PATH=")
})
assert.True(t, found, "Expected to find TEST_ENV_VAR in the environment variables")
assert.True(t, foundPath, "Expected PATH to be preserved after unsetting HOME")
})

t.Run("kill", func(t *testing.T) {
Expand Down