#!/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