Ruby Metaprogramming for Infrastructure Documentation

A parked project: Build a Ruby DSL that defines infrastructure once and generates AsciiDoc, D2 diagrams, Ansible inventory, and more through metaprogramming.

Status: Parked for future exploration
Prerequisites: RHCSA completion, basic Ruby familiarity
Inspiration: Rails/DHH, AsciiDoctor extensions, Infrastructure as Code


The Vision

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    SINGLE SOURCE OF TRUTH                        β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                                  β”‚
β”‚   infrastructure.rb (Ruby DSL)                                   β”‚
β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                       β”‚
β”‚   β”‚ infrastructure :domus do            β”‚                       β”‚
β”‚   β”‚   host :ise_01 do                   β”‚                       β”‚
β”‚   β”‚     ip "10.50.1.20"                 β”‚                       β”‚
β”‚   β”‚     role :radius                    β”‚                       β”‚
β”‚   β”‚     services :ise, :tacacs          β”‚                       β”‚
β”‚   β”‚   end                               β”‚                       β”‚
β”‚   β”‚                                     β”‚                       β”‚
β”‚   β”‚   network :inside do                β”‚                       β”‚
β”‚   β”‚     cidr "10.50.1.0/24"             β”‚                       β”‚
β”‚   β”‚     vlan 10                         β”‚                       β”‚
β”‚   β”‚     gateway "10.50.1.1"             β”‚                       β”‚
β”‚   β”‚   end                               β”‚                       β”‚
β”‚   β”‚ end                                 β”‚                       β”‚
β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                       β”‚
β”‚                         β”‚                                        β”‚
β”‚                         β–Ό                                        β”‚
β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”‚
β”‚   β”‚ AsciiDoc β”‚   D2     β”‚ Ansible  β”‚   DNS    β”‚ Firewall β”‚     β”‚
β”‚   β”‚ attrs    β”‚ Diagrams β”‚ Inventoryβ”‚  Zones   β”‚  Rules   β”‚     β”‚
β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β”‚
β”‚                                                                  β”‚
β”‚   One definition β†’ Many outputs β†’ Always in sync                β”‚
β”‚                                                                  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Why This Matters

Current Problem

Issue Pain

Hardcoded IPs in docs

Change IP β†’ update 50 files

Separate inventory sources

Ansible says one thing, docs say another

Manual diagram updates

Infrastructure changes, diagrams rot

Antora attributes

Copy-paste between antora.yml files

The Solution

Define infrastructure ONCE in expressive Ruby. Metaprogramming generates everything else.

# This single definition...
host :vault do
  ip "10.50.1.100"
  port 8200
  role :secrets
  cert_path "/etc/ssl/certs/vault.pem"
end

# ...automatically generates:
# 1. AsciiDoc: vault-ip: 10.50.1.100
# 2. D2: vault: {label: "Vault\n10.50.1.100"}
# 3. Ansible: vault ansible_host=10.50.1.100
# 4. /etc/hosts: 10.50.1.100 vault.inside.domusdigitalis.dev

Project Phases

Phase 1: Learn Ruby Fundamentals

Topic Resource Status

Ruby basics

Try Ruby (browser)

[ ] Not Started

Classes and objects

Ruby Koans or Pragmatic Ruby

[ ] Not Started

Blocks, Procs, Lambdas

Critical for DSLs

[ ] Not Started

Metaprogramming basics

define_method, method_missing

[ ] Not Started

AsciiDoctor source

Read how extensions work

[ ] Not Started

Phase 2: Build Core DSL

Step 1: Basic Host Definition

# lib/infra_dsl/host.rb
module InfraDSL
  class Host
    attr_reader :name, :attributes

    def initialize(name, &block)
      @name = name
      @attributes = {}
      instance_eval(&block) if block_given?
    end

    # Metaprogramming: generate attribute methods dynamically
    %i[ip mac role vlan gateway dns].each do |attr|
      define_method(attr) do |value = nil|
        if value
          @attributes[attr] = value
        else
          @attributes[attr]
        end
      end
    end

    # Catch any undefined attributes
    def method_missing(method_name, *args, &block)
      if args.length == 1
        @attributes[method_name] = args.first
      elsif args.empty?
        @attributes[method_name]
      else
        super
      end
    end

    def respond_to_missing?(method_name, include_private = false)
      true
    end
  end
end

Step 2: Infrastructure Container

# lib/infra_dsl/infrastructure.rb
module InfraDSL
  class Infrastructure
    attr_reader :name, :hosts, :networks

    def initialize(name, &block)
      @name = name
      @hosts = {}
      @networks = {}
      instance_eval(&block) if block_given?
    end

    def host(name, &block)
      @hosts[name] = Host.new(name, &block)
    end

    def network(name, &block)
      @networks[name] = Network.new(name, &block)
    end

    # Generate outputs
    def to_asciidoc_attributes
      output = []
      @hosts.each do |name, host|
        host.attributes.each do |attr, value|
          output << "#{name}-#{attr}: #{value}"
        end
      end
      output.join("\n")
    end

    def to_ansible_inventory
      output = []
      @hosts.each do |name, host|
        line = "#{name} ansible_host=#{host.ip}"
        line += " ansible_user=#{host.attributes[:user]}" if host.attributes[:user]
        output << line
      end
      output.join("\n")
    end

    def to_d2
      # Generate D2 diagram source
      output = ["direction: right", ""]
      @hosts.each do |name, host|
        output << "#{name}: {"
        output << "  label: \"#{name}\\n#{host.ip}\""
        output << "  shape: rectangle"
        output << "}"
      end
      output.join("\n")
    end
  end
end

Step 3: Top-Level DSL Method

# lib/infra_dsl.rb
require_relative 'infra_dsl/host'
require_relative 'infra_dsl/network'
require_relative 'infra_dsl/infrastructure'

module InfraDSL
  def self.infrastructure(name, &block)
    Infrastructure.new(name, &block)
  end
end

# Make it available at top level
def infrastructure(name, &block)
  InfraDSL.infrastructure(name, &block)
end

Phase 3: Define Your Infrastructure

# domus-infrastructure.rb
require_relative 'lib/infra_dsl'

DOMUS = infrastructure :domus do

  # === ISE Cluster ===
  host :ise_01 do
    ip "10.50.1.20"
    mac "00:50:56:XX:XX:XX"
    role :ise_pan
    vlan 10
    services [:radius, :tacacs, :profiling]
    fqdn "ise-01.inside.domusdigitalis.dev"
  end

  host :ise_02 do
    ip "10.50.1.21"
    role :ise_psn
    vlan 10
    services [:radius, :profiling]
    fqdn "ise-02.inside.domusdigitalis.dev"
  end

  # === Security Stack ===
  host :vault do
    ip "10.50.1.100"
    port 8200
    role :secrets
    fqdn "vault.inside.domusdigitalis.dev"
  end

  host :wazuh_manager do
    ip "10.50.1.130"
    role :siem
    services [:wazuh_manager, :wazuh_api]
  end

  # === DNS ===
  host :bind_01 do
    ip "10.50.1.90"
    role :dns
    services [:bind, :dns]
  end

  # === Networks ===
  network :inside do
    cidr "10.50.1.0/24"
    vlan 10
    gateway "10.50.1.1"
    description "Internal management network"
  end

  network :dmz do
    cidr "10.50.2.0/24"
    vlan 20
    gateway "10.50.2.1"
    description "DMZ for external-facing services"
  end

end

Phase 4: Generators

AsciiDoc Attributes Generator

# generators/asciidoc.rb
class AsciidocGenerator
  def initialize(infra)
    @infra = infra
  end

  def generate
    output = ["# Auto-generated from domus-infrastructure.rb",
              "# DO NOT EDIT - regenerate with: ruby generate.rb asciidoc",
              ""]

    @infra.hosts.each do |name, host|
      output << "# #{name}"
      host.attributes.each do |attr, value|
        key = "#{name.to_s.gsub('_', '-')}-#{attr.to_s.gsub('_', '-')}"
        val = value.is_a?(Array) ? value.join(', ') : value
        output << "#{key}: #{val}"
      end
      output << ""
    end

    output.join("\n")
  end

  def write(path)
    File.write(path, generate)
    puts "Generated: #{path}"
  end
end

# Usage:
# AsciidocGenerator.new(DOMUS).write("antora-attributes.yml")

D2 Diagram Generator

# generators/d2.rb
class D2Generator
  def initialize(infra)
    @infra = infra
  end

  def generate_network_diagram
    output = ["# Auto-generated network diagram", "direction: right", ""]

    # Group hosts by role
    roles = @infra.hosts.values.group_by { |h| h.attributes[:role] }

    roles.each do |role, hosts|
      output << "#{role}: {"
      output << "  label: #{role.to_s.upcase}"
      output << "  style.fill: \"#e3f2fd\""
      output << ""

      hosts.each do |host|
        output << "  #{host.name}: {"
        output << "    label: \"#{host.name}\\n#{host.ip}\""
        output << "  }"
      end

      output << "}"
      output << ""
    end

    output.join("\n")
  end
end

Ansible Inventory Generator

# generators/ansible.rb
class AnsibleGenerator
  def initialize(infra)
    @infra = infra
  end

  def generate_ini
    output = ["# Auto-generated Ansible inventory", ""]

    # Group by role
    roles = @infra.hosts.values.group_by { |h| h.attributes[:role] }

    roles.each do |role, hosts|
      output << "[#{role}]"
      hosts.each do |host|
        line = "#{host.name} ansible_host=#{host.ip}"
        output << line
      end
      output << ""
    end

    output.join("\n")
  end

  def generate_yaml
    # YAML inventory format
    inventory = { 'all' => { 'children' => {} } }

    roles = @infra.hosts.values.group_by { |h| h.attributes[:role] }

    roles.each do |role, hosts|
      inventory['all']['children'][role.to_s] = {
        'hosts' => hosts.map { |h|
          [h.name.to_s, { 'ansible_host' => h.ip }]
        }.to_h
      }
    end

    require 'yaml'
    inventory.to_yaml
  end
end

Phase 5: CLI Tool

#!/usr/bin/env ruby
# bin/infra-gen
require_relative '../lib/infra_dsl'
require_relative '../domus-infrastructure'
require_relative '../generators/asciidoc'
require_relative '../generators/d2'
require_relative '../generators/ansible'

command = ARGV[0]

case command
when 'asciidoc'
  AsciidocGenerator.new(DOMUS).write('output/antora-attributes.adoc')

when 'd2'
  D2Generator.new(DOMUS).write('output/network.d2')

when 'ansible'
  AnsibleGenerator.new(DOMUS).write('output/inventory.ini')

when 'all'
  AsciidocGenerator.new(DOMUS).write('output/antora-attributes.adoc')
  D2Generator.new(DOMUS).write('output/network.d2')
  AnsibleGenerator.new(DOMUS).write('output/inventory.ini')
  puts "All outputs generated!"

when 'validate'
  puts "Validating infrastructure definition..."
  errors = []
  DOMUS.hosts.each do |name, host|
    errors << "#{name}: missing IP" unless host.ip
    errors << "#{name}: missing role" unless host.attributes[:role]
  end
  if errors.empty?
    puts "βœ“ Validation passed"
  else
    errors.each { |e| puts "βœ— #{e}" }
    exit 1
  end

else
  puts "Usage: infra-gen [asciidoc|d2|ansible|all|validate]"
end

Integration with Existing Workflow

With Antora/domus-* Repos

# Generate attributes, copy to each component
ruby bin/infra-gen asciidoc
cp output/antora-attributes.adoc ~/atelier/_bibliotheca/domus-infra-ops/docs/asciidoc/modules/ROOT/partials/
cp output/antora-attributes.adoc ~/atelier/_bibliotheca/domus-ise-linux/docs/asciidoc/modules/ROOT/partials/

# Then in antora.yml:
# asciidoc:
#   attributes:
#     include::partial$antora-attributes.adoc[]

With Make

# Makefile
.PHONY: generate docs

generate:
	ruby bin/infra-gen all

docs: generate
	cd ~/atelier/_bibliotheca/domus-docs && make

# Ensure docs always use fresh generated content

Git Hook (Pre-Commit)

#!/bin/bash
# .git/hooks/pre-commit

# Regenerate and check for drift
ruby bin/infra-gen all
git diff --exit-code output/

if [ $? -ne 0 ]; then
  echo "Infrastructure outputs have changed. Please commit generated files."
  exit 1
fi

Advanced Metaprogramming Ideas

Auto-Generate Verification Methods

class Host
  # For each service, generate a check method
  def services(list)
    @attributes[:services] = list

    list.each do |service|
      # Dynamically create: host.check_radius!, host.check_dns!, etc.
      define_singleton_method("check_#{service}!") do
        puts "Checking #{service} on #{@name} (#{ip})..."
        # Could actually run connectivity tests here
      end
    end
  end
end

# Usage:
ise_01.check_radius!
ise_01.check_tacacs!

DSL for Firewall Rules

host :webserver do
  ip "10.50.2.10"

  firewall do
    allow :ssh, from: :admin_network
    allow :https, from: :any
    deny :all
  end
end

# Generates firewalld or nftables rules

Relationship Mapping

host :app_server do
  ip "10.50.1.50"

  depends_on :database, port: 5432
  depends_on :redis, port: 6379
  authenticates_via :ise_01, protocol: :radius
end

# Auto-generates dependency diagrams
# Auto-validates connectivity
# Auto-generates required firewall rules

Learning Resources

Ruby Fundamentals

Metaprogramming

  • Metaprogramming Ruby 2 - THE book

  • Rails source code - activerecord/lib/active_record/associations.rb

  • AsciiDoctor extensions - asciidoctor/lib/asciidoctor/extensions.rb

DSL Design


Project Repository Structure

domus-infra-dsl/
β”œβ”€β”€ bin/
β”‚   └── infra-gen              # CLI tool
β”œβ”€β”€ lib/
β”‚   └── infra_dsl/
β”‚       β”œβ”€β”€ host.rb
β”‚       β”œβ”€β”€ network.rb
β”‚       β”œβ”€β”€ infrastructure.rb
β”‚       └── version.rb
β”œβ”€β”€ generators/
β”‚   β”œβ”€β”€ asciidoc.rb
β”‚   β”œβ”€β”€ d2.rb
β”‚   β”œβ”€β”€ ansible.rb
β”‚   β”œβ”€β”€ dns.rb
β”‚   └── firewall.rb
β”œβ”€β”€ output/                    # Generated files
β”œβ”€β”€ domus-infrastructure.rb    # Your infrastructure definition
β”œβ”€β”€ Gemfile
β”œβ”€β”€ Rakefile
└── README.adoc

Success Criteria

When this project is "done":

  • Define infrastructure in Ruby DSL

  • Generate AsciiDoc attributes for all domus-* repos

  • Generate D2 network diagrams

  • Generate Ansible inventory

  • Single make generate updates everything

  • Change IP in one place β†’ reflected everywhere

  • Write blog post / share with Ruby community


Connection to DHH / Rails Philosophy

This project embodies Rails principles:

Principle Application

Convention over Configuration

Standard attribute names, predictable output locations

DRY (Don’t Repeat Yourself)

Define once, generate many

Programmer Happiness

DSL reads like English, not YAML spaghetti

Metaprogramming for Expressiveness

host :vault do instead of hash literals

If you build this well and write about it, it’s exactly the kind of thing the Ruby community appreciates - practical metaprogramming solving real infrastructure problems.