Ruby for DevOps — Part 3 of 5
DevOps automation often exists to run another tool: git, docker, kubectl, terraform, tofu, or a vendor CLI. That makes process execution one of the most important places to be deliberate. A wrapper that builds one large shell string can turn a filename, branch name, or user-supplied value into executable syntax.
Ruby’s Open3 standard library provides access to a child process’s stdin, stdout, stderr, and completion status.1 Used with an executable plus an argument array, it supports the same safe pattern Python engineers know from subprocess.run([...]): pass data as arguments, not as shell code.
This tutorial builds the repository’s CommandRunner, which adds captured streams, elapsed time, meaningful results, timeouts, process termination, dry-run behavior, and structured JSON through the CLI.
Fork or follow the code
The full project is in the DevOps Coach repository. Open the exact command_runner.rb implementation and command_runner_test.rb test file.
Fork with GitHub CLI:
gh repo fork jjam3774/devop-coach --clone
cd devop-coach/tutorials/ruby-for-devops/code
Or clone and follow the upstream project:
git clone https://github.com/jjam3774/devop-coach.git
cd devop-coach/tutorials/ruby-for-devops/code
Avoid the shell unless you need shell semantics
This is convenient but risky when any value comes from outside the code:
branch = ARGV.fetch(0)
system("git checkout #{branch}")
A shell interprets spaces, semicolons, pipes, redirections, command substitutions, and other metacharacters. Quoting every platform correctly is harder than avoiding interpretation.
Pass each argument separately instead:
branch = ARGV.fetch(0)
system("git", "checkout", branch)
The branch value remains one argument even if it contains spaces or shell-looking text. The repository’s runner preserves that property with Open3.popen3(env, *argv).
Use a shell only when the shell itself is the feature you need—for example, a carefully controlled pipeline or redirect that has no cleaner Ruby equivalent. Make that decision explicit, keep the script constant, and never splice untrusted values into it.
Model execution as data
A reusable runner should return more than true or false. The project defines a result object:
Result = Struct.new(
:command,
:stdout,
:stderr,
:exit_status,
:duration_seconds,
:timed_out,
:dry_run,
keyword_init: true
) do
def success?
!timed_out && (dry_run || exit_status == 0)
end
def to_h
{
command: command,
stdout: stdout,
stderr: stderr,
exit_status: exit_status,
duration_seconds: duration_seconds,
timed_out: timed_out,
dry_run: dry_run,
success: success?
}
end
end
A structured result supports human output, JSON output, tests, and higher-level policy. For example, the CLI maps a timeout to exit code 124, while a failed child command retains its own exit status.
Validate before starting a process
Normalize input at the boundary:
def normalize_command(command)
argv = Array(command).map(&:to_s)
if argv.empty? || argv.first.empty?
raise InvalidCommand, "command must contain at least one executable"
end
argv
end
The runner also converts the timeout to a float and requires it to be positive. Invalid requests fail before a child process is created.
For logs and reports, Shellwords.join(argv) creates a readable representation of the argument list.2 That string is for display only. Execution still uses the original array.
Capture stdout and stderr concurrently
A child can write to both output streams. Reading one stream to completion before the other can deadlock if the unread pipe fills. The implementation starts one reader thread per stream:
Open3.popen3(env, *argv, **spawn_options) do |stdin, stdout, stderr, wait_thread|
stdin.close
stdout_reader = Thread.new { stdout.read }
stderr_reader = Thread.new { stderr.read }
unless wait_thread.join(timeout_seconds)
timed_out = true
terminate_process(wait_thread)
end
stdout_text = stdout_reader.value
stderr_text = stderr_reader.value
status = wait_thread.value
end
The code closes stdin because this runner does not provide interactive input. A different tool could expose an input string or stream, but adding that capability should be explicit.
Try a command that writes to both streams:
bin/devops-toolkit run --json -- ruby -e \
'STDOUT.write("ready"); STDERR.write("diagnostic")'
The JSON result should contain ready under stdout, diagnostic under stderr, and exit status 0.
Enforce a real timeout
A network CLI, package manager, or provider plugin can hang. The runner waits for a bounded period:
unless wait_thread.join(timeout_seconds)
timed_out = true
terminate_process(wait_thread)
end
Termination first sends TERM, giving the process one second to exit. If it remains alive, the runner sends KILL:
def terminate_process(wait_thread)
Process.kill("TERM", wait_thread.pid)
return if wait_thread.join(1)
Process.kill("KILL", wait_thread.pid)
wait_thread.join
rescue Errno::ESRCH, Errno::ECHILD
nil
end
Test the behavior with a harmless sleeping Ruby process:
bin/devops-toolkit run --timeout 0.2 --json -- ruby -e 'sleep 2'
echo $?
The result should report "timed_out": true, and the CLI should return 124.
A production orchestrator may need to terminate an entire process group rather than one PID, especially when the child launches descendants. Add process-group handling only after testing the target operating systems and child tools.
Add dry-run behavior at the lowest useful layer
A dry-run flag is most trustworthy when the execution component—not only the CLI—enforces it:
return Result.new(
command: display_command,
stdout: "",
stderr: "",
exit_status: 0,
duration_seconds: 0.0,
timed_out: false,
dry_run: true
) if dry_run
Now a future caller that uses CommandRunner directly receives the same protection.
Preview a deployment command:
bin/devops-toolkit run --dry-run --json -- \
kubectl apply -f deployment.yml
The output should show the complete command and "dry_run": true. No kubectl process is started, so the example remains safe even if kubectl is not installed.
Dry-run does not make a destructive design safe by itself. It must be unambiguous, covered by tests, and paired with a separate opt-in for the real action.
Handle a missing executable as a normal result
Open3 raises Errno::ENOENT when an executable cannot be found. The runner translates that into the conventional command-not-found status 127:
rescue Errno::ENOENT => error
Result.new(
command: display_command || Array(command).join(" "),
stdout: "",
stderr: error.message,
exit_status: 127,
duration_seconds: 0.0,
timed_out: false,
dry_run: false
)
That makes absence easy to process in JSON or tests:
bin/devops-toolkit run --json -- definitely-not-installed
echo $?
The CLI returns 127 rather than hiding the failure behind a generic status.
Prove that metacharacters remain data
Security claims should have tests. The suite passes a shell-looking value to a child Ruby program and asserts that it arrives unchanged as one argument:
unsafe_looking_argument = "; echo this-must-not-run"
result = @runner.run([
RbConfig.ruby,
"-rjson",
"-e",
"puts JSON.generate(ARGV)",
unsafe_looking_argument
])
assert_equal [unsafe_looking_argument], JSON.parse(result.stdout)
If the runner accidentally introduced a shell, the semicolon could start another command. Array-based execution keeps it as ordinary text.
Run the complete runner tests:
bundle exec ruby -Itest test/command_runner_test.rb
The suite covers output capture, nonzero statuses, dry runs, missing executables, timeouts, metacharacter handling, and invalid empty commands.
Ruby versus Python for process automation
The production principles are the same in both languages.
| Requirement | Python | Ruby |
|---|---|---|
| Argument-safe invocation | subprocess.run([exe, arg]) |
Open3.capture3(exe, arg) or Open3.popen3 |
| Captured output | capture_output=True |
stdout and stderr pipes from Open3 |
| Timeout | timeout= |
wait-thread timeout plus explicit termination |
| Exit status | CompletedProcess.returncode |
Process::Status#exitstatus |
| Environment override | env={...} |
environment hash before executable arguments |
| Working directory | cwd= |
chdir: spawn option |
Ruby gives you another implementation option without changing the operational contract. JSON, streams, exit codes, and command arguments work the same way from the caller’s perspective.
Practice exercise
Add an optional max_output_bytes argument to CommandRunner#run. When stdout or stderr exceeds the limit, truncate the stored result and add a boolean flag such as output_truncated.
Write tests before changing the implementation. Decide whether exceeding the limit should fail the command or only affect captured output. Document the choice, because callers need predictable behavior.
Next: validate configuration and call HTTP services
Part 4 uses YAML.safe_load, URL validation, Net::HTTP, concurrency, required-versus-optional services, and injectable transport. The result is a small health checker that works at a terminal or in CI.
Continue to Automate YAML, JSON, and Service Health Checks with Ruby.
