diff --git a/jeannie b/jeannie index 53e4be7..f270527 100755 --- a/jeannie +++ b/jeannie @@ -73,30 +73,54 @@ mapping = { "pimox.default_worker_count": "LAB_PIMOX_WORKER_COUNT", } -stack = [] -values = {} -pattern = re.compile(r"^(\s*)([A-Za-z_][A-Za-z0-9_]*):(?:\s*(.*?))?\s*$") +def flatten(document, prefix=""): + values = {} + if isinstance(document, dict): + for key, value in document.items(): + path = f"{prefix}.{key}" if prefix else str(key) + if isinstance(value, dict): + values.update(flatten(value, path)) + elif isinstance(value, list): + values[path] = ",".join(str(item) for item in value) + elif value is not None: + values[path] = str(value) + return values -with open(inventory_file, encoding="utf-8") as handle: - for raw_line in handle: - if not raw_line.strip() or raw_line.lstrip().startswith("#") or raw_line.lstrip().startswith("- "): - continue - match = pattern.match(raw_line.rstrip("\n")) - if not match: - continue - indent = len(match.group(1)) - key = match.group(2) - value = (match.group(3) or "").strip() - while stack and stack[-1][0] >= indent: - stack.pop() - path = ".".join([item[1] for item in stack] + [key]) - if value == "": - stack.append((indent, key)) - continue - if " #" in value: - value = value.split(" #", 1)[0].strip() - value = value.strip("\"'") - values[path] = value + +def parse_simple_inventory(path): + stack = [] + values = {} + pattern = re.compile(r"^(\s*)([A-Za-z_][A-Za-z0-9_]*):(?:\s*(.*?))?\s*$") + + with open(path, encoding="utf-8") as handle: + for raw_line in handle: + if not raw_line.strip() or raw_line.lstrip().startswith("#") or raw_line.lstrip().startswith("- "): + continue + match = pattern.match(raw_line.rstrip("\n")) + if not match: + continue + indent = len(match.group(1)) + key = match.group(2) + value = (match.group(3) or "").strip() + while stack and stack[-1][0] >= indent: + stack.pop() + current_path = ".".join([item[1] for item in stack] + [key]) + if value == "": + stack.append((indent, key)) + continue + if " #" in value: + value = value.split(" #", 1)[0].strip() + values[current_path] = value.strip("\"'") + return values + + +try: + import yaml +except ImportError: + values = parse_simple_inventory(inventory_file) +else: + with open(inventory_file, encoding="utf-8") as handle: + values = flatten(yaml.safe_load(handle) or {}) for path, env_name in mapping.items(): value = values.get(path) @@ -1302,6 +1326,10 @@ homelab_preflight() { echo "${phase^} preflight checks passed." } +inventory_check() { + "${REPO_ROOT}/scripts/validate-homelab-inventory" "${HOMELAB_INVENTORY_FILE:-${REPO_ROOT}/homelab.yml}" +} + run_pimox_pipeline() { local mode="${LAB_PIMOX_PIPELINE:-true}" local pimox_host="${LAB_PIMOX_HOST:-${TF_VAR_pimox_host:-192.168.100.80}}" @@ -5199,6 +5227,9 @@ case "${1:-}" in preflight) homelab_preflight ;; + inventory-check) + inventory_check + ;; fix-debian-docker-root) fix_debian_docker_root ;; @@ -5224,7 +5255,7 @@ case "${1:-}" in nuke ;; *) - echo "Usage: $0 {up|plan [all|provisioning|cluster|platform|apps|edge]|rebuild-cluster|stop-cluster|start-cluster|status|apps|website-translation-model|website-ollama-listen|ollama-setup|deploy-gitea|rpi-services|bootstrap-gitea-repo|backup-gitea|drill-gitea-restore|install-gitea-runner|move-prometheus-stack-workers|doctor-versions|doctor-edge|doctor-gitea|doctor-rpi|doctor-cluster|preflight|fix-debian-docker-root|secrets-init|secrets-check|tailnet-policy-check|ai-index|ai-check|openwrt|nuke}" + echo "Usage: $0 {up|plan [all|provisioning|cluster|platform|apps|edge]|rebuild-cluster|stop-cluster|start-cluster|status|apps|website-translation-model|website-ollama-listen|ollama-setup|deploy-gitea|rpi-services|bootstrap-gitea-repo|backup-gitea|drill-gitea-restore|install-gitea-runner|move-prometheus-stack-workers|doctor-versions|doctor-edge|doctor-gitea|doctor-rpi|doctor-cluster|preflight|inventory-check|fix-debian-docker-root|secrets-init|secrets-check|tailnet-policy-check|ai-index|ai-check|openwrt|nuke}" exit 1 ;; esac diff --git a/scripts/render-docs b/scripts/render-docs index 2424bd0..ae1dd7b 100755 --- a/scripts/render-docs +++ b/scripts/render-docs @@ -99,6 +99,8 @@ main() { return 1 fi + "${REPO_ROOT}/scripts/validate-homelab-inventory" "${INVENTORY_FILE}" + main_script="$(read_inventory_value metadata.main_script)" if [[ -z "${main_script}" ]]; then printf 'homelab.yml metadata.main_script must not be empty\n' >&2 diff --git a/scripts/validate-homelab-inventory b/scripts/validate-homelab-inventory new file mode 100755 index 0000000..54763cf --- /dev/null +++ b/scripts/validate-homelab-inventory @@ -0,0 +1,228 @@ +#!/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.opi5_pimox.lan_ip", + "hosts.opi5_pimox.worker_storage", + "hosts.oci_edge.public_ip", + "services.gitea.root_url", + "services.local_registry.endpoint", + "services.traefik.load_balancer_ip", + ): + require(path) + require("pimox.default_worker_count", 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.opi5_pimox.lan_ip + hosts.opi5_pimox.worker_storage + hosts.oci_edge.public_ip + services.gitea.root_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