Dark navy and teal Ruby for DevOps graphic reading No Implicit Shell

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.

References

  1. Ruby Open3 Documentation
  2. Ruby Shellwords Documentation