sites: Inventar und Zielbild an echte Knoten (pve44–pve61) angepasst; ADR-0001/0002/0006 ergänzt
ci / test (push) Successful in 25s

Aufbauplan Task 0.4:
- inventory.yaml: pve44/46/48/52/61 und GODE-PC-05/06 (Ada, Windows, cpu_roles: false)
- proxmox.yaml: eine GPU-VM pro Knoten mit allen Karten (D2), local-lvm/tank (D1 B),
  192.168.100.0/24 ohne VLAN, Gast-IPs .220–.239 (ANNAHME bis Task 0.2)
- registry.yaml/prometheus.yml: ops-Labels auf gpu-pve44/46/48, Ada ohne pve_node/vmid
- tofu: dynamische hostpci, Speicher je Gast; Ansible: pve_gpu_hosts/pve_kiosk,
  Replikation auf mehrere Ziele
- Placement: cpu_roles=false schließt Arbeitsplätze/Kiosk von CPU-Rollen aus
- PLAN §1.3/§1.4/§9 und ADR-Nachträge (D1 entschieden, D2/D3 Bestätigung offen)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 01:39:49 +02:00
co-authored by Claude Opus 5.5
parent 8cf27b48ce
commit c81e372c49
20 changed files with 343 additions and 190 deletions
+1 -1
View File
@@ -182,7 +182,7 @@ def plan(nodes: list[NodeProfile], roles: list[RoleSpec], models: list[ModelSpec
missing = role.count - placed
if missing and (not role.needs.gpu or role.cpu_fallback):
mode: Literal["cpu", "cpu_fallback"] = "cpu" if not role.needs.gpu else "cpu_fallback"
candidates = [n for n in nodes if n.memory_gb >= role.needs.min_ram_gb]
candidates = [n for n in nodes if n.cpu_roles and n.memory_gb >= role.needs.min_ram_gb]
# bevorzugt Nodes ohne GPU-Rollen, dann wenig belastete, dann viel RAM
gpu_roles = {a.node for a in result.assignments if a.mode == "gpu"}
candidates.sort(key=lambda n: (n.name in gpu_roles, cpu_load[n.name], -n.memory_gb, n.name))
+1
View File
@@ -43,6 +43,7 @@ class NodeProfile(BaseModel):
disks: list[Disk] = Field(default_factory=list)
gpus: list[GPU] = Field(default_factory=list)
labels: dict[str, str] = Field(default_factory=dict)
cpu_roles: bool = True # False: Arbeitsplatz/Kiosk – keine Infra- oder CPU-Fallback-Rollen, nur eigene GPUs
NVIDIA_SMI_QUERY = ["nvidia-smi", "--query-gpu=index,name,memory.total,compute_cap,driver_version",
+23 -11
View File
@@ -24,7 +24,7 @@ def test_healer_labels_available_on_alerts():
"""Labels, die Healer-Regeln brauchen, müssen von Metrik oder Scrape-Target kommen."""
prom = yaml.safe_load((DEPLOY / "prometheus/prometheus.yml").read_text())
jobs = {j["job_name"]: j for j in prom["scrape_configs"]}
for job in ("node", "gpu", "vllm"):
for job in ("node", "gpu", "llamacpp"):
for sc in jobs[job]["static_configs"]:
assert {"slot", "pve_node", "vmid"} <= set(sc["labels"]), (job, sc)
from knaster.canary import OPS_LABELS
@@ -46,13 +46,17 @@ def test_compose_files_parse(role):
def test_ops_labels_consistent_between_registry_and_prometheus():
"""Das erste Scrape-Ziel eines Slots trägt dieselben ops-Labels wie die Registry (Healer-Ziel)."""
reg = yaml.safe_load((REPO_ROOT / "sites/knaster/registry.yaml").read_text())
prom = yaml.safe_load((DEPLOY / "prometheus/prometheus.yml").read_text())
vllm = {sc["labels"]["slot"]: sc["labels"] for sc in
next(j for j in prom["scrape_configs"] if j["job_name"] == "vllm")["static_configs"]}
for slot, labels in vllm.items():
first: dict[str, dict] = {}
for sc in next(j for j in prom["scrape_configs"] if j["job_name"] == "llamacpp")["static_configs"]:
first.setdefault(sc["labels"]["slot"], sc["labels"])
assert first
for slot, labels in first.items():
ops = reg["slots"][slot]["ops"]
assert ops["vmid"] == labels["vmid"] and ops["container"] == labels["container"]
assert {k: ops[k] for k in ("docker_host", "container", "pve_node", "vmid")} == \
{k: labels[k] for k in ("docker_host", "container", "pve_node", "vmid")}, slot
WORKFLOWS = sorted((REPO_ROOT / "n8n/workflows").glob("*.json"))
@@ -160,19 +164,27 @@ def test_proxmox_site_consistent_with_registry_and_prometheus():
reg = yaml.safe_load((REPO_ROOT / "sites/knaster/registry.yaml").read_text())
vms = site["vms"]
for vm in vms.values():
assert vm["gpu"] in site["pci_mappings"]
assert site["pci_mappings"][vm["gpu"]]["node"] == vm["node"] # Mapping liegt auf dem Host der VM
for gpu in vm["gpus"]:
assert gpu in site["pci_mappings"]
assert site["pci_mappings"][gpu]["node"] == vm["node"] # Mapping liegt auf dem Host der VM
assert vm["driver_generation"] in {"legacy", "modern"}
gpus = [vm["gpu"] for vm in vms.values()]
assert vm["node"] in site["nodes"] and site["nodes"][vm["node"]]["gpu_host"]
gpus = [g for vm in vms.values() for g in vm["gpus"]]
assert len(gpus) == len(set(gpus)) # keine GPU doppelt durchgereicht
assert set(gpus) == set(site["pci_mappings"]) # jede gemappte Karte steckt in einer VM
by_vmid = {str(vm["vmid"]): (name, vm) for name, vm in {**vms, **site["containers"]}.items()}
for slot, spec in reg["slots"].items():
ops = spec.get("ops")
if not ops:
ops = spec.get("ops") or {}
if "vmid" not in ops: # z. B. Ada-Worker unter Windows (D3), kein Proxmox-Gast
assert "pve_node" not in ops, slot
continue
name, vm = by_vmid[ops["vmid"]]
assert vm["node"] == ops["pve_node"], slot
assert ops["docker_host"].startswith(name.split("-")[0]), slot
assert ops["docker_host"] == name, slot
for c in site["containers"].values():
if c.get("ha"):
assert site["nodes"][c["node"]]["zfs_tank"] and c["storage"] == "tank"
assert all(site["nodes"][t]["zfs_tank"] for t in c["replicate_to"])
vmids = [str(v["vmid"]) for v in vms.values()] + [str(c["vmid"]) for c in site["containers"].values()]
assert len(vmids) == len(set(vmids))
# vLLM nur auf modernen Karten
+43 -2
View File
@@ -28,29 +28,70 @@ def test_parsers():
assert disks[0].size_gb == 256.1 and not disks[0].rotational
PVE_GPU_HOSTS = {"pve44", "pve46", "pve48", "pve52"}
def test_site_knaster_matches_real_nodes():
"""Aufbauplan Task 0.4: Site-Dateien beschreiben die echten Knoten, nicht mehr die Annahmen pve-ada/pve1–4."""
import ipaddress
from knaster.util import load_yaml
nodes = {n.name: n for n in load_nodes(REPO_ROOT / "sites/knaster/inventory.yaml")}
assert set(nodes) == {"pve44", "pve46", "pve48", "pve52", "pve61", "gode-pc-05", "gode-pc-06"}
pve = load_yaml(REPO_ROOT / "sites/knaster/proxmox.yaml")
old_net = ipaddress.ip_network("10.20.0.0/24")
ips = []
for section in ("vms", "containers"):
ips += [ipaddress.ip_interface(g["ip"]).ip for g in pve[section].values()]
ips += [ipaddress.ip_address(pve["cluster"][k]) for k in ("gateway", "dns")]
assert not [ip for ip in ips if ip in old_net]
assert {m["node"] for m in pve["pci_mappings"].values()} <= PVE_GPU_HOSTS
assert {v["node"] for v in pve["vms"].values()} <= PVE_GPU_HOSTS
# eine GPU-VM pro GPU-Knoten mit allen Karten des Knotens (D2)
assert sorted(v["node"] for v in pve["vms"].values()) == sorted(PVE_GPU_HOSTS)
for vm in pve["vms"].values():
mapped = {name for name, m in pve["pci_mappings"].items() if m["node"] == vm["node"]}
assert set(vm["gpus"]) == mapped
gpus_inv = [g for g in nodes[vm["node"]].gpus]
assert len(vm["gpus"]) == len(gpus_inv)
# Ada-Rechner bleiben Windows-Arbeitsplätze (D3): keine Infra-/CPU-Rollen
assert not nodes["gode-pc-05"].cpu_roles and not nodes["pve61"].cpu_roles
def test_placement_knaster():
roles, models = load_catalog(REPO_ROOT / "catalog")
p = plan(load_nodes(REPO_ROOT / "sites/knaster/inventory.yaml"), roles, models)
by_role = {}
for a in p.assignments:
by_role.setdefault(a.role, []).append(a)
assert {by_role["gpu-main"][0].node, by_role["gpu-flex"][0].node} == {"gode-pc-05", "gode-pc-06"}
assert by_role["gpu-main"][0].gpu_name.startswith("NVIDIA RTX 4000 Ada")
assert "qwen3-14b-awq" in by_role["gpu-main"][0].models_fitting
embed_nodes = {a.node for a in by_role["gpu-embed"]}
assert len(embed_nodes) == 2 # Anti-Affinität
assert all("P2000" in a.gpu_name for a in by_role["gpu-embed"] + by_role["gpu-small"])
assert by_role["infra"][0].mode == "cpu"
assert by_role["infra"][0].mode == "cpu" and by_role["infra"][0].node in PVE_GPU_HOSTS
assert not p.unplaced
# M4000 bewusst außer Betrieb (ADR-0006): weder frei noch belegt
assert p.free_gpus == []
assert len(p.disabled_gpus) == 4 and all("M4000" in g for g in p.disabled_gpus)
tts = {a.role: a for a in p.assignments if a.role.startswith("tts")}
assert tts["tts-hq"].mode == "shared" and tts["tts-hq"].node == "pve-ada"
assert tts["tts-hq"].mode == "shared" and tts["tts-hq"].node == by_role["gpu-flex"][0].node
assert "qwen3-tts-1.7b" in tts["tts-hq"].models_fitting
assert tts["tts-cpu"].node == by_role["infra"][0].node
assert tts["tts-cpu"].models_fitting == ["piper-de-thorsten-medium"]
def test_nodes_without_cpu_roles_get_no_infra():
"""Arbeitsplätze und Kiosk nehmen keine CPU-Rollen, auch wenn sie den meisten RAM haben."""
roles, models = load_catalog(REPO_ROOT / "catalog")
nodes = [NodeProfile(name="workstation", memory_gb=64, cpu_roles=False),
NodeProfile(name="server", memory_gb=32)]
p = plan(nodes, roles, models)
assert {a.node for a in p.assignments if a.mode in ("cpu", "cpu_fallback")} == {"server"}
def test_placement_other_cluster():
roles, models = load_catalog(REPO_ROOT / "catalog")
p = plan(load_nodes(REPO_ROOT / "sites/example-single-gpu/inventory.yaml"), roles, models)