Ruby for DevOps — Part 4 of 5
A large share of DevOps automation is structured-data plumbing: read a YAML inventory, validate it, call endpoints, and emit JSON that a pipeline can evaluate. Ruby can do that with standard-library components including Psych-backed YAML parsing, URI, Net::HTTP, JSON, threads, and queues.1
This tutorial builds two reusable layers. ConfigLoader turns configuration text into a narrow, normalized service schema. HealthChecker checks services concurrently, distinguishes required from optional failures, and returns structured results. The CLI presents the same data to humans or machines.
Fork or follow the code
The complete project is in the DevOps Coach repository. Open the exact config_loader.rb, health_checker.rb, and services.yml files.
gh repo fork jjam3774/devop-coach --clone
cd devop-coach/tutorials/ruby-for-devops/code
bundle install
Or follow the upstream implementation directly:
git clone https://github.com/jjam3774/devop-coach.git
cd devop-coach/tutorials/ruby-for-devops/code
Define a narrow configuration contract
The sample inventory is intentionally small:
services:
- name: example-home
url: https://example.com/
expected_status: 200
required: true
headers:
User-Agent: ruby-devops-toolkit/1.0
- name: optional-no-content
url: https://httpbin.org/status/204
expected_status: 204
required: false
Each service needs a non-empty name and an absolute HTTP or HTTPS URL. expected_status defaults to 200; required defaults to true; and headers defaults to an empty mapping. Narrow schemas give a team flexibility because downstream code receives one predictable shape. You can later normalize JSON, database records, or service-catalog data into that same schema.
Load YAML safely
Ruby’s YAML interface is backed by Psych.2 Do not use unrestricted object deserialization for configuration that can be edited outside a trusted code path. The loader uses safe_load and disables aliases:
document = YAML.safe_load(
raw,
permitted_classes: [],
permitted_symbols: [],
aliases: false
)
raise ConfigError, "configuration root must be a mapping" unless document.is_a?(Hash)
services = document["services"]
raise ConfigError, "configuration must contain a services list" unless services.is_a?(Array)
raise ConfigError, "services list must not be empty" if services.empty?
This project does not need arbitrary Ruby classes, symbols, or YAML aliases, so it permits none of them. Safe parsing is only the first step: valid YAML can still describe an invalid health check.
Validate semantics, not only syntax
For URLs, use URI.parse and require both an HTTP scheme and a host:
def validate_http_url(value, label)
uri = URI.parse(value)
unless %w[http https].include?(uri.scheme) && uri.host
raise ConfigError, "#{label}.url must be an absolute HTTP or HTTPS URL"
end
rescue URI::InvalidURIError
raise ConfigError, "#{label}.url is not a valid URL"
end
Convert status codes explicitly, require a range of 100–599, require required to be a real boolean, and require every header value to be a string. This prevents surprising configuration such as required: "false", which Ruby would otherwise treat as truthy.
Validate the sample without making a network call:
bin/devops-toolkit config --file config/services.yml
bin/devops-toolkit config --file config/services.yml --json
The command returns exit code 65 for a configuration error, letting CI distinguish bad input from an unavailable service.
Keep HTTP transport replaceable
HealthChecker depends on a small transport object. The real transport uses Ruby’s documented Net::HTTP client API:3
class NetHttpTransport
def call(url:, headers:, timeout:)
uri = URI.parse(url)
request = Net::HTTP::Get.new(uri)
headers.each { |name, value| request[name] = value }
response = Net::HTTP.start(
uri.host, uri.port,
use_ssl: uri.scheme == "https",
open_timeout: timeout,
read_timeout: timeout,
write_timeout: timeout
) { |http| http.request(request) }
response.code.to_i
end
end
The transport accepts keyword inputs and returns one status integer. Tests provide a fake with the same method, so they can exercise success, failures, and exceptions without depending on the public internet. This boundary keeps options open for mTLS, a proxy-aware transport, a Unix-socket transport, or a maintained HTTP gem later—without rewriting configuration validation or result handling.
Check services concurrently with a bounded worker pool
queue = Queue.new
services.each_with_index { |service, index| queue << [index, service] }
results = Array.new(services.length)
threads = Array.new(worker_count) do
Thread.new do
loop do
index, service = queue.pop(true)
results[index] = check(service, timeout: timeout)
rescue ThreadError
break
end
end
end
threads.each(&:join)
Each result is written to its original index, so output order matches the inventory even when requests finish in a different order. A bounded pool is easier on the local machine and target systems than an unbounded thread per endpoint. Optional checks can fail without failing the overall command; required failures return exit code 2.
Run the health checker
cat config/services.yml
bin/devops-toolkit check --file config/services.yml --timeout 3 --workers 3
bin/devops-toolkit check --file config/services.yml --timeout 3 --workers 3 --json
The JSON document contains an overall success flag, a required-failure count, and every check result. This makes Ruby a practical choice when Bash, Python, a CI runner, or a monitoring agent all need to consume the same stable contract.
Stream operational JSON without another dependency
The repository includes a JSON Lines report built with Ruby’s standard JSON parser and generator.4
ruby examples/log_report.rb config/events.jsonl
File.foreach(input_path).with_index(1) do |line, line_number|
next if line.strip.empty?
event = JSON.parse(line)
level = event.fetch("level", "UNKNOWN").upcase
service = event.fetch("service", "unknown")
total += 1
level_counts[level] += 1
error_counts[service] += 1 if level == "ERROR"
rescue JSON::ParserError => error
warn "#{input_path}:#{line_number}: invalid JSON: #{error.message}"
exit 65
end
Streaming avoids loading a large log file into memory. The JSON can be piped to jq, archived in CI, or consumed by Python or another Ruby process.
Run deterministic tests
bundle exec ruby -Itest test/config_loader_test.rb
bundle exec ruby -Itest test/health_checker_test.rb
Configuration tests use temporary files and health-check tests inject a fake transport. No unit test depends on the live internet. Keep live endpoints in a separate smoke or integration job.
Ruby versus Python for structured automation
| Requirement | Python | Ruby in this project |
|---|---|---|
| Safe YAML | yaml.safe_load |
YAML.safe_load |
| URL validation | urllib.parse |
URI.parse plus scheme and host checks |
| HTTP | Requests or urllib.request |
Net::HTTP behind a transport interface |
| Concurrent I/O | Thread pool or async client | Bounded Ruby thread pool |
| Test isolation | Mocked client or fake session | Injected fake transport |
The value is not that one column is universally better. Ruby gives teams another proven option while preserving the operational contract: structured data, timeouts, exit codes, and deterministic tests.
Next: make it reproducible in CI
Part 5 uses Bundler, Rake, Minitest, syntax checks, smoke commands, and a GitHub Actions version matrix. You will finish with one local command that validates the entire toolkit and the same command running on every pull request.
Continue to Test and Ship Ruby DevOps Automation in CI.
