my-homelab-configs/scripts/validate-homelab-inventory

235 lines
6.2 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
INVENTORY_FILE="${1:-${REPO_ROOT}/homelab.yml}"
if [[ ! -s "${INVENTORY_FILE}" ]]; then
printf 'Missing homelab inventory: %s\n' "${INVENTORY_FILE}" >&2
exit 1
fi
if command -v python3 >/dev/null 2>&1 &&
python3 - "${INVENTORY_FILE}" <<'PY'
import ipaddress
import sys
try:
import yaml
except ImportError:
raise SystemExit(42)
inventory_file = sys.argv[1]
with open(inventory_file, encoding="utf-8") as handle:
document = yaml.safe_load(handle)
failures = []
def get(path):
value = document
for part in path.split("."):
if not isinstance(value, dict) or part not in value:
return None
value = value[part]
return value
def require(path, expected_type=str):
value = get(path)
if value is None or value == "":
failures.append(f"{path} is required")
return None
if expected_type is not None and not isinstance(value, expected_type):
failures.append(f"{path} must be {expected_type.__name__}, got {type(value).__name__}")
return None
return value
if not isinstance(document, dict):
failures.append("inventory root must be a YAML mapping")
else:
for path in (
"metadata.main_script",
"domain.base",
"domain.public_url",
"domain.gitea_url",
"network.lan_cidr",
"network.lan_ip_prefix",
"network.metallb.traefik_ip",
"hosts.debian.lan_ip",
"hosts.debian.tailscale_ip",
"hosts.debian.docker_root",
"hosts.rpi4.lan_ip",
"hosts.rpi4.tailscale_ip",
"hosts.rpi4.docker_root",
"hosts.opi5_pimox.lan_ip",
"hosts.opi5_pimox.worker_storage",
"hosts.oci_edge.public_ip",
"services.gitea.root_url",
"services.heimdall.public_url",
"services.local_registry.endpoint",
"services.traefik.load_balancer_ip",
):
require(path)
require("pimox.default_worker_count", None)
require("services.heimdall.http_port", None)
subdomains = require("domain.subdomains", list)
if isinstance(subdomains, list):
for index, value in enumerate(subdomains):
if not isinstance(value, str) or not value:
failures.append(f"domain.subdomains[{index}] must be a non-empty string")
for path in (
"network.lan_cidr",
"network.metallb.traefik_ip",
"hosts.debian.lan_ip",
"hosts.debian.tailscale_ip",
"hosts.rpi4.lan_ip",
"hosts.rpi4.tailscale_ip",
"hosts.opi5_pimox.lan_ip",
"hosts.oci_edge.public_ip",
"services.traefik.load_balancer_ip",
):
value = get(path)
if not value:
continue
try:
if path.endswith("lan_cidr"):
ipaddress.ip_network(str(value), strict=False)
else:
ipaddress.ip_address(str(value))
except ValueError:
failures.append(f"{path} is not a valid IP address/CIDR: {value}")
worker_count = get("pimox.default_worker_count")
if worker_count is not None:
try:
if int(worker_count) < 0:
failures.append("pimox.default_worker_count must be non-negative")
except (TypeError, ValueError):
failures.append("pimox.default_worker_count must be an integer")
if failures:
for failure in failures:
print(f"inventory error: {failure}", file=sys.stderr)
raise SystemExit(1)
print(f"inventory ok: {inventory_file}")
PY
then
exit 0
else
status=$?
if [[ "${status}" != "42" ]]; then
exit "${status}"
fi
fi
if command -v ruby >/dev/null 2>&1; then
ruby - "${INVENTORY_FILE}" <<'RUBY'
require "ipaddr"
require "yaml"
inventory_file = ARGV.fetch(0)
document = YAML.load_file(inventory_file)
failures = []
def get(document, path)
path.split(".").reduce(document) do |value, part|
return nil unless value.is_a?(Hash) && value.key?(part)
value[part]
end
end
required_paths = %w[
metadata.main_script
domain.base
domain.public_url
domain.gitea_url
network.lan_cidr
network.lan_ip_prefix
network.metallb.traefik_ip
hosts.debian.lan_ip
hosts.debian.tailscale_ip
hosts.debian.docker_root
hosts.rpi4.lan_ip
hosts.rpi4.tailscale_ip
hosts.rpi4.docker_root
hosts.opi5_pimox.lan_ip
hosts.opi5_pimox.worker_storage
hosts.oci_edge.public_ip
services.gitea.root_url
services.heimdall.http_port
services.heimdall.public_url
services.local_registry.endpoint
services.traefik.load_balancer_ip
pimox.default_worker_count
]
unless document.is_a?(Hash)
failures << "inventory root must be a YAML mapping"
else
required_paths.each do |path|
value = get(document, path)
failures << "#{path} is required" if value.nil? || value == ""
end
subdomains = get(document, "domain.subdomains")
if !subdomains.is_a?(Array)
failures << "domain.subdomains must be Array"
else
subdomains.each_with_index do |value, index|
failures << "domain.subdomains[#{index}] must be a non-empty string" unless value.is_a?(String) && !value.empty?
end
end
%w[
network.metallb.traefik_ip
hosts.debian.lan_ip
hosts.debian.tailscale_ip
hosts.rpi4.lan_ip
hosts.rpi4.tailscale_ip
hosts.opi5_pimox.lan_ip
hosts.oci_edge.public_ip
services.traefik.load_balancer_ip
].each do |path|
value = get(document, path)
next if value.nil? || value == ""
begin
IPAddr.new(value.to_s)
rescue ArgumentError
failures << "#{path} is not a valid IP address: #{value}"
end
end
cidr = get(document, "network.lan_cidr")
begin
IPAddr.new(cidr.to_s) if cidr
rescue ArgumentError
failures << "network.lan_cidr is not a valid CIDR: #{cidr}"
end
worker_count = get(document, "pimox.default_worker_count")
failures << "pimox.default_worker_count must be non-negative" if worker_count && worker_count.to_i < 0
end
if failures.any?
failures.each { |failure| warn "inventory error: #{failure}" }
exit 1
end
puts "inventory ok: #{inventory_file}"
RUBY
exit 0
fi
cat >&2 <<'EOF'
No real YAML parser is available.
Install python3-yaml or ruby, then rerun scripts/validate-homelab-inventory.
EOF
exit 1