Dark navy and teal Ruby for DevOps graphic reading YAML In, JSON Out

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.

References

  1. Ruby Standard Library
  2. Ruby YAML Documentation
  3. Ruby Net::HTTP Documentation
  4. Ruby JSON Documentation