Ruby for DevOps — Part 2 of 5
A useful DevOps script eventually becomes a tool other people call. At that point, “edit these variables and run the file” is not enough. The tool needs help text, validated options, stable output, predictable exit codes, and behavior that works equally well at a terminal and in CI.
Ruby’s OptionParser is a standard-library class for command-line option analysis.1 Its official tutorial keeps option definitions and handlers together, which is the pattern used by each command in this project.2 It is enough to build a focused internal CLI without adding a framework. In this tutorial, you will use the repository’s devops-toolkit executable to understand a production-ready command contract and extend it safely.
Fork or follow the code
The complete project is in the DevOps Coach repository. Open the exact bin/devops-toolkit entrypoint and lib/devops_toolkit/cli.rb implementation.
Fork and clone with GitHub CLI:
gh repo fork jjam3774/devop-coach --clone
cd devop-coach/tutorials/ruby-for-devops/code
Or follow the upstream source directly:
git clone https://github.com/jjam3774/devop-coach.git
cd devop-coach/tutorials/ruby-for-devops/code
Run bundle install once before the test commands in this tutorial.
Define the interface before the implementation
The toolkit exposes four commands:
| Command | Purpose | Safe first run |
|---|---|---|
doctor |
Report the Ruby runtime and common DevOps executables. | bin/devops-toolkit doctor --json |
run |
Execute an external process with timeout and dry-run controls. | bin/devops-toolkit run --dry-run -- terraform plan |
config |
Safely load and validate a YAML service inventory. | bin/devops-toolkit config --file config/services.yml |
check |
Run concurrent HTTP checks from that inventory. | bin/devops-toolkit check --file config/services.yml |
The interface separates orchestration from implementation. CLI parses arguments and formats output; CommandRunner, ConfigLoader, and HealthChecker perform the work. That separation makes unit tests faster and lets you reuse the same classes from another Ruby program.
Keep the executable tiny
The executable should load the library, pass ARGV into the CLI, and return the CLI’s result to the operating system:
#!/usr/bin/env ruby
# frozen_string_literal: true
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
require "devops_toolkit"
exit DevOpsToolkit::CLI.new(argv: ARGV).call
This file contains no option definitions or business logic. A small entrypoint is easier to package and avoids code that can only be tested by spawning a process.
Make it executable, then display the top-level help:
chmod +x bin/devops-toolkit
bin/devops-toolkit help
Expected command summary:
Usage:
devops-toolkit doctor [--json]
devops-toolkit run [options] -- COMMAND [ARG ...]
devops-toolkit config [--file PATH] [--json]
devops-toolkit check [options]
Dispatch explicit subcommands
The call method removes the first argument and dispatches it through a case expression:
command = @argv.shift
case command
when "doctor" then doctor_command
when "run" then run_command
when "config" then config_command
when "check" then check_command
when "help", "--help", "-h", nil
@out.puts(help_text)
command.nil? ? EXIT_USAGE : 0
else
@err.puts("Unknown command: #{command}")
EXIT_USAGE
end
Explicit dispatch is appropriate for a small internal tool. It makes the supported surface obvious and ensures an unknown command fails instead of guessing what the user meant.
Parse options close to the command
Each command creates its own parser. The run parser accepts a timeout, a dry-run flag, JSON output, and verbose logging:
options = {
timeout: 30.0,
dry_run: false,
json: false,
verbose: false
}
parser = OptionParser.new do |opts|
opts.banner = "Usage: devops-toolkit run [options] -- COMMAND [ARG ...]"
opts.on("--timeout SECONDS", Float, "Stop a long-running command") do |value|
options[:timeout] = value
end
opts.on("--dry-run", "Print the operation without running it") do
options[:dry_run] = true
end
opts.on("--json", "Emit machine-readable JSON") do
options[:json] = true
end
opts.on("--verbose", "Enable diagnostic logs on stderr") do
options[:verbose] = true
end
end
The typed Float conversion rejects --timeout tomorrow before the command runner receives it. The code also verifies that the timeout is positive. Parsing and semantic validation are separate responsibilities, and both matter.
Use -- to mark the end of toolkit options:
bin/devops-toolkit run --timeout 10 -- git log --oneline -5
Without that boundary, a flag intended for git could be interpreted as a toolkit flag. This convention is common in Unix tools and keeps nested command lines predictable.
Return meaningful exit codes
Human-readable errors are not enough for automation. CI needs a process status it can evaluate.
| Exit code | Meaning in this toolkit |
|---|---|
0 |
The command or check succeeded. |
2 |
One or more required HTTP checks failed. |
64 |
Command usage or option validation failed. |
65 |
Configuration could not be safely loaded or validated. |
70 |
An unexpected internal software error occurred. |
124 |
An external command exceeded its timeout. |
Other nonzero value |
run preserves the external command’s exit status when available. |
The CLI catches expected error classes at one boundary:
rescue OptionParser::ParseError, CommandRunner::InvalidCommand, ArgumentError => error
@err.puts("Usage error: #{error.message}")
EXIT_USAGE
rescue ConfigLoader::ConfigError => error
@err.puts("Configuration error: #{error.message}")
EXIT_CONFIG
Do not use rescue StandardError around every method. Catch specific, expected failures where you can add context or translate them into an interface-level result. The outer CLI boundary can catch an unexpected error, report its class, and return a software-error status.
Try an invalid option and inspect the result:
bin/devops-toolkit doctor --not-real
echo $?
The first command should write an option error to standard error. The second should print 64.
Separate standard output from diagnostics
A CLI that promises JSON must keep standard output clean. JSON belongs on stdout; verbose logs and error messages belong on stderr.
This command can be safely piped into jq:
bin/devops-toolkit doctor --json | jq '.ruby_version, .tools.git'
The command runner’s logger is constructed with the CLI’s error stream:
Logger.new(@err).tap do |logger|
logger.progname = "devops-toolkit"
logger.level = verbose ? Logger::INFO : Logger::WARN
end
If --verbose is enabled, diagnostics remain visible to a human but do not corrupt the JSON document on stdout.
Offer both human and machine output
The same report can support two audiences. doctor builds one Ruby hash and then chooses the presentation:
report = {
toolkit_version: DevOpsToolkit::VERSION,
ruby_version: RUBY_VERSION,
ruby_platform: RUBY_PLATFORM,
ruby_executable: RbConfig.ruby,
tools: %w[git docker kubectl terraform tofu].to_h do |name|
[name, find_executable(name)]
end
}
Human output:
bin/devops-toolkit doctor
Machine output:
bin/devops-toolkit doctor --json
The data is generated once, which reduces the chance that human and JSON modes disagree.
Inject dependencies instead of hard-coding them
The CLI constructor accepts a configuration loader, command-runner factory, health checker, and output streams. Default values build the real components, while tests provide fakes.
def initialize(
argv:,
out: $stdout,
err: $stderr,
config_loader: ConfigLoader.new,
command_runner_factory: nil,
health_checker: HealthChecker.new
)
# Store dependencies for command methods.
end
This is a small dependency-injection pattern. It avoids a framework and lets a test verify check --json without calling the internet. It also gives future integrations another option: a Jenkins plugin or Ruby service can invoke the CLI class with in-memory streams.
Run the safe command tour
Start with commands that do not change infrastructure:
bin/devops-toolkit doctor --json
bin/devops-toolkit config --file config/services.yml --json
bin/devops-toolkit run --dry-run --json -- terraform plan
Now verify the usage contract:
bin/devops-toolkit unknown-command
echo $?
Finally, run the CLI tests:
bundle exec ruby -Itest test/cli_test.rb
The tests cover JSON output, dry-run behavior, dedicated configuration errors, required versus optional health checks, and unknown-command handling. Part 5 explains the complete suite and CI workflow.
Why this gives a DevOps team flexibility
A stable CLI becomes a language-neutral boundary. Bash, Python, Ruby, a CI runner, or a monitoring agent can call the same executable and evaluate JSON plus the exit code. The implementation can evolve without requiring callers to understand Ruby objects.
Ruby also lets the interface remain lightweight. OptionParser, JSON, Logger, and RbConfig are documented Ruby components,1 so the production CLI does not depend on a large command framework. If the tool grows to dozens of commands, a framework may become worthwhile; the current design preserves the option rather than requiring it on day one.
Practice exercise
Add a version --json form that emits this shape:
{
"name": "devops-toolkit",
"version": "1.0.0"
}
Update help_text, add a version_command method with its own OptionParser, and write tests for human and JSON output. Keep diagnostics on stderr and return 64 for an unsupported version option.
Next: execute external commands safely
A polished CLI is only as safe as the operations behind it. Part 3 builds the CommandRunner, captures stdout and stderr concurrently, enforces a timeout, and treats shell metacharacters as data rather than executable syntax.
Continue to Run Shell Commands Safely with Ruby.