Steps

The exec step

An exec step runs a program: a migration tool, a seeding script, a CLI whose output is the check. vero starts it as an argv list with no shell, in its own process group, with a capped record of what it printed, and ends it when the step's deadline fires. There is nothing to quote and nothing to inject, and a plan that wants a pipeline writes ["sh", "-c", "..."] and owns it.

An exec step runs with your privileges and is not a sandbox: a plan file is executable code, and a plan from an untrusted source should not be run. vero run --help ends with the same sentence.

Key Type Meaning
command list of strings argv, templated; a string is refused, there is no shell
workdir string relative to the plan file
env map of strings templated; the child also gets PATH, HOME, LANG and nothing else
stdin string templated
timeout duration alternative to the step's timeout; not both
maxOutput int bytes kept of each of stdout and stderr, default 1 MiB

A complete plan

exec.yaml runs a stand-in migration tool, examples/bin/migrate, which prints what it did and fails on a bad DATABASE_URL the way a real one would. It needs no fixture server.

tasks:
  - name: migrate
    steps:
      - exec:
          command: ["./bin/migrate", "up"]
          workdir: "."
          env:
            DATABASE_URL: "{{ env.databaseUrl }}"
          timeout: 60s
        assert:
          - exitCode == 0
          - stderr not contains "FATAL"
          - stdout contains "migrated"
$ bin/vero run examples/exec.yaml
run 01M3ZRX0T4X2QGJGPYVVHFB9P4  exec.yaml  --jobs 4  lifecycle isolated

1 passed  0 failed  0 errored  0 timed out  0 skipped  0 blocked  0 not run   (1 tasks, 0 requests, 0.0s)

An exec step sends no HTTP request, so the request count stays at zero. vero plan --explain migrate describes the step as exec ./bin/migrate up: no HTTP requests.

What the child sees

The argv is command with every element rendered. workdir is resolved against the plan file's directory, so "." is the directory the plan sits in, and an absolute path is used as written.

The environment is built from scratch. The child gets PATH, HOME and LANG copied from vero's own environment when they are set, then the step's env in sorted key order. Nothing else crosses: a secret in vero's environment reaches the child only if the plan writes it into env, and then it is scrubbed from the child's output like any other secret. A script step's child also gets VERO_SCRIPT_TOKEN; an exec step's child does not.

stdin is written to the child when set. Without it, stdin is empty.

Every value in command, env, stdin and workdir takes templates (templates).

Output and its cap

Both streams are captured, each up to maxOutput bytes, 1 MiB by default. Past the cap vero keeps reading and discards, so a chatty child never blocks on a full pipe. What was cut is recorded, and an assertion on a cut stream carries the note stdout was truncated at 1048576 bytes of 2097152; anything past the cap was not looked at, so "not present" stays distinct from "not looked at". truncated.stdout and truncated.stderr say the same thing to an assertion.

Deadline and process group

The deadline is the step's timeout, or exec.timeout, 30 s when neither is set. Setting both is a load error: set the timeout once, on the step or inside exec, not both.

The child starts in a process group of its own. When the deadline fires, vero sends SIGTERM to the whole group, waits 5 s, then sends SIGKILL to the group. Once the leader has exited, a final SIGKILL goes to the group so a child the leader started does not outlive it holding a lock. The step is timed_out, its assertions are not evaluated, and exitCode is nil, never -1:

TIMEOUT  sleeper › step 1                                         exec-sleep.yaml

  the step's deadline fired and the process group was killed (SIGTERM sent to process group 330856)

  exec  ["sh", "-c", "sleep 30"]  in .
    exit code none (killed or never started), 1001.6ms
    stdout
    stderr


0 passed  0 failed  0 errored  1 timed out  0 skipped  0 blocked  0 not run   (1 tasks, 0 requests, 1.0s)

The same happens on a run timeout or a first SIGINT, with the cause named in place of the step's deadline. A child killed by a signal vero did not send ends the step errored with the wait error. Process groups and group signals are Linux and macOS only.

Names in scope

Name Value
exitCode the exit code, or nil when the process was killed or never started
stdout, stderr what was kept of each stream, as strings
truncated.stdout, truncated.stderr whether each stream hit maxOutput
duration.total wall time from start to exit; the only phase an exec step has
env, tasks, run as in every step

duration alone is refused, as on every step kind: name duration.total. The usual string operators apply to the streams, as in stderr not contains "FATAL" and stdout matches "^migrated \\d+ files". See assertions.

What a failure shows

The failure block prints the argv as it ran, the working directory, the exit code and the time, then each stream with its lines prefixed by |, then every assertion that did not hold with the values it read:

FAIL  migrate › step 1 › assert #1                                exec-fail.yaml:13

  exec  ["./bin/migrate", "up"]  in .
    exit code 1, 9.0ms
    stdout
    stderr
    | FATAL: DATABASE_URL is not set

  assert  exitCode == 0    failed
          exitCode = 1
          exec-fail.yaml:13
  assert  stdout contains "migrated"    failed
          stdout = ""
          exec-fail.yaml:13

0 passed  1 failed  0 errored  0 timed out  0 skipped  0 blocked  0 not run   (1 tasks, 0 requests, 0.0s)

A truncated stream is marked on its line, as stdout (truncated: 2097152 bytes written, 1048576 kept). The event log carries the same record as an exec event (vero.events/v1).

Load errors

The one most people hit first is a command written as one string. vero shows the list it thinks you meant:

exec-string.yaml:9: tasks.migrate.steps[0].exec.command: command is a string; exec takes an argv list with no shell: write ["./bin/migrate", "up"], or ["sh", "-c", "./bin/migrate up"] if you need a shell

The others:

Plan Load error
no command an exec step needs command, an argv list such as ["./bin/migrate", "up"]
command: [""] the program to run is empty
timeout on the step and in exec set the timeout once, on the step or inside exec, not both
a negative maxOutput maxOutput is a byte count, got -1
an env key such as x-y environment variable name "x-y" must match ^[A-Za-z_][A-Za-z0-9_]*$
repeat on the step repeat works on http steps only

An exec step may sit in a task that holds a resource, and a lifecycle.reset hook may be an exec step; a reset step with no assertions is held to exitCode == 0.

This page is docs/content/steps/exec.md in the repository.

verodocs