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 |
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 |
|
[ ] 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
-
Try Ruby - Browser-based intro
-
Ruby Koans - Learn through testing
Metaprogramming
-
Metaprogramming Ruby 2 - THE book
-
Rails source code -
activerecord/lib/active_record/associations.rb -
AsciiDoctor extensions -
asciidoctor/lib/asciidoctor/extensions.rb
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 generateupdates 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 |
|
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.
Related
-
RHCSA Study - Finish this first
-
Metaprogramming Notes (future - create when learning Ruby)
-
Domus Documentation - Where this will be used