Validate homelab inventory with YAML parser
This commit is contained in:
parent
889396eaa4
commit
9497b8dc86
47
jeannie
47
jeannie
|
|
@ -73,11 +73,26 @@ mapping = {
|
||||||
"pimox.default_worker_count": "LAB_PIMOX_WORKER_COUNT",
|
"pimox.default_worker_count": "LAB_PIMOX_WORKER_COUNT",
|
||||||
}
|
}
|
||||||
|
|
||||||
stack = []
|
def flatten(document, prefix=""):
|
||||||
values = {}
|
values = {}
|
||||||
pattern = re.compile(r"^(\s*)([A-Za-z_][A-Za-z0-9_]*):(?:\s*(.*?))?\s*$")
|
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:
|
|
||||||
|
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:
|
for raw_line in handle:
|
||||||
if not raw_line.strip() or raw_line.lstrip().startswith("#") or raw_line.lstrip().startswith("- "):
|
if not raw_line.strip() or raw_line.lstrip().startswith("#") or raw_line.lstrip().startswith("- "):
|
||||||
continue
|
continue
|
||||||
|
|
@ -89,14 +104,23 @@ with open(inventory_file, encoding="utf-8") as handle:
|
||||||
value = (match.group(3) or "").strip()
|
value = (match.group(3) or "").strip()
|
||||||
while stack and stack[-1][0] >= indent:
|
while stack and stack[-1][0] >= indent:
|
||||||
stack.pop()
|
stack.pop()
|
||||||
path = ".".join([item[1] for item in stack] + [key])
|
current_path = ".".join([item[1] for item in stack] + [key])
|
||||||
if value == "":
|
if value == "":
|
||||||
stack.append((indent, key))
|
stack.append((indent, key))
|
||||||
continue
|
continue
|
||||||
if " #" in value:
|
if " #" in value:
|
||||||
value = value.split(" #", 1)[0].strip()
|
value = value.split(" #", 1)[0].strip()
|
||||||
value = value.strip("\"'")
|
values[current_path] = value.strip("\"'")
|
||||||
values[path] = value
|
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():
|
for path, env_name in mapping.items():
|
||||||
value = values.get(path)
|
value = values.get(path)
|
||||||
|
|
@ -1302,6 +1326,10 @@ homelab_preflight() {
|
||||||
echo "${phase^} preflight checks passed."
|
echo "${phase^} preflight checks passed."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inventory_check() {
|
||||||
|
"${REPO_ROOT}/scripts/validate-homelab-inventory" "${HOMELAB_INVENTORY_FILE:-${REPO_ROOT}/homelab.yml}"
|
||||||
|
}
|
||||||
|
|
||||||
run_pimox_pipeline() {
|
run_pimox_pipeline() {
|
||||||
local mode="${LAB_PIMOX_PIPELINE:-true}"
|
local mode="${LAB_PIMOX_PIPELINE:-true}"
|
||||||
local pimox_host="${LAB_PIMOX_HOST:-${TF_VAR_pimox_host:-192.168.100.80}}"
|
local pimox_host="${LAB_PIMOX_HOST:-${TF_VAR_pimox_host:-192.168.100.80}}"
|
||||||
|
|
@ -5199,6 +5227,9 @@ case "${1:-}" in
|
||||||
preflight)
|
preflight)
|
||||||
homelab_preflight
|
homelab_preflight
|
||||||
;;
|
;;
|
||||||
|
inventory-check)
|
||||||
|
inventory_check
|
||||||
|
;;
|
||||||
fix-debian-docker-root)
|
fix-debian-docker-root)
|
||||||
fix_debian_docker_root
|
fix_debian_docker_root
|
||||||
;;
|
;;
|
||||||
|
|
@ -5224,7 +5255,7 @@ case "${1:-}" in
|
||||||
nuke
|
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
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,8 @@ main() {
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
"${REPO_ROOT}/scripts/validate-homelab-inventory" "${INVENTORY_FILE}"
|
||||||
|
|
||||||
main_script="$(read_inventory_value metadata.main_script)"
|
main_script="$(read_inventory_value metadata.main_script)"
|
||||||
if [[ -z "${main_script}" ]]; then
|
if [[ -z "${main_script}" ]]; then
|
||||||
printf 'homelab.yml metadata.main_script must not be empty\n' >&2
|
printf 'homelab.yml metadata.main_script must not be empty\n' >&2
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue