Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02994-7685-7940-bf34-3555b8256018
174 lines
6.1 KiB
Python
174 lines
6.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Validate the S1 host inventory without contacting a provider or host."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import ipaddress
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import yaml
|
|
|
|
|
|
SCHEMA_VERSION = "1.0"
|
|
PROVIDERS = {"hosteurope", "hetzner"}
|
|
LIFECYCLE_MODES = {"adopted", "provider-managed"}
|
|
BASELINE_PROFILES = {"ufw-managed", "external-firewall"}
|
|
HETZNER_REQUIRED = {"server_type", "region", "image", "role"}
|
|
HETZNER_OPTIONAL = {"labels"}
|
|
NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$")
|
|
USER_RE = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$")
|
|
|
|
|
|
class InventoryError(ValueError):
|
|
"""The inventory does not satisfy the S1 contract."""
|
|
|
|
|
|
def _nonempty_string(value: Any) -> bool:
|
|
return isinstance(value, str) and bool(value.strip())
|
|
|
|
|
|
def validate_inventory(payload: Any) -> dict[str, Any]:
|
|
if not isinstance(payload, dict):
|
|
raise InventoryError("inventory must be a YAML object")
|
|
if str(payload.get("schema_version")) != SCHEMA_VERSION:
|
|
raise InventoryError(f"schema_version must be {SCHEMA_VERSION!r}")
|
|
servers = payload.get("servers")
|
|
if not isinstance(servers, list) or not servers:
|
|
raise InventoryError("servers must be a non-empty list")
|
|
|
|
errors: list[str] = []
|
|
names: set[str] = set()
|
|
for index, server in enumerate(servers):
|
|
label = f"servers[{index}]"
|
|
if not isinstance(server, dict):
|
|
errors.append(f"{label} must be an object")
|
|
continue
|
|
name = server.get("name")
|
|
if not _nonempty_string(name) or not NAME_RE.fullmatch(name):
|
|
errors.append(f"{label}.name must be a stable host identifier")
|
|
name = label
|
|
elif name in names:
|
|
errors.append(f"{label}.name duplicates {name!r}")
|
|
else:
|
|
names.add(name)
|
|
label = str(name)
|
|
|
|
provider = server.get("provider")
|
|
lifecycle = server.get("lifecycle_mode")
|
|
profile = server.get("baseline_profile")
|
|
ssh_user = server.get("ssh_user")
|
|
if provider not in PROVIDERS:
|
|
errors.append(f"{label}: provider must be one of {sorted(PROVIDERS)}")
|
|
if lifecycle not in LIFECYCLE_MODES:
|
|
errors.append(
|
|
f"{label}: lifecycle_mode must be one of {sorted(LIFECYCLE_MODES)}"
|
|
)
|
|
if profile not in BASELINE_PROFILES:
|
|
errors.append(
|
|
f"{label}: baseline_profile must be one of {sorted(BASELINE_PROFILES)}"
|
|
)
|
|
if not _nonempty_string(ssh_user) or not USER_RE.fullmatch(ssh_user):
|
|
errors.append(f"{label}: ssh_user must be a valid Unix user name")
|
|
|
|
if lifecycle == "adopted":
|
|
address = server.get("ip")
|
|
try:
|
|
ipaddress.ip_address(address)
|
|
except (TypeError, ValueError):
|
|
errors.append(f"{label}: adopted hosts require a literal ip address")
|
|
if "provisioning" in server:
|
|
errors.append(
|
|
f"{label}: adopted hosts must not carry provider provisioning fields"
|
|
)
|
|
elif lifecycle == "provider-managed":
|
|
if provider != "hetzner":
|
|
errors.append(
|
|
f"{label}: provider-managed is currently implemented only for hetzner"
|
|
)
|
|
if "ip" in server:
|
|
errors.append(
|
|
f"{label}: provider-managed addresses come from provider output; remove ip"
|
|
)
|
|
provisioning = server.get("provisioning")
|
|
if not isinstance(provisioning, dict):
|
|
errors.append(f"{label}: provider-managed hosts require provisioning")
|
|
else:
|
|
missing = sorted(
|
|
key
|
|
for key in HETZNER_REQUIRED
|
|
if not _nonempty_string(provisioning.get(key))
|
|
)
|
|
unknown = sorted(
|
|
set(provisioning) - HETZNER_REQUIRED - HETZNER_OPTIONAL
|
|
)
|
|
if missing:
|
|
errors.append(
|
|
f"{label}: provisioning missing {', '.join(missing)}"
|
|
)
|
|
if unknown:
|
|
errors.append(
|
|
f"{label}: provisioning has unknown fields {', '.join(unknown)}"
|
|
)
|
|
labels = provisioning.get("labels", [])
|
|
if not isinstance(labels, list) or not all(
|
|
_nonempty_string(item) for item in labels
|
|
):
|
|
errors.append(
|
|
f"{label}: provisioning.labels must be a list of strings"
|
|
)
|
|
|
|
if errors:
|
|
raise InventoryError("inventory contract failed:\n- " + "\n- ".join(errors))
|
|
return payload
|
|
|
|
|
|
def load_inventory(path: Path) -> dict[str, Any]:
|
|
try:
|
|
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
except (OSError, yaml.YAMLError) as exc:
|
|
raise InventoryError(f"cannot read {path}: {exc}") from exc
|
|
return validate_inventory(payload)
|
|
|
|
|
|
def managed_hetzner_servers(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
|
validate_inventory(payload)
|
|
return [
|
|
server
|
|
for server in payload["servers"]
|
|
if server["provider"] == "hetzner"
|
|
and server["lifecycle_mode"] == "provider-managed"
|
|
]
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"inventory", nargs="?", type=Path, default=Path("inventory/servers.yaml")
|
|
)
|
|
parser.add_argument(
|
|
"--managed-hetzner", action="store_true", help="print selected names"
|
|
)
|
|
args = parser.parse_args()
|
|
try:
|
|
payload = load_inventory(args.inventory)
|
|
except InventoryError as exc:
|
|
print(exc, file=sys.stderr)
|
|
return 1
|
|
selected = managed_hetzner_servers(payload)
|
|
result = {
|
|
"ok": True,
|
|
"schema_version": SCHEMA_VERSION,
|
|
"server_count": len(payload["servers"]),
|
|
"managed_hetzner": [server["name"] for server in selected],
|
|
}
|
|
print(json.dumps(result, sort_keys=True))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|