Compare commits

86 Commits
Author SHA1 Message Date
Robert Rapp 3b110d3d3f known-issues: Ontologie-Test unter Windows, auseinanderlaufende Branches
ci / test (push) Failing after 23s
2026-10-19 19:25:21 +02:00
Robert RappandClaude Opus 5.5 4a1bda3409 Merge main in plan/aufbau-2026-10-12: KNOW-Analyse, Ontologie, cpu_roles
ci / test (push) Failing after 22s
main (bis 13.10.) enthielt eine parallele Fassung von Task 0.4 (c81e372) sowie
KNOW-Befunde und die Ontologie. Konflikte zugunsten des Plan-Branches gelöst
(ausgerollter Stand); übernommen aus main: Ontologie (core/src/knaster/ontology,
ontology/, Tests, Router-Image), KNOW-Doku, cpu_roles im Placement samt
Kennzeichnung von pve61 und gode-pc-05/06 im Inventar und Test.
infra-stack kopiert jetzt auch ontology/ nach infra (Router-Build braucht es).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 19:23:22 +02:00
Robert RappandClaude Opus 5.5 c4ae5b2494 docs: Notfall-Übergabe der Verwaltung (für die Geschäftsführung)
ci / test (push) Failing after 23s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 19:14:55 +02:00
Robert RappandClaude Opus 5.5 50acfa6a95 Verwaltung durch Personen (Weg 1), AGENTS.md, project-map.md, known-issues.md
ci / test (push) Failing after 24s
Admin-Schlüssel je Person auf ops (Bastion) aus sites/knaster/proxmox.yaml
(access.ops_ssh_keys/_revoked), Playbook admins.yml, Test, Übergabe in
BETRIEB §15. Einstieg für Agenten und neue Admins: AGENTS.md, Projektkarte,
bekannte Probleme. Weg 2 (Verwaltung über NEX) als Entwurf in Phase 9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:54:02 +02:00
Robert RappandClaude Opus 5.5 039f57a435 Plan 6.6: Namespace nex mit Pod Security, Sprachdienste nach nex-speech
ci / test (push) Failing after 25s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:00:57 +02:00
Robert RappandClaude Opus 5.5 f9036934c3 docs: llama.cpp in k3s (Task 6.1), Device Plugin nur M4000, VRAM-Befund
ci / test (push) Failing after 22s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:49:12 +02:00
Robert RappandClaude Opus 5.5 862825fa1e llama.cpp: Embedding mit -ub 4096 (ub 8192 braucht 5 GB Rechenpuffer auf der P2000)
ci / test (push) Failing after 22s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:44:15 +02:00
Robert RappandClaude Opus 5.5 986f1116fe k3s: llama.cpp-Server als Deployments (Task 6.1)
ci / test (push) Failing after 22s
Ein Pod je GPU-VM (Embed+Rerank bzw. Chat), Host-Netz mit den Ports aus dem
Compose, P2000 fest per UUID (NVIDIA_VISIBLE_DEVICES). Das Device Plugin
vergibt nur noch Index 0 (M4000). Rolle gpu-llamacpp: gemeinsame Vorbereitung
(prepare.yml), k3s.yml mit Image-Import und Compose-Stopp; gpu-llamacpp.yml
bricht ohne -e llamacpp_compose=true ab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:27:23 +02:00
Robert RappandClaude Opus 5.5 24f4066aaf Plan 0.2: offene Reservierungen am 19.10. erneut angefragt
ci / test (push) Failing after 22s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:20:39 +02:00
Robert RappandClaude Opus 5.5 d875a9620e Plan 0.2: Laptop im Dock mit .181 bestätigt
ci / test (push) Failing after 22s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:12:58 +02:00
Robert RappandClaude Opus 5.5 a6d598be76 Plan 6.5: Prüfung Pod-Monitoring und Alert-Test eingetragen
ci / test (push) Failing after 21s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:10:57 +02:00
Robert RappandClaude Opus 5.5 09a4e44761 alerts: K3sPodCrashLooping über max_over_time (Alert flackerte zwischen Neustarts)
ci / test (push) Failing after 22s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:55:02 +02:00
Robert RappandClaude Opus 5.5 4ac251715e k3s: Pod-Monitoring in Grafana (Task 6.5)
ci / test (push) Failing after 23s
kube-state-metrics (NodePort 30808) und ServiceAccount prometheus-infra (nur
nodes/metrics) im Cluster; Prometheus auf infra liest kubelet/cAdvisor per TLS
gegen die k3s-CA. Ziele per file_sd aus kubectl get nodes, cAdvisor auf die
nötigen Reihen begrenzt. Dashboard "k3s – Pods" (Vorgabe nex), Alerts K3s*
nur mit notify. Playbook monitoring-k3s.yml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:31:03 +02:00
Robert RappandClaude Fable 5.1 abf4a75063 Plan Phase 6.0: Agenten im Cluster (nex-admin mit k3s-Laufzeit)
ci / test (push) Failing after 24s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 15:09:55 +02:00
Robert RappandClaude Fable 5.1 287a4a2c73 Brücke nex-know: Host-Port 14200 auf appsrv08
ci / test (push) Failing after 22s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 14:46:04 +02:00
Robert RappandClaude Fable 5.1 39cd150230 Brücke nex: EndpointSlice statt Endpoints, DNS-Prüfung mit getent
ci / test (push) Failing after 21s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 13:45:57 +02:00
Robert RappandClaude Fable 5.1 31f6435c2f Namespace nex: Brücke zu appsrv08, Zugang für nex-admin, Pull-Secret
ci / test (push) Failing after 22s
Vorbereitung für Agenten im Cluster (nex-Spec 2026-10-19, D259/D263/D265):
Services ohne Selector auf die Host-Ports von appsrv08, ServiceAccount mit
Role nur für den Namespace nex, Pull-Secret aus einer Datei auf ops.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 13:45:18 +02:00
Robert RappandClaude Fable 5.1 02adb2f4b2 docs: Portainer CE im Cluster, Übernahme der Datenbank, offene Admin-Schritte
ci / test (push) Failing after 25s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 18:48:49 +02:00
Robert RappandClaude Fable 5.1 d27385e3e1 portainer-k3s: Prüfung über /api/system/status (ohne Anmeldung)
ci / test (push) Failing after 23s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 18:47:52 +02:00
Robert RappandClaude Fable 5.1 4d31dda0e5 k3s: Portainer CE mit übernommener Datenbank von appsrv08 (Manifest + Playbook mit Rollback BE→CE)
ci / test (push) Failing after 24s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 18:45:37 +02:00
Robert RappandClaude Fable 5.1 26e1079f3d docs: Zuteilung der GPU-VMs (8 vCPU / 20 GB), ARC 2 GB, Vorgehen bei Änderung
ci / test (push) Successful in 29s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 17:30:25 +02:00
Robert RappandClaude Fable 5.1 2a8badd610 GPU-VMs 8 vCPU / 20 GB, infra 4 GB, ZFS-ARC 2 GB (Rolle pve-host), kein Auto-Reboot durch tofu
ci / test (push) Successful in 29s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 17:26:00 +02:00
Robert RappandClaude Fable 5.1 4cd3fb87ed docs: RustDesk in k3s (BETRIEB §14, README, Plan), SearXNG-Cutover Weg 2
ci / test (push) Successful in 28s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 14:47:58 +02:00
Robert RappandClaude Fable 5.1 8b1f2a7e95 rustdesk: Schlüsselprüfung über das Secret statt exec
ci / test (push) Successful in 29s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 14:47:15 +02:00
Robert RappandClaude Fable 5.1 b930d658ed rustdesk: Image ohne Shell – Relay-Adresse per $(RELAY_HOST), Prüfung über Secret
ci / test (push) Successful in 28s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 14:46:26 +02:00
Robert RappandClaude Fable 5.1 d11e2d90fc k3s: RustDesk-Server (hbbs/hbbr) als Manifest und Playbook, parallel zu appsrv08
ci / test (push) Successful in 26s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 14:41:00 +02:00
Robert RappandClaude Fable 5.1 eafebaab94 docs: SearXNG-Umzug in BETRIEB §14 und Plan
ci / test (push) Successful in 27s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 12:37:15 +02:00
Robert RappandClaude Fable 5.1 7d1bdf70e0 k3s: NEX-Websuche (SearXNG) als Umzugstest von appsrv08, NodePort 30880
ci / test (push) Successful in 28s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 12:36:28 +02:00
Robert RappandClaude Fable 5.1 9c4e3f1a2f docs: k3s/Portainer (D6) in BETRIEB §14 und Plan; nex-speech.yml abgesichert; k3s-VM DHCP-Client-ID=MAC
ci / test (push) Successful in 28s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 12:03:56 +02:00
Robert RappandClaude Fable 5.1 dc4edb643f k3s: Konfigurationsverzeichnis anlegen
ci / test (push) Successful in 27s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 11:58:59 +02:00
Robert RappandClaude Fable 5.1 d9537cbb42 k3s-server: kubectl-Version ohne Regex ableiten (YAML-Escape)
ci / test (push) Successful in 26s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 11:58:41 +02:00
Robert RappandClaude Fable 5.1 3a8e9f3357 refresh-vm-access: Dienst-VMs mit aufnehmen (Lesefehler behoben)
ci / test (push) Failing after 23s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 11:57:57 +02:00
Robert RappandClaude Fable 5.1 2c1d43e9c7 k3s (D6): Dienst-VM k3s per tofu, Rollen k3s-server/k3s-agent/k3s-apps, Manifeste, NEX-Sprachdienste als k3s-Umzugstest
ci / test (push) Failing after 22s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 11:55:18 +02:00
Robert RappandClaude Fable 5.1 6cccc01d14 docs: ops und infra mit festen Adressen, DHCP-Reservierungsliste und VLAN-Ausblick im Plan
ci / test (push) Successful in 29s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 19:11:52 +02:00
Robert RappandClaude Fable 5.1 96cc1ab9c6 infra: feste Adresse 192.168.100.188 statt DHCP, DNS im LXC gesetzt
ci / test (push) Successful in 32s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 19:10:06 +02:00
Robert RappandClaude Fable 5.1 414d020e9d docs: zweite Laptop-Adresse (.181, Dock) in Firewall- und Anmeldedoku
ci / test (push) Successful in 32s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 19:01:40 +02:00
Robert RappandClaude Fable 5.1 72b0cd852a firewall: Admin-Laptop auch über USB-LAN im Dock (.181) zulassen
ci / test (push) Successful in 30s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 19:00:53 +02:00
Robert RappandClaude Fable 5.1 d278cb38bb docs: Anmeldung und Vertretung (ANMELDUNG.md), Password Depot als Quelle für TOTP, Bitwarden-Hinweis
ci / test (push) Successful in 30s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-13 18:06:55 +02:00
Robert RappandClaude Fable 5.1 97bbb58433 docs: Betriebshandbuch §8–§13 (Zugang, Benachrichtigungen, Firewall, Zertifikat, Netzwerk, Testvorgehen), healthcheck.sh, Plan Phase 4 erledigt
ci / test (push) Successful in 27s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-13 17:12:51 +02:00
Robert RappandClaude Fable 5.1 47ad1580ff pve-cluster: Verwaltungsports nur aus IPSET admin (Proxmox ergänzt management stets um das /24); Skript nutzt python auf dem Laptop
ci / test (push) Successful in 25s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-13 17:08:42 +02:00
Robert Rapp 87b678e7a8 pve-cluster: cluster.fw ohne mode (pmxcfs erlaubt kein chmod)
ci / test (push) Successful in 26s
2026-10-13 17:06:40 +02:00
Robert Rapp f3cfacfe48 pve-cluster: Token-Abfrage verträgt fehlenden Benutzer (Probelauf)
ci / test (push) Successful in 27s
2026-10-13 17:06:05 +02:00
Robert Rapp d9b2539dea pve-cluster: when-Listen in Anführungszeichen (YAML-Flow-Sequenz)
ci / test (push) Successful in 26s
2026-10-13 17:05:43 +02:00
Robert RappandClaude Fable 5.1 81602373fc pve-cluster: Phase 4 als Rolle – Admin-Gruppe/-Konten, Healer-Token, Benachrichtigungen, Firewall mit IPSET management (Tasks 4.1–4.4)
ci / test (push) Failing after 21s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-13 17:05:30 +02:00
rorappandClaude Opus 5.5 9442cc49d1 ontology: Ontologie als Quelle im Repo, Abgleich mit n8n/KNOW in der Service-Routine
ci / test (push) Successful in 27s
- ontology/know.yaml: 49 Klassen, 66 Beziehungen, 12 Vokabulare (72 Konzepte);
  erzeugt: know.ttl (OWL + SKOS), Ansicht aus viewer.html.tmpl
- knaster.ontology: Modell + Prüfung, Export, Momentaufnahme der n8n-Workflows
  (nur GET; KNOW-Spezifikation aus dem Workflow-Code, Aufrufe inkl. Laufzeit-
  Operationen, direktes SQL), Abgleich, Übernahme von Entscheidungen
- Service-Routine Schritt 8: Verstöße als Befunde, Ontologie-Änderungen als
  Vorschläge ontology_change (immer Freigabe); „ordne P-x <konzept> zu“;
  Entscheidungen in ontology_decisions, `knaster ontology apply` schreibt sie
  in die YAML (Version +1)
- CLI: knaster ontology validate|export|snapshot|check|apply; knaster validate
  prüft Modell und Aktualität der Turtle-Datei
- Gegen echten n8n-Export geprüft: neue Befunde B31–B33 (verified/confirmed,
  KAN ruft unbekannte Operation, MPA ordnet Projektereignisse nie ein)
- SPEC-ontology, BETRIEB §6, Router-Image enthält die Ontologie

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 02:54:49 +02:00
rorappandClaude Opus 5.5 525b29aa6d docs: Vollanalyse der KNOW-Datenhaltung, Befunde B11–B30
ci / test (push) Successful in 28s
Vier Ebenen (Schema, SQL der 80 Operationen, Payloads der Aufrufer,
Ablagen außerhalb von KNOW), jeder Befund am Code nachgeprüft:
keine Fremdschlüssel, zwei Kontakt-Schlüsselschemata, zwei Projekt-Kennungen,
zu enge/fehlende Eindeutigkeit, Upserts frieren Fachdaten ein, veraltende
abgeleitete Werte, nie geschriebene Felder, Kategorie-Vokabulare (Safety-Floor
über CAT unerreichbar), uneinheitliche Normalisierung, Schema-Drift,
gemeinsamer DB-Zugang, staticData, unverschlüsselte Browser-Sessions,
LLM-Aufrufe am PSE-LiteLLM vorbei. Schema-Referenz und Ontologie um
fehlende Spalten ergänzt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 02:34:42 +02:00
rorappandClaude Opus 5.5 d9d8d4e38b docs: KNOW-Befund B10 (Absender-Report liest eingefrorene Statistik aus contacts)
ci / test (push) Successful in 25s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 02:21:07 +02:00
rorappandClaude Opus 5.5 d0f1d31918 docs: KNOW-Befunde B1–B9 festgehalten, Ontologie als OWL/Turtle
ci / test (push) Successful in 26s
- KNOW-befunde.md: Aufrufe unbekannter Operationen (CANCEL, KAN-Learn, IN-Sync),
  direktes SQL in CAT, 12 Tabellen ohne aktiven Schreibweg mit Ursache (keine
  Operation / kein Aufrufer / Seed), DDL im KAF-Bestätigungspfad, Lösch-Operation
  über KGW, fehlende Löschfristen; jeweils Beleg, Folge, Vorschlag
- know-ontology.ttl: 49 Klassen, 66 Beziehungen, 39 Individuen (rdflib-geprüft)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 02:15:48 +02:00
rorappandClaude Opus 5.5 fdaa5a5694 docs: Referenz der n8n-Wissensdatenbank KNOW/KGW (Tabellen, Zugriffe, Auffälligkeiten)
ci / test (push) Successful in 25s
Aus den Workflow-Definitionen abgeleitet (n8n-API, nur lesend, keine DB-Inhalte):
33 Tabellen im Schema knowledge, welche Workflows über welche Operation schreiben
und lesen, KGW-Freigaben, Aufrufe unbekannter Operationen (CANCEL, KAN-Learn,
IN-Sync) und Bezug zu Task 6.4 (find_active_llm_workers über KGW).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 02:05:10 +02:00
rorappandClaude Opus 5.5 c81e372c49 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>
2026-10-13 01:39:49 +02:00
Robert RappandClaude Opus 5.5 5c790cc8d4 gpu-llamacpp: Compose auf P2000 per device_ids, Rolle und Playbook (Task 6.1)
ci / test (push) Successful in 27s
- Build-Pin wie im Benchmark (b6000 = 4762ad7, CUDA 12.4, sm_52;61), Host-Netz,
  Log-Rotation, /slots und Web-Oberfläche aus, Qwen3 ohne Denkphase.
- Exporter aus dem Compose entfernt (laufen nativ, Rolle gpu-exporters).
- Rolle gpu-llamacpp: Karten-UUID über den Namen, Modelle mit Prüfsumme,
  ufw-Regeln nur vorgemerkt. Inventargruppe gpu_llamacpp (ohne gpu-pve52).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:28:33 +02:00
Robert RappandClaude Opus 5.5 d8797a9433 benchmark: Compose-Kartenwahl und Rerank-Bedingung in ADR-0006 ergänzt
ci / test (push) Successful in 26s
- count: 1 in gpu-llamacpp greift die M4000 (geprüft), für die P2000 device_ids setzen
- Empfehlung pve44/46/48 unter der Bedingung, dass Embedding + Rerank gemeinsam auf die P2000 passen
- 8B-VRAM: 7,73 GiB gemessen, im Katalog auf 7,8 aufgerundet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:21:11 +02:00
Robert RappandClaude Opus 5.5 cdf0bb63e3 benchmark: Task 3.3 auf gpu-pve48 gemessen (llama.cpp, whisper.cpp, Piper), ohne Strommessung
ci / test (push) Successful in 28s
- Katalog: vram_gb für Qwen3-Embedding, Qwen3-4B und Qwen3-8B gemessen, Messwerte als note
- ADR-0006: Nachtrag „Benchmark“ mit Tabellen und Empfehlung zur M4000
  (ja für STT auf pve52, auf pve44/46/48 vorerst nein)
- Plan: Task 3.3 abgehakt, Strommessung, Dialektsätze und D131/D133 offen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:19:28 +02:00
Robert RappandClaude Opus 5.5 cd7f9308f1 docs: Rückmeldung 6.2 – VRAM-Angabe präzisiert, Energiesparen ergänzt
ci / test (push) Successful in 29s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:07:11 +02:00
Robert RappandClaude Opus 5.5 b9eb55bf44 docs: Rückmeldung Sprachdienste NEX (Task 6.2) mit Messwerten, Betrieb und Plan-Fortschritt
ci / test (push) Successful in 27s
Erkennung 10 s → 2,94 s, 60 s → 8,63 s (M4000, Median), TTS-TTFB < 1 s,
Autostart getestet, Stufe 2 geprüft (torch 2.9.1+cu126 letzte Version mit sm_50).
Offen: feste IP (D5), Abnahme von appsrv08, Slot stt-de.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:05:18 +02:00
Robert RappandClaude Opus 5.5 365549b1f4 plan: Task 5.2 – Infra-Stack mit Monitoring erledigt, n8n-Anbindung offen
ci / test (push) Successful in 25s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:57:47 +02:00
Robert RappandClaude Opus 5.5 828ed3975b nex-speech: /app vor COPY --chmod anlegen (sonst ohne x-Bit, Import scheitert)
ci / test (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:52:22 +02:00
Robert RappandClaude Opus 5.5 456fce2386 infra-stack: Schutz gegen veralteten ZFS-Seiten-Cache nach HA-Umzug, Betriebsabschnitt 7
ci / test (push) Successful in 28s
Bei der HA-Probe startete dockerd auf pve48 nicht (bbolt „invalid freelist page“): Nach zfs receive
liefert der Zielknoten für früher gemappte Dateien alten Inhalt aus dem Seiten-Cache. Ein oneshot-Dienst
im LXC verwirft vor containerd/docker den Cache aller Dateien (posix_fadvise DONTNEED).
BETRIEB.md Abschnitt 7: URLs, Geheimnisse (ohne Werte), file_sd auffrischen, Wiederherstellung.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:49:49 +02:00
Robert RappandClaude Opus 5.5 ddd7ad033d nex-speech: Dateirechte explizit (Clone auf ops ist 0600, Piper läuft als nobody)
ci / test (push) Successful in 29s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:41:35 +02:00
Robert RappandClaude Opus 5.5 afc5fa0360 nex-speech: Sprachdienste für NEX (whisper.cpp CUDA 12 + Piper) als Compose und Ansible-Rolle
ci / test (push) Successful in 29s
- deploy/compose/nex-speech: whisper-server v1.9.4 (sm_52/sm_61, Port 8178, -l de -bs 5
  --convert, tmpfs, Aufwärmen mit 1 s Stille) und OpenAI-kompatibler Piper-Wrapper
  (Port 8179, mp3/opus/aac/flac/pcm gestreamt, wav, PCM 24 kHz mit Rate-Header)
- Host-Netz, damit ufw greift; keine Texte in Logs, kein Access-Log
- Rolle nex-speech + Playbook (nur gpu-pve52): ufw, Modelle mit sha256, Compose bauen/starten
- compose-check um nex-speech erweitert, community.docker in requirements.yml

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:41:11 +02:00
Robert RappandClaude Opus 5.5 b66c4c37cc grafana: GPU-Namen in der knaster-Übersicht über nvidia_smi_gpu_info
ci / test (push) Successful in 32s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:28:17 +02:00
Robert RappandClaude Opus 5.5 02f1d62715 infra-stack: Repo-Auszug für Container-Benutzer lesbar machen (Clone auf ops ist 0600)
ci / test (push) Successful in 29s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:26:20 +02:00
Robert RappandClaude Opus 5.5 bf7c137adf infra: Compose-Stack mit Monitoring, ohne LiteLLM (Task 5.2)
ci / test (push) Successful in 28s
- deploy/compose/infra: Dienst litellm entfernt (D4), Router auf LITELLM_BASE_URL
  (appsrv08, Port noch offen), alle Images gepinnt, Grafana und pve-exporter
  ohne Profile, node-exporter für den LXC, Zustand über KNASTER_STATE_DIR
- Prometheus: GPU-VMs per file_sd (node, gpu), Proxmox-Knoten per IP, infra-node
- Grafana: Datasource und Dashboards provisioniert (grafana.com 10347, 1860,
  14574 sowie knaster-Übersicht)
- Alertmanager: n8n-Receiver nur vorbereitet (auskommentiert)
- Ansible: Rollen infra-stack und gpu-exporters, Playbooks monitoring.yml und
  monitoring-targets.yml, Skript monitoring-targets.py (tofu output → file_sd)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:22:26 +02:00
Robert RappandClaude Opus 5.5 fd48fd1c7f plan: Phase 4 – 4.5 und 4.7 erledigt, 4.1 teilweise
ci / test (push) Successful in 29s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:07:31 +02:00
Robert RappandClaude Opus 5.5 4bdfd205fa betrieb: rollierende Update- und Neustart-Routine für den Cluster (Task 4.7)
ci / test (push) Successful in 29s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:07:18 +02:00
Robert RappandClaude Opus 5.5 c1f11d2660 tofu: Container-Pool nicht über tofu (pool_id erzwingt Ersetzen)
ci / test (push) Successful in 29s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:06:29 +02:00
Robert RappandClaude Opus 5.5 438e0949e9 tofu: Gäste in die Pools gpu und infra (Task 4.5)
ci / test (push) Successful in 28s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:06:05 +02:00
Robert RappandClaude Opus 5.5 bbe9236792 plan: GPU-VMs (3.2), HA/Replikation (2.2) und infra-LXC erledigt und getestet
ci / test (push) Successful in 29s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:04:19 +02:00
Robert RappandClaude Opus 5.5 c794cd3f0a docker-host: Compose-Paketname für Debian 13
ci / test (push) Successful in 28s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:54:24 +02:00
Robert RappandClaude Opus 5.5 56576c7d3c scripts: Zugang auch für Infra-Container (Hostschlüssel per pct exec, Knoten nach HA-Umzug)
ci / test (push) Successful in 30s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:53:37 +02:00
Robert RappandClaude Opus 5.5 bf20da1629 infra: HA-Ressource und Node-Affinity per pve-host statt tofu, Infra-IP im Inventar
ci / test (push) Successful in 29s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:41:17 +02:00
Robert RappandClaude Opus 5.5 23d6e36028 gpu-vms: DHCP-Client-ID auf MAC, Docker-Runtime als Aufgabe, Zugang nach Neustart auffrischen
ci / test (push) Successful in 28s
Nach dem Treiber-Neustart hatten gpu-pve44 und gpu-pve46 ihre DHCP-Adressen
getauscht; die strenge Hostschlüssel-Prüfung hat den Lauf gestoppt. netplan
nutzt jetzt die MAC als Client-ID (Voraussetzung für Reservierungen, D5).
Der Handler für nvidia-ctk ist eine idempotente Aufgabe, vor dem Neustart
werden Handler ausgeführt. scripts/refresh-vm-access.sh holt Adressen aus
dem Agenten, schreibt das Inventar und trägt die Hostschlüssel ein.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:39:25 +02:00
Robert RappandClaude Opus 5.5 ba42ce3f9d scripts: Hostschlüssel der GPU-VMs über den Gast-Agenten eintragen
ci / test (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:19:58 +02:00
Robert RappandClaude Opus 5.5 72e293ce9a ansible: Python-Interpreter der GPU-VMs festlegen
ci / test (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:13:03 +02:00
Robert RappandClaude Opus 5.5 47816bda68 plan: Task 3.1 erledigt, 3.2 vorbereitet; Sprach-Auftrag NEX als 6.2, HDD-Befund in 5.1
ci / test (push) Successful in 28s
ops-LXC, vfio auf allen GPU-Knoten (Bildschirm danach schwarz), Templates
9044/9046/9048/9052, API-Benutzer tofu@pve. Auftrag Sprachmodelle mit
Abgleich gegen den echten Cluster unter docs/auftraege/. Die 4-TB-HDD ist
die Toshiba BACKUP und scheidet für den Cluster aus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:10:36 +02:00
Robert RappandClaude Opus 5.5 e5d1fa45b1 tofu: neue Ressourcennamen des Providers (hardware_mapping_pci, haresource)
ci / test (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:09:08 +02:00
Robert RappandClaude Opus 5.5 ff74c1a7b2 tofu: GPU-VMs mit allen Karten des Knotens, DHCP über Gast-Agent, Provider für PVE 9
ci / test (push) Successful in 28s
bpg/proxmox ~> 0.116.0 (nur API). Template je Knoten, Speicher je Gast,
hostpci dynamisch aus vms.*.gpus, kein Secure Boot (DKMS), Vendor-Snippet
für den qemu-guest-agent, Ausgabe ansible_inventory für die DHCP-Adressen.
scripts/make-template.sh legt das Ubuntu-24.04-Template mit Prüfsumme an.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:04:56 +02:00
Robert RappandClaude Opus 5.5 e88e0ea46b sites: Inventar und Registry an echte Knoten (pve44–pve61) angepasst; ADR-0001/0002/0006 und PLAN §1.3/1.4/9 ergänzt
Inventar mit pve44–pve61 und den Ada-Rechnern gode-pc-05/06 (M4000 weiter
disabled). Registry-ops auf gpu-pve44/gpu-pve48/infra laut Placement, bei
den Ada-Slots entfallen pve_node und vmid (Worker-Registry v2, Task 6.4).
Tests: neuer test_site_knaster_matches_real_nodes, Konsistenztest auf
gpus-Liste umgestellt. ADR-Nachträge zu D1 (Variante B), D2 (eine GPU-VM
pro Knoten) und D3 (Ada bleibt Windows, vorgeschlagen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:04:55 +02:00
Robert RappandClaude Opus 5.5 9b2ad426da pve-host: update-grub ohne ZFS-Root wirklich ausführen
ci / test (push) Failing after 27s
proxmox-boot-tool refresh endet ohne proxmox-boot-uuids mit Exit 0, die
Verkettung mit || hat update-grub deshalb nie erreicht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:00:33 +02:00
Robert RappandClaude Opus 5.5 ac91744ce0 pve-host: lesende Aufgaben auch im Check-Modus ausführen
ci / test (push) Failing after 27s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:59:09 +02:00
Robert RappandClaude Opus 5.5 908c698589 proxmox: Site-Datei und pve-host-Rolle an die echten Knoten (pve44–pve61) angepasst
ci / test (push) Failing after 27s
proxmox.yaml mit gelesenen PCI-IDs/IOMMU-Gruppen, eine GPU-VM pro Knoten
mit allen Karten, DHCP, Templates je Knoten. pve-host nur für GPU-Knoten:
vfio-pci-IDs auf dem Knoten gelesen, softdeps, sysfb aus, initramfs-
Handler, Vendor-Snippet für den Gast-Agenten, Replikation erst wenn
der Container existiert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:58:34 +02:00
Robert RappandClaude Opus 5.5 dfb2630517 plan: Ausgangslage und Plattenregel an den Stand nach dem Löschen angepasst
ci / test (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:39:01 +02:00
Robert RappandClaude Opus 5.5 4cfa31bc3e plan: Windows-Platten gelöscht, ZFS-Pool tank auf pve44/pve48/pve52 (Task 1.2)
ci / test (push) Successful in 27s
Freigabe vom 12.10. umgesetzt: zweite SSD per Seriennummer gelöscht,
tank angelegt und im Cluster eingetragen, ARC 4 GiB, Neustart-Probe
auf pve48 (Legacy) und pve44 (UEFI) bestanden. Nebenbefund: pve44 ohne
/boot/efi in fstab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:37:40 +02:00
Robert RappandClaude Opus 5.5 983b39f238 docs(plan): Erst-Update auf PVE 9.2.21 / Kernel 7.0.14 und Cluster knaster mit 5 Knoten erledigt
ci / test (push) Successful in 29s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:45:09 +02:00
Robert RappandClaude Opus 5.5 640e79dbe8 feat(platform): bootstrap-node.sh für Proxmox-Knoten (Schlüssel, Paketquellen, full-upgrade)
Idempotent, läuft vom Admin-Laptop in Git-Bash. Hostschlüssel wird nur mit
--reinstall verworfen, das Update läuft per systemd-run abgekoppelt von der
SSH-Sitzung. .gitattributes hält Shell-Skripte auf LF.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:35:07 +02:00
rorapp 8cf27b48ce Merge pull request 'Plan/aufbau 2026 10 12' (#1) from plan/aufbau-2026-10-12 into main
ci / test (push) Successful in 27s
Reviewed-on: #1
2026-10-12 14:18:17 +00:00
150 changed files with 35536 additions and 362 deletions
+1 -1
View File
@@ -22,6 +22,6 @@ PROXMOX_TOKEN_ID=knaster@pve!healer
PROXMOX_TOKEN_SECRET=
NOTIFY_WEBHOOK_URL=
# n8n (nur für Import/Tests)
# n8n: Import/Tests und Ontologie-Abgleich (Router liest Workflows nur, GET /api/v1/workflows)
N8N_HOST=
N8N_API_KEY=
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+2
View File
@@ -14,3 +14,5 @@ data/
*.tfstate*
.DS_Store
deploy/compose/*/secrets/
platform/proxmox/ansible/inventory.generated.yml
platform/proxmox/ansible/monitoring-targets.generated/
+77
View File
@@ -0,0 +1,77 @@
# AGENTS.md – Arbeitsweise für Coding-Agenten (Claude Code, Codex, Hermes …)
Dieses Repo beschreibt den Proxmox-/k3s-Cluster **knaster** und den Router-Service `knaster`. Wer hier als Agent
arbeitet, ändert **Infrastruktur, die läuft**. Erst lesen: [project-map.md](project-map.md) (was wo läuft),
[known-issues.md](known-issues.md) (bekannte Fallen), der Aufbauplan
[docs/superpowers/plans/2026-10-12-aufbau-cluster-knaster.md](docs/superpowers/plans/2026-10-12-aufbau-cluster-knaster.md)
(was offen ist) und [docs/BETRIEB.md](docs/BETRIEB.md) (wie man es betreibt).
## Sprache und Stil
- Deutsch in Doku, Kommentaren, Commit-Nachrichten und Antworten; Umlaute ausschreiben (ä, ö, ü, ß).
- Code so schreiben wie der umgebende Code: Kommentardichte, Benennung, Muster (Playbook-Kopf mit Zweck und Aufruf,
Manifest-Kopf mit Zweck und Herkunft).
- Daten mit Datum notieren (`19.10.`), keine relativen Angaben („gestern“).
## Der Weg jeder Änderung
1. **Im Repo ändern** (auf dem Admin-Laptop bzw. dem Gerät der verwaltenden Person). Nie direkt auf ops oder in
einer VM editieren; `/root/cluster-knaster` auf ops und `/opt/knaster/repo` auf infra sind Kopien.
2. **Prüfen:** `make test` (bzw. `pytest core`), `make lint`, bei Ansible `ansible-playbook --syntax-check` auf ops,
bei Prometheus `promtool check config`/`check rules` (läuft im Prometheus-Image auf infra).
3. **Committen und pushen** (Branch laut Plan, derzeit `plan/aufbau-2026-10-12`). Nur eigene Dateien stagen –
es arbeiten oft mehrere Sitzungen parallel im selben Repo. Vor dem Push `git pull --rebase`.
4. **Auf ops ausrollen:** `ssh ops`, `cd /root/cluster-knaster && git pull`, dann in `platform/proxmox/ansible`
das passende Playbook. `kubectl`, `tofu` und Ansible gibt es **nur auf ops**.
5. **Live prüfen** (Pods bereit, Dienst antwortet, Prometheus-Ziel `up`) und erst dann abhaken.
6. **Plan und Doku nachziehen:** Checkbox im Aufbauplan mit Datum und Befund, Betriebswissen in `docs/BETRIEB.md`,
neue Fallen in `known-issues.md`, neue Dienste/Adressen in `project-map.md`.
## Zugang
- Einstieg ist **ops** (`192.168.100.173`, Bastion). Von dort erreicht man alle Knoten, VMs und infra mit dem
Schlüssel `root@ops`; Ansible-Inventar `platform/proxmox/ansible/inventory.yml` (+ `inventory.generated.yml`).
- Wer verwaltet, steht in `sites/knaster/proxmox.yaml` unter `access.ops_ssh_keys` (Playbook `playbooks/admins.yml`,
Übergabe: BETRIEB §15). Proxmox-Weboberfläche nur mit persönlichem Konto und TOTP.
- Muster für Befehle vom Laptop: `ssh ops '<befehl>'`, `ssh ops 'cd /root/cluster-knaster/platform/proxmox/ansible && ansible-playbook playbooks/<x>.yml'`.
In Ansible-Ad-hoc-Befehlen keine Go-Templates (`{{.Names}}`) – Ansible wertet `{{ }}` selbst aus; besser ein
kleines Skript mit `-m script`.
## Feste Regeln
- **Keine Geheimnisse im Repo.** Tokens und Passwörter liegen auf ops unter `/root/.config/knaster/` (0600), im LXC
infra unter `/opt/knaster/infra/.env` bzw. `secrets/`, beim Menschen in Password Depot. Playbooks lesen sie von dort,
Ausgaben mit `no_log: true`.
- **Dry-Run:** Healer und Router laufen mit `KNASTER_DRY_RUN=true`, `enabled_actions: []`. Nicht ohne ausdrückliche
Freigabe des Nutzers ändern.
- **IP-Literale statt DNS-Namen** in Konfigurationen (Netz `192.168.100.0/24`, Gateway `.1`, DNS `.120`). Adressen der
GPU-VMs und der k3s-VM kommen per DHCP; Prometheus-Ziele deshalb per file_sd, nicht fest.
- **GPUs:** In jeder GPU-VM ist Index 0 die Quadro M4000, Index 1 die P2000 (gpu-pve52 hat nur die M4000).
`nvidia.com/gpu` in k3s vergibt **nur die M4000**. Die P2000 gehört llama.cpp (fest per UUID). Neue GPU-Pods
bekommen also eine M4000; eine P2000 nur nach Absprache.
- **Was nach k3s umgezogen ist, nicht wieder per Compose starten:** `playbooks/nex-speech.yml` und
`playbooks/gpu-llamacpp.yml` brechen ab (Schalter nur für den Rückweg).
- **Images pinnen** (exakte Version, kein `latest`); bei k3s-Pods mit eigenem Build `imagePullPolicy: Never` und Import
per `docker save | k3s ctr images import -` (Muster: Rolle `nex-speech-k3s`, `gpu-llamacpp` `tasks/k3s.yml`).
- **Infra-LXC hat 4 GB RAM:** neue Prometheus-Jobs mit `metric_relabel_configs` auf das Nötige begrenzen.
- **Nach außen wirkende oder schwer umkehrbare Schritte** (Produktion auf appsrv08, DNS/DHCP, Löschen, Firewall,
Neustart von Knoten) vorher mit dem Nutzer abstimmen. Die Firewall-Rolle hat einen Totmannschalter, trotzdem
vorher prüfen, dass die eigene Adresse in `firewall.management` steht.
- **Namespace `nex`** gehört NEX (nex-admin legt dort Agenten an). Änderungen dort mit dem NEX-Team abstimmen.
## Muster im Repo
| Aufgabe | Vorlage |
|---|---|
| Neuer Dienst in k3s | `deploy/k8s/<dienst>/` + `playbooks/<dienst>-k3s.yml` (z. B. `nex-searxng`, `monitoring`) |
| Dienst mit GPU/Host-Netz in einer VM | `deploy/k8s/nex-speech/`, `deploy/k8s/llamacpp/llamacpp.yaml.j2` |
| Zugang für eine Anwendung zur k8s-API | `deploy/k8s/nex/admin-access.yaml` (ServiceAccount, Role, Token-Secret), Token-Datei auf ops |
| Neues Prometheus-Ziel | `deploy/prometheus/prometheus.yml`; wechselnde Adressen über `platform/proxmox/scripts/monitoring-targets.py` |
| Neuer Alert | `deploy/prometheus/alerts.yml` **und** passende Regel in `config/healer-rules.yaml` (Test erzwingt das) |
| Grafana-Dashboard | `deploy/grafana/dashboards/*.json`, Datasource-uid `prometheus`, Herkunft in `provisioning/dashboards/knaster.yml` |
## Testen unter Windows
- `.venv\Scripts\python -m pytest core` – `test_tts_server` scheitert unter Windows (kein Piper), das ist bekannt.
- `yamllint` meldet auf geänderten Dateien „wrong new line character“, weil Git die Arbeitskopie mit CRLF auscheckt.
Für eine echte Prüfung den Stand aus Git prüfen: `git show HEAD:<datei> | .venv/Scripts/yamllint -s -c .yamllint.yml -`.
+2 -2
View File
@@ -13,14 +13,14 @@ test:
lint:
$(VENV)/bin/ruff check core
$(VENV)/bin/yamllint -s -c .yamllint.yml config catalog sites deploy/prometheus deploy/alertmanager deploy/litellm deploy/compose platform
$(VENV)/bin/yamllint -s -c .yamllint.yml config catalog sites deploy/prometheus deploy/alertmanager deploy/litellm deploy/compose platform ontology
validate:
$(VENV)/bin/knaster validate --root .
$(VENV)/bin/knaster placement --inventory sites/knaster/inventory.yaml --catalog catalog > /dev/null
compose-check:
@for d in infra gpu-vllm gpu-llamacpp; do \
@for d in infra gpu-vllm gpu-llamacpp nex-speech; do \
(cd deploy/compose/$$d && cp .env.example .env.ci && \
POSTGRES_PASSWORD=ci VLLM_MODEL=ci SLOT_ALIAS=ci docker compose --env-file .env.ci \
--profile embed --profile chat --profile dashboards --profile proxmox config -q; \
+6
View File
@@ -15,11 +15,17 @@ Anfrage → n8n → Router-Service ──▶ Stufe 3 Embedding-Classifier (CPU)
| Dokument | Inhalt |
|---|---|
| [AGENTS.md](AGENTS.md) | **Einstieg für Agenten und neue Admins**: Arbeitsweise, Regeln, Muster |
| [project-map.md](project-map.md) | Was wo läuft: Rechner, Adressen, Dienste, Ports, Namespaces, Repo, Orte der Geheimnisse |
| [known-issues.md](known-issues.md) | Bekannte Probleme und Fallen |
| [docs/NOTFALL-ADMIN-UEBERGABE.md](docs/NOTFALL-ADMIN-UEBERGABE.md) | Notfall: Verwaltung an eine andere Person übergeben (für die Geschäftsführung) |
| [docs/PLAN.md](docs/PLAN.md) | Architektur und Betrieb: Proxmox, Pflege, Autohealing, Lifecycle, Finetuning, Problemerkennung, Portabilität |
| [docs/adr/](docs/adr/) | Architekturentscheidungen |
| [docs/spec/](docs/spec/) | Spezifikationen der Komponenten |
| [docs/superpowers/plans/2026-10-12-aufbau-cluster-knaster.md](docs/superpowers/plans/2026-10-12-aufbau-cluster-knaster.md) | **Aufbauplan auf echter Hardware**: Phasen, Entscheidungen D1–D5, Zuordnung zu Planner Epic 7 |
| [docs/IMPLEMENTATION.md](docs/IMPLEMENTATION.md) | Arbeitspakete, Teststrategie, Stand der Verifikation |
| [ontology/know.yaml](ontology/know.yaml) | **Ontologie der Datenhaltung** (Quelle); Pflege und Abgleich in [SPEC-ontology](docs/spec/SPEC-ontology.md) |
| [docs/reference/KNOW-schema.md](docs/reference/KNOW-schema.md) | Bestehende n8n-Wissensdatenbank KNOW/KGW: Tabellen, wer schreibt und liest, Auffälligkeiten |
| [docs/BETRIEB.md](docs/BETRIEB.md) | Skill-Lebenszyklus, Rolle von Hermes, Pflege per Prompt, Dry-Run aufheben, Rollback |
| [n8n/README.md](n8n/README.md) | Workflows und Import |
| [platform/proxmox/README.md](platform/proxmox/README.md) | Proxmox-Ablauf (OpenTofu + Ansible) |
+9 -4
View File
@@ -1,4 +1,6 @@
# Modell-Katalog (SPEC-inventory §2). vram_gb = Gewichte + Mindest-KV-Cache (Richtwerte, per Kalibrierung prüfen).
# Gemessen am 12.10.2026 (Task 3.3, ADR-0006): llama-server b6000/CUDA 12.4 in gpu-pve48 beim Katalog-Kontext,
# belegter VRAM inkl. CUDA-Kontext, auf M4000 und P2000 gleich. Ungemessene Einträge bleiben Richtwerte.
# Ist ein Modell nicht mehr aktuell, wird ein neuer Eintrag ergänzt. Der Wechsel läuft über die Lifecycle-Pipeline.
# Engine-Grenzen gelten für alle Modelle der Engine (ADR-0006).
engines:
@@ -24,24 +26,27 @@ models:
lora: true
qwen3-8b-q4km:
kind: llm
vram_gb: 7
vram_gb: 7.8 # gemessen: 7918 MiB (7,73 GiB, aufgerundet) bei 16384 Kontext → nur M4000 (8 GB), auf P2000 scheitert der KV-Cache
min_cc: 5.0
engines: [llamacpp]
context: 16384
note: "llama-bench 12.10. (pp512/tg128 t/s): M4000 115/10,0, P2000 152/9,0 (nur mit 512 Kontext)"
qwen3-4b-q4km:
kind: llm
vram_gb: 4
vram_gb: 4.1 # gemessen: 4153 MiB bei 8192 Kontext
min_cc: 5.0
engines: [llamacpp]
context: 8192
cpu_ok: true # langsam, aber als Notfall-Verifier nutzbar
note: "llama-bench 12.10. (pp512/tg128 t/s): P2000 271/14,5, M4000 197/15,1"
qwen3-embedding-0.6b-q8:
kind: embedding
vram_gb: 1.5
vram_gb: 2.8 # gemessen: 2827 MiB mit -c 8192 -ub 8192 (Compose-Einstellung, großer Rechenpuffer)
min_cc: 5.0
engines: [llamacpp]
context: 8192
cpu_ok: true
note: "llama-bench 12.10. pp512 (Embedding): P2000 1009 t/s, M4000 929 t/s"
bge-m3-f16:
kind: embedding
vram_gb: 2.5
@@ -74,4 +79,4 @@ models:
vram_gb: 0.2
engines: [onnx]
cpu_ok: true
note: "schnell auf CPU (≪ Echtzeit), solide Qualität; piper-tts 1.3+ ist GPL-3.0 (eigener Dienst)"
note: "schnell auf CPU, gemessen 12.10. mit knaster bench tts (4 vCPU i7-6700): RTF 0,17 (241 Zeichen), 0,11 (596 Zeichen); piper-tts 1.3+ ist GPL-3.0 (eigener Dienst)"
+21
View File
@@ -51,3 +51,24 @@ rules:
match: {alertname: DiskAlmostFull}
ladder:
- {action: notify, params: {text: "Platte auf {instance} ({mountpoint}) fast voll", severity: warning}, attempts: 1}
# k3s (Task 6.5): nur melden; Pods startet Kubernetes selbst neu
- id: k3s-node-not-ready
match: {alertname: K3sNodeNotReady}
ladder:
- {action: notify, params: {text: "k3s-Knoten {node} nicht bereit – VM und k3s-agent prüfen", severity: high}, attempts: 1}
- id: k3s-pod-crashloop
match: {alertname: K3sPodCrashLooping}
ladder:
- {action: notify, params: {text: "Pod {namespace}/{pod} ({container}) startet immer wieder neu", severity: warning}, attempts: 1}
- id: k3s-deployment-unavailable
match: {alertname: K3sDeploymentUnavailable}
ladder:
- {action: notify, params: {text: "Deployment {namespace}/{deployment} hat zu wenige bereite Pods", severity: warning}, attempts: 1}
- id: k3s-memory-near-limit
match: {alertname: K3sContainerMemoryNearLimit}
ladder:
- {action: notify, params: {text: "{namespace}/{pod} ({container}) über 90 % des Speicherlimits", severity: warning}, attempts: 1}
+1
View File
@@ -18,3 +18,4 @@ actions:
promote_candidate: {mode: approve, risk: high}
rollback_candidate: {mode: approve, risk: medium}
reboot_host: {mode: approve, risk: high, window: "02:00-05:00"}
ontology_change: {mode: approve, risk: low}
+107 -1
View File
@@ -1,4 +1,4 @@
"""Kommandozeile: serve, validate, inventory, placement, registry export."""
"""Kommandozeile: serve, validate, inventory, placement, registry export, ontology."""
from __future__ import annotations
@@ -47,8 +47,28 @@ def main(argv: list[str] | None = None) -> int:
reg_sub = p_reg.add_subparsers(dest="reg_cmd", required=True)
reg_sub.add_parser("export", help="effektiven Stand (inkl. Laufzeitzustand) als YAML ausgeben")
p_onto = sub.add_parser("ontology", help="Ontologie der Datenhaltung (SPEC-ontology)")
p_onto.add_argument("--path", default=None, help="Ontologie-Datei (Standard: ontology/know.yaml)")
onto_sub = p_onto.add_subparsers(dest="onto_cmd", required=True)
onto_sub.add_parser("validate", help="Modell prüfen")
p_exp = onto_sub.add_parser("export", help="Turtle, Graph-JSON und/oder HTML-Ansicht erzeugen")
p_exp.add_argument("--ttl")
p_exp.add_argument("--graph")
p_exp.add_argument("--html")
p_exp.add_argument("--template", default=None, help="Standard: viewer.html.tmpl neben der Ontologie")
p_snap = onto_sub.add_parser("snapshot", help="n8n-Workflows lesen und als JSON speichern (nur GET)")
p_snap.add_argument("--out", required=True)
p_chk = onto_sub.add_parser("check", help="Ontologie mit der Datenhaltung in n8n abgleichen")
p_chk.add_argument("--snapshot", help="Workflows aus Datei statt aus der n8n-API")
p_chk.add_argument("--json", action="store_true")
p_app = onto_sub.add_parser("apply", help="freigegebene Entscheidungen in die Ontologie übernehmen")
p_app.add_argument("--dry-run", action="store_true")
args = parser.parse_args(argv)
if args.cmd == "ontology":
return _ontology(args)
if args.cmd == "serve":
import uvicorn
@@ -104,5 +124,91 @@ def main(argv: list[str] | None = None) -> int:
return 2
def _ontology(args: argparse.Namespace) -> int:
import json
from .ontology import (
Ontology,
apply_decisions,
build_snapshot,
check,
fetch_workflows,
to_graph,
to_html,
to_turtle,
)
from .settings import Settings
settings = Settings.from_env()
path = Path(args.path) if args.path else settings.ontology_path
onto = Ontology.from_file(path)
if args.onto_cmd == "validate":
errors = onto.validate_model()
for e in errors:
print(f"ERROR {e}")
print(f"{len(onto.classes)} Klassen, {len(onto.relations)} Beziehungen, {len(onto.vocabularies)} Vokabulare, "
f"{len(errors)} Fehler")
return 1 if errors else 0
if args.onto_cmd == "export":
if args.ttl:
Path(args.ttl).write_text(to_turtle(onto), encoding="utf-8")
if args.graph:
Path(args.graph).write_text(json.dumps(to_graph(onto), ensure_ascii=False, indent=1), encoding="utf-8")
if args.html:
template = Path(args.template) if args.template else path.parent / "viewer.html.tmpl"
Path(args.html).write_text(to_html(onto, template), encoding="utf-8")
return 0
if args.onto_cmd in {"snapshot", "check"}:
if getattr(args, "snapshot", None):
data = json.loads(Path(args.snapshot).read_text(encoding="utf-8"))
workflows = data.get("data", data) if isinstance(data, dict) else data
else:
if not (settings.n8n_host and settings.n8n_api_key):
print("N8N_HOST und N8N_API_KEY fehlen (oder --snapshot angeben)", file=sys.stderr)
return 2
workflows = fetch_workflows(settings.n8n_host, settings.n8n_api_key)
if args.onto_cmd == "snapshot":
Path(args.out).write_text(json.dumps({"data": workflows}, ensure_ascii=False), encoding="utf-8")
print(f"{len(workflows)} Workflows gespeichert in {args.out} (enthält Workflow-Code, nicht committen)")
return 0
from .store import Store
decided = {d["key"] for d in Store(settings.db_url).list_ontology_decisions()}
findings = check(onto, build_snapshot(workflows), decided=decided)
if args.json:
print(json.dumps([f.model_dump() for f in findings], ensure_ascii=False, indent=1))
else:
for f in findings:
mark = "→ Vorschlag" if f.change else ""
print(f"{f.severity.upper():7} {f.summary} {mark}".rstrip())
print(f"{len(findings)} Befunde, davon {sum(1 for f in findings if f.change)} Änderungsvorschläge")
return 1 if any(f.severity == "high" for f in findings) else 0
if args.onto_cmd == "apply":
from .store import Store
store = Store(settings.db_url)
pending = store.list_ontology_decisions(applied=False)
applied, messages = apply_decisions(onto, pending)
for m in messages:
print(m)
errors = onto.validate_model()
if errors:
print("Ontologie wäre ungültig, nichts gespeichert:\n" + "\n".join(errors), file=sys.stderr)
return 1
if applied and not args.dry_run:
header = "".join(line + "\n" for line in path.read_text(encoding="utf-8").splitlines()
if line.startswith("#"))
onto.save(path, header)
store.mark_ontology_applied(applied)
print(f"{len(applied)} Entscheidungen übernommen, Version {onto.version}. "
"Danach `knaster ontology export` und committen.")
return 0
return 2
if __name__ == "__main__":
sys.exit(main())
+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",
+11
View File
@@ -101,6 +101,17 @@ class ActionExecutor:
self.registry.set_calibrated_context(slot, value)
return f"Slot {slot}: effektiver Kontext = {value} Tokens"
# --- Ontologie --------------------------------------------------------------------------
def _do_ontology_change(self, key: str, type: str, decision: str = "accept", decided_by: str = "human",
**params: Any) -> str:
if decision not in {"accept", "map"}:
raise ValueError(f"Entscheidung {decision} unbekannt")
if decision == "map" and not params.get("concept"):
raise ValueError("Zuordnung braucht ein Konzept")
self.store.set_ontology_decision(key, decision, {"type": type, **params}, decided_by)
target = f" → {params['concept']}" if decision == "map" else ""
return f"Entscheidung gespeichert ({decision}{target}); in die Ontologie mit `knaster ontology apply`"
# --- Lifecycle --------------------------------------------------------------------------
def _do_create_candidate(self, kind: str, slot: str, target: dict[str, Any], **_: Any) -> str:
cid = self.lifecycle.create(kind, slot, target) # type: ignore[arg-type]
+39 -2
View File
@@ -35,6 +35,7 @@ class MaintenanceConfig(BaseModel):
adapter_min_cases: int = 300
adapter_target_rate: float = 0.90
lookback_days: float = 7.0
ontology_max_findings: int = 15
@classmethod
def from_file(cls, path: str | Path) -> MaintenanceConfig:
@@ -83,7 +84,8 @@ HELP = """Befehle:
- status · hilfe · routine · vorschläge
- genehmige P-3 [P-5 …] · genehmige alle mit risiko niedrig · lehne P-3 ab: <Grund>
- wechsle <slot> auf <basismodell> · trainiere adapter für <cluster> · rollback <slot>
- stufe <skill> zurück · markiere <slot> als ok|degraded|unavailable"""
- stufe <skill> zurück · markiere <slot> als ok|degraded|unavailable
- ordne P-7 <konzept> zu (Vokabel-Vorschlag einem vorhandenen Konzept zuordnen)"""
class LLMIntentMapper:
@@ -120,6 +122,7 @@ COMMANDS: list[tuple[str, re.Pattern[str]]] = [
("rollback", r"^rollback\s+(?P<slot>[\w.-]+)$"),
("demote", r"^(stufe\s+(?P<skill>[\w.-]+)\s+zurück|degradiere\s+(?P<skill2>[\w.-]+))$"),
("mark", r"^markiere\s+(?P<slot>[\w.-]+)\s+(als\s+)?(?P<status>ok|degraded|unavailable)$"),
("map_concept", rf"^ordne\s+(?P<id>{_ID})\s+(dem\s+konzept\s+)?(?P<concept>[\w.-]+)\s+zu$"),
("confirm", r"^(ja|yes|j|ok)$"),
("cancel", r"^(nein|no|n|abbrechen)$"),
]
@@ -143,6 +146,7 @@ class MaintenanceAgent:
def __init__(self, *, store: Store, registry: Registry, library: SkillLibrary, index: SkillIndex,
policy: Policy, actions: ActionExecutor, lifecycle: Lifecycle,
config: MaintenanceConfig | None = None, intent_mapper: IntentMapper | None = None,
ontology_check: Callable[[], list[Any]] | None = None,
clock: Callable[[], float] = now, wallclock: Callable[[], datetime] = datetime.now):
self.store = store
self.registry = registry
@@ -153,6 +157,7 @@ class MaintenanceAgent:
self.lifecycle = lifecycle
self.config = config or MaintenanceConfig()
self.intent_mapper = intent_mapper
self.ontology_check = ontology_check
self.clock = clock
self.wallclock = wallclock
self._pending: dict[str, str] = {} # Sitzung → zu bestätigender Befehl
@@ -197,8 +202,9 @@ class MaintenanceAgent:
def _execute(self, seq: int) -> Proposal:
p = self._load(seq)
params = {**p.params, "decided_by": p.decided_by} if p.kind == "ontology_change" else p.params
try:
result = self.actions.execute(p.kind, p.params)
result = self.actions.execute(p.kind, params)
self.store.update_proposal(seq, state="executed", result=result)
except ActionError as exc:
self.store.update_proposal(seq, state="failed", result={"ok": False, "message": str(exc)})
@@ -212,6 +218,9 @@ class MaintenanceAgent:
result={"message": reason or "abgelehnt"})
if p.kind == "promote_candidate":
self.lifecycle.reject(p.params["candidate"], f"abgelehnt: {reason}")
if p.kind == "ontology_change":
params = {k: v for k, v in p.params.items() if k != "key"}
self.store.set_ontology_decision(p.params["key"], "reject", params, by)
return self._load(seq)
def run_deferred(self) -> list[Proposal]:
@@ -318,6 +327,24 @@ class MaintenanceAgent:
f"{cand['kind']} für {cand['slot']} übernehmen ({cand['target']})",
reason="alle Prüfstufen bestanden", source="lifecycle"))
# 8. Ontologie ↔ Datenhaltung (SPEC-ontology §3)
if self.ontology_check is not None:
try:
ontology_findings = self.ontology_check()
except Exception as exc: # noqa: BLE001 – externe Quelle (n8n-API) darf die Routine nicht abbrechen
report.findings.append(f"Ontologie-Abgleich nicht möglich: {type(exc).__name__}: {exc}")
ontology_findings = []
violations = [f for f in ontology_findings if not f.change]
for f in violations[:c.ontology_max_findings]:
report.findings.append(f"Ontologie [{f.severity}]: {f.summary}")
if len(violations) > c.ontology_max_findings:
report.findings.append(f"Ontologie: {len(violations) - c.ontology_max_findings} weitere Befunde "
"(knaster ontology check)")
for f in ontology_findings:
if f.change:
add(self.propose("ontology_change", {"key": f.key, **f.change}, f.summary, reason=f.detail,
dedup_key=f"ontology:{f.key}"))
for p in self.run_deferred():
report.auto_executed.append(f"{p.id} im Wartungsfenster ausgeführt: {p.summary}")
@@ -446,3 +473,13 @@ class MaintenanceAgent:
return self._new("mark_slot", {"slot": args["slot"], "status": args["status"].lower(),
"reason": f"Auftrag von {user}", "set_by": user},
f"Slot {args['slot']} als {args['status'].lower()} markieren", user)
def _cmd_map_concept(self, args: dict[str, str], user: str) -> CommandReply:
p = self._load(_seq(args["id"]))
if p.kind != "ontology_change" or p.params.get("type") != "vocab_value":
raise ValueError(f"{p.id} ist kein Vokabel-Vorschlag")
if p.state != "open":
raise ValueError(f"{p.id} ist {p.state}")
self.store.update_proposal(p.seq, params={**p.params, "decision": "map", "concept": args["concept"]})
done = self.approve(p.seq, by=user)
return CommandReply(text=self._decided_text(done), proposals=[done])
+10
View File
@@ -0,0 +1,10 @@
"""Ontologie der Datenhaltung: Modell, Export, Abgleich mit n8n, Übernahme freigegebener Entscheidungen."""
from .apply import apply_decisions
from .check import OntologyFinding, check
from .export import to_graph, to_html, to_turtle
from .model import Ontology
from .n8n import Snapshot, build_snapshot, fetch_workflows
__all__ = ["Ontology", "OntologyFinding", "Snapshot", "apply_decisions", "build_snapshot", "check",
"fetch_workflows", "to_graph", "to_html", "to_turtle"]
+78
View File
@@ -0,0 +1,78 @@
"""Freigegebene Entscheidungen in die Ontologie übernehmen (`knaster ontology apply`)."""
from __future__ import annotations
import re
from typing import Any
from .model import Concept, Ontology, OntologyClass
NEW_MODULE = "neu"
def _slug(value: str) -> str:
text = value.strip().lower()
for a, b in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss"), ("+", "_"), ("&", "_")):
text = text.replace(a, b)
return re.sub(r"_+", "_", re.sub(r"[^a-z0-9]+", "_", text)).strip("_") or "wert"
def _class_id(table: str) -> str:
return "".join(part.capitalize() for part in table.split("_"))
def apply_decision(onto: Ontology, key: str, decision: str, params: dict[str, Any]) -> str:
"""Eine Entscheidung anwenden. decision ∈ {accept, map, reject}. Gibt eine Beschreibung zurück."""
change = params.get("type")
if decision == "reject":
if change == "vocab_value":
v = onto.vocabularies[params["vocabulary"]]
if not v.is_rejected(params["value"]):
v.rejected.append(params["value"])
return f"{v.label}: „{params['value']}“ verworfen"
if key not in onto.ignore:
onto.ignore.append(key)
return f"{key} wird künftig ignoriert"
if change == "vocab_value":
v = onto.vocabularies[params["vocabulary"]]
value = params["value"]
if decision == "map":
target = v.resolve(params["concept"])
if target is None:
raise KeyError(f"Konzept {params['concept']} gibt es in {v.label} nicht")
if value.lower() not in (a.lower() for a in v.concepts[target].alt):
v.concepts[target].alt.append(value)
return f"{v.label}: „{value}“ → {target}"
cid = _slug(value)
if cid in v.concepts:
raise ValueError(f"{v.label}: Konzept {cid} existiert bereits")
v.concepts[cid] = Concept(label=value if value != cid else "")
return f"{v.label}: neues Konzept {cid}"
if change == "add_attribute":
onto.classes[params["class"]].attributes[params["column"]] = params["sql_type"]
return f"{params['class']}: Attribut {params['column']} ({params['sql_type']})"
if change == "add_class":
cid = _class_id(params["table"])
if cid in onto.classes:
raise ValueError(f"Klasse {cid} existiert bereits")
onto.modules.setdefault(NEW_MODULE, "Noch nicht zugeordnet")
onto.classes[cid] = OntologyClass(label=params["table"], module=NEW_MODULE, table=params["table"],
definition="Automatisch aufgenommen; Definition und Beziehungen ergänzen.",
attributes=dict(params.get("attributes") or {}))
return f"neue Klasse {cid} für Tabelle {params['table']}"
raise ValueError(f"Unbekannte Änderung {change}")
def apply_decisions(onto: Ontology, decisions: list[dict[str, Any]]) -> tuple[list[str], list[str]]:
"""→ (übernommene Schlüssel, Meldungen). Fehlerhafte Entscheidungen bleiben offen und erscheinen als Meldung."""
applied, messages = [], []
for d in decisions:
try:
messages.append(apply_decision(onto, d["key"], d["decision"], d.get("params") or {}))
applied.append(d["key"])
except (KeyError, ValueError) as exc:
messages.append(f"{d['key']}: nicht übernommen ({exc})")
if applied:
onto.version += 1
return applied, messages
+177
View File
@@ -0,0 +1,177 @@
"""Abgleich Ontologie ↔ tatsächliche Datenhaltung in n8n (SPEC-ontology §3).
Zwei Arten von Ergebnissen:
- Befunde mit `change`: die Ontologie sollte wachsen (neuer Vokabelwert, neue Spalte, neue Tabelle).
Daraus werden Vorschläge zur Freigabe.
- Befunde ohne `change`: die Datenhaltung verstößt gegen die Ontologie bzw. gegen KNOW
(unbekannte Operation, SQL an KNOW vorbei, Konzept ohne Erzeuger, Tabelle ohne Schreiber).
"""
from __future__ import annotations
from collections.abc import Iterable
from typing import Any, Literal
from pydantic import BaseModel
from .model import Ontology
from .n8n import Snapshot, is_closed, literal_values, value_segments
Severity = Literal["info", "low", "medium", "high"]
SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2, "info": 3}
class OntologyFinding(BaseModel):
key: str
kind: str
severity: Severity
summary: str
detail: str = ""
change: dict[str, Any] | None = None
class _Obs:
"""Beobachtete Werte eines Vokabulars: Wert → Quellen, dazu welche Quellen nur feste Werte liefern."""
def __init__(self) -> None:
self.values: dict[str, set[str]] = {}
self.closed: set[str] = set() # Quellen mit geschlossener Wertemenge (aktiv)
self.open: set[str] = set() # Quellen mit freien Werten (aktiv)
self.produced: set[str] = set() # Werte aus aktiven Quellen
def add(self, value: str, source: str, active: bool) -> None:
self.values.setdefault(value, set()).add(source)
if active:
self.produced.add(value)
def _vocab_observations(onto: Ontology, snap: Snapshot) -> dict[str, _Obs]:
assert snap.know is not None
by_column = onto.vocab_columns()
obs: dict[str, _Obs] = {}
for call in snap.calls:
if not call.operation:
continue
for table, cols in snap.know.writes.get(call.operation, {}).items():
for col in cols:
ref = f"{table}.{col}"
for vid in by_column.get(ref, []):
o = obs.setdefault(vid, _Obs())
label = f"{call.workflow}{'' if call.active else ' (inaktiv)'}"
for value in snap.know.assigned.get(call.operation, {}).get(ref, set()):
o.add(value, f"KNOW {call.operation} (aufgerufen von {label})", call.active)
if call.inferred or not call.payload:
continue
values = literal_values(call.payload, col)
for value in values:
o.add(value, f"{label} → {call.operation}.{col}", call.active)
if call.active and value_segments(call.payload, col):
(o.closed if is_closed(call.payload, col) else o.open).add(call.workflow)
for ref, values in snap.know.seeds.items():
for vid in by_column.get(ref, []):
for value in values:
obs.setdefault(vid, _Obs()).add(value, f"KNOW-Seed {ref}", False)
return obs
def check(onto: Ontology, snap: Snapshot, decided: Iterable[str] = ()) -> list[OntologyFinding]:
skip = set(decided) | set(onto.ignore)
out: list[OntologyFinding] = []
know = snap.know
if know is None:
return [OntologyFinding(key="know:missing", kind="know_missing", severity="high",
summary="KNOW-Workflow nicht gefunden (Code mit `const allowed` und Schema)")]
# 1. Schema ↔ Ontologie
mapped = onto.tables()
for table, cols in sorted(know.schema.items()):
cid = mapped.get(table)
if cid is None:
out.append(OntologyFinding(
key=f"table:{table}", kind="unmapped_table", severity="medium",
summary=f"Tabelle {table} ({len(cols)} Spalten) fehlt in der Ontologie",
detail="Als neue Klasse aufnehmen; Modul, Definition und Beziehungen danach im Repo ergänzen.",
change={"type": "add_class", "table": table, "attributes": dict(cols)}))
continue
attrs = onto.classes[cid].attributes
for col, typ in cols.items():
if col not in attrs:
out.append(OntologyFinding(
key=f"column:{table}.{col}", kind="unmapped_column", severity="low",
summary=f"Neue Spalte {table}.{col} ({typ}) – als Attribut von {cid} aufnehmen?",
change={"type": "add_attribute", "class": cid, "column": col, "sql_type": typ}))
for col in attrs:
if col not in cols:
out.append(OntologyFinding(key=f"gone:{table}.{col}", kind="missing_column", severity="low",
summary=f"Attribut {cid}.{col} gibt es in KNOW nicht (mehr)"))
for cid, c in onto.classes.items():
if c.table and c.table not in know.schema:
out.append(OntologyFinding(key=f"gone:{c.table}", kind="missing_table", severity="medium",
summary=f"Klasse {cid} verweist auf Tabelle {c.table}, die KNOW nicht anlegt"))
# 2. Aufrufe unbekannter Operationen (pro Workflow und Operation zusammengefasst)
unknown: dict[tuple[str, str], list[str]] = {}
active_of: dict[str, bool] = {}
for call in snap.calls:
if call.operation and not call.inferred and call.operation not in know.allowed:
unknown.setdefault((call.workflow, call.operation), []).append(call.node)
active_of[call.workflow] = call.active
for (wf, op), nodes in sorted(unknown.items()):
out.append(OntologyFinding(
key=f"op:{wf}:{op}", kind="unknown_operation", severity="high" if active_of[wf] else "low",
summary=f"{wf}{'' if active_of[wf] else ' (inaktiv)'} ruft `{op}` auf, die Operation gibt es in KNOW nicht",
detail=f"Knoten: {', '.join(sorted(set(nodes)))}. KNOW lehnt mit „Nicht erlaubte KNOW operation“ ab."))
# 3. SQL an KNOW vorbei
for d in snap.direct_sql:
out.append(OntologyFinding(
key=f"sql:{d.workflow}:{d.node}", kind="direct_sql",
severity=("medium" if d.writes else "low") if d.active else "info",
summary=f"{d.workflow} {'schreibt' if d.writes else 'liest'} per eigenem SQL in "
f"{', '.join(sorted(d.tables))} (an KNOW vorbei)",
detail=f"Knoten „{d.node}“"))
# 4. Vokabulare: neue Werte und Konzepte, die keine feste Zuordnung erzeugt
obs = _vocab_observations(onto, snap)
for vid, o in sorted(obs.items()):
v = onto.vocabularies[vid]
for value, sources in sorted(o.values.items()):
if v.resolve(value) or v.is_rejected(value):
continue
only_inactive = all("(inaktiv)" in s_ for s_ in sources)
out.append(OntologyFinding(
key=f"vocab:{vid}:{value.lower()}", kind="vocab_value", severity="low" if only_inactive else "medium",
summary=f"{v.label}: neuer Wert „{value}“",
detail="Quelle: " + "; ".join(sorted(sources)) + ". Als Konzept aufnehmen, einem Konzept "
"zuordnen („ordne P-x <konzept> zu“) oder verwerfen.",
change={"type": "vocab_value", "vocabulary": vid, "value": value, "sources": sorted(sources)}))
if o.closed:
produced = {v.resolve(x) for x in o.produced}
missing = [cid for cid, c in v.concepts.items() if cid not in produced and not c.technical]
if missing:
extra = f"; freie Werte kommen zusätzlich aus {', '.join(sorted(o.open))}" if o.open else ""
out.append(OntologyFinding(
key=f"unused:{vid}", kind="concept_without_producer", severity="info",
summary=f"{v.label}: {', '.join(missing)} erzeugt keine feste Zuordnung "
f"(geprüft: {', '.join(sorted(o.closed))}{extra})"))
# 5. Tabellenklassen ohne aktiven Schreibweg
active_ops = {c.operation for c in snap.calls if c.active and c.operation} # inkl. abgeleiteter
for cid, c in onto.classes.items():
if not c.table or c.table not in know.schema:
continue
writers = {op for op, tables in know.writes.items() if c.table in tables and op != "setup_schema"}
seeded = any(ref.startswith(f"{c.table}.") for ref in know.seeds)
if not writers and seeded:
continue # Konfigurationstabelle, die setup_schema befüllt
if not writers:
out.append(OntologyFinding(key=f"nowriter:{c.table}", kind="no_write_operation", severity="info",
summary=f"{cid} ({c.table}): KNOW hat keine Schreib-Operation"))
elif not writers & active_ops:
out.append(OntologyFinding(key=f"nowriter:{c.table}", kind="no_active_writer", severity="info",
summary=f"{cid} ({c.table}): kein aktiver Workflow ruft "
f"{', '.join(sorted(writers))} auf"))
out = [f for f in out if f.key not in skip]
out.sort(key=lambda f: (SEVERITY_ORDER[f.severity], f.kind, f.key))
return out
+133
View File
@@ -0,0 +1,133 @@
"""Erzeugte Artefakte aus der Ontologie: OWL/Turtle, Graph-JSON für die Ansicht, eigenständige HTML-Seite."""
from __future__ import annotations
import json
import re
from datetime import date
from pathlib import Path
from typing import Any
from .model import Ontology
XSD = {"TEXT": "xsd:string", "NUMERIC": "xsd:decimal", "INTEGER": "xsd:integer", "BIGINT": "xsd:integer",
"SMALLINT": "xsd:integer", "BIGSERIAL": "xsd:integer", "BOOLEAN": "xsd:boolean", "DATE": "xsd:date",
"TIMESTAMPTZ": "xsd:dateTime", "JSONB": "rdf:JSON", "DOUBLE PRECISION": "xsd:double"}
BOOKKEEPING = {"id", "created_at", "updated_at"}
def _lit(text: str) -> str:
return '"' + text.replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ") + '"@de'
def _local(text: str) -> str:
text = (text.replace("+", "plus").replace("ä", "ae").replace("ö", "oe").replace("ü", "ue")
.replace("Ä", "Ae").replace("Ö", "Oe").replace("Ü", "Ue").replace("ß", "ss"))
return re.sub(r"[^A-Za-z0-9_]", "_", text)
def _union(ids: list[str], prefix: str) -> str:
if len(ids) == 1:
return f"{prefix}:{ids[0]}"
return "[ a owl:Class ; owl:unionOf ( " + " ".join(f"{prefix}:{i}" for i in ids) + " ) ]"
def relation_columns(onto: Ontology, cid: str) -> set[str]:
"""Spalten einer Tabellenklasse, über die eine Beziehung läuft (keine Attribute im engeren Sinn)."""
c = onto.classes[cid]
cols: set[str] = set()
for r in onto.relations:
if r.source == cid and c.table:
cols |= set(re.findall(r"\b([a-z_]+)\b", r.via.split("=")[0])) & set(c.attributes)
return cols
def to_turtle(onto: Ontology) -> str:
p = onto.id
out = [f"@prefix {p}: <{onto.namespace}> .", "@prefix owl: <http://www.w3.org/2002/07/owl#> .",
"@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .",
"@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .",
"@prefix skos: <http://www.w3.org/2004/02/skos/core#> .",
"@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .", "",
f"<{onto.namespace.rstrip('#/')}> a owl:Ontology ;",
f" rdfs:label {_lit(onto.label or onto.id)} ;",
f' owl:versionInfo "{onto.version}" .', "",
f"{p}:quelleTabelle a owl:AnnotationProperty .", f"{p}:viaSpalte a owl:AnnotationProperty .", ""]
for cid, c in onto.classes.items():
lines = [f"{p}:{cid} a owl:Class", f" rdfs:label {_lit(c.label)}"]
if c.definition:
lines.append(f" rdfs:comment {_lit(c.definition)}")
if c.table:
lines.append(f' {p}:quelleTabelle "{c.table}"')
if c.subclass_of:
lines.append(f" rdfs:subClassOf {p}:{c.subclass_of}")
out.append(" ;\n".join(lines) + " .\n")
props: dict[str, dict[str, list[str]]] = {}
for r in onto.relations:
e = props.setdefault(r.name, {"dom": [], "rng": [], "via": []})
for k, v in (("dom", r.source), ("rng", r.target)):
if v not in e[k]:
e[k].append(v)
e["via"].append(f"{r.source}→{r.target}: {r.via}" if r.via else f"{r.source}→{r.target}")
for name, e in props.items():
out.append(f"{p}:{name} a owl:ObjectProperty ;\n rdfs:domain {_union(e['dom'], p)} ;\n"
f" rdfs:range {_union(e['rng'], p)} ;\n"
+ " ;\n".join(f" {p}:viaSpalte {_lit(v)}" for v in e["via"]) + " .\n")
dprops: dict[str, dict[str, Any]] = {}
for cid, c in onto.classes.items():
skip = relation_columns(onto, cid) | BOOKKEEPING
for col, typ in c.attributes.items():
if col in skip:
continue
d = dprops.setdefault(col, {"dom": [], "rng": XSD.get(typ.split("(")[0].strip(), "xsd:string")})
d["dom"].append(cid)
for col, d in sorted(dprops.items()):
out.append(f"{p}:{_local(col)} a owl:DatatypeProperty ;\n rdfs:domain {_union(d['dom'], p)} ;\n"
f" rdfs:range {d['rng']} .\n")
for vid, v in onto.vocabularies.items():
scheme = f"{p}:{_local(vid)}Schema"
out.append(f"{scheme} a skos:ConceptScheme ;\n rdfs:label {_lit(v.label)} .\n")
for cid, c in v.concepts.items():
types = f"skos:Concept, {p}:{v.class_}" if v.class_ else "skos:Concept"
lines = [f"{p}:{_local(vid)}_{_local(cid)} a {types}", f" skos:inScheme {scheme}",
f" skos:notation \"{cid}\"", f" skos:prefLabel {_lit(c.label or cid)}"]
lines += [f" skos:altLabel {_lit(a)}" for a in c.alt]
if c.note:
lines.append(f" skos:scopeNote {_lit(c.note)}")
out.append(" ;\n".join(lines) + " .\n")
return "\n".join(out)
def to_graph(onto: Ontology, generated: str | None = None) -> dict[str, Any]:
"""Datenformat der Ansicht (ontology/viewer.html.tmpl)."""
enums: dict[str, dict[str, list[str]]] = {}
individuals: dict[str, list[str]] = {}
for v in onto.vocabularies.values():
values = [c.label or cid for cid, c in v.concepts.items()]
if v.class_:
individuals.setdefault(v.class_, []).extend(x for x in values if x not in individuals.get(v.class_, []))
else:
for ref in v.columns:
table, _, col = ref.partition(".")
owner = onto.class_for_table(table)
if owner:
enums.setdefault(owner, {})[col] = list(v.concepts)
classes = []
for cid, c in onto.classes.items():
skip = relation_columns(onto, cid) | BOOKKEEPING
classes.append({"id": cid, "label": c.label, "module": c.module, "table": c.table, "def": c.definition,
"key": c.key or "–", "attrs": [[k, t] for k, t in c.attributes.items() if k not in skip],
"individuals": individuals.get(cid, []), "enums": enums.get(cid, {}), "sens": c.sensitive,
"abstract": c.abstract})
relations = [r.model_dump() for r in onto.relations]
relations += [{"source": cid, "name": "subClassOf", "target": c.subclass_of, "via": "", "kind": "subclass"}
for cid, c in onto.classes.items() if c.subclass_of]
return {"classes": classes, "relations": relations, "version": onto.version,
"generated": generated or date.today().isoformat()}
def to_html(onto: Ontology, template: str | Path, generated: str | None = None) -> str:
tmpl = Path(template).read_text(encoding="utf-8")
data = json.dumps(to_graph(onto, generated), ensure_ascii=False).replace("</", "<\\/")
ttl = json.dumps(to_turtle(onto), ensure_ascii=False).replace("</", "<\\/")
return tmpl.replace("__DATA__", data).replace("__TTL__", ttl)
+148
View File
@@ -0,0 +1,148 @@
"""Ontologie als Quelle der Wahrheit: Kern (Klassen, Beziehungen), Vokabulare, Abbildung auf Tabellen.
Drei Ebenen mit unterschiedlicher Pflege (SPEC-ontology §1):
- Kern: bewusst gepflegt, Änderungen per Commit.
- Vokabulare: wachsen über Vorschläge aus der Service-Routine (beobachtet → vorgeschlagen → bestätigt).
- Instanzen: leben in den Daten, nicht in dieser Datei.
"""
from __future__ import annotations
from pathlib import Path
from typing import Literal
import yaml
from pydantic import BaseModel, Field
from ..util import load_yaml
RelationKind = Literal["key", "soft", "external", "subclass"]
class OntologyClass(BaseModel):
label: str
module: str
definition: str = ""
table: str | None = None # None = implizit (nur als Wert in Spalten) oder externes System
key: str = ""
sensitive: str = ""
abstract: bool = False
subclass_of: str | None = None
attributes: dict[str, str] = Field(default_factory=dict) # Spalte → SQL-Typ (Tabellenklassen)
class Relation(BaseModel):
source: str
name: str
target: str
via: str = ""
kind: RelationKind = "key"
class Concept(BaseModel):
label: str = ""
alt: list[str] = Field(default_factory=list) # weitere Schreibweisen, die auf dieses Konzept zeigen
note: str = ""
technical: bool = False # kein fachlicher Wert (z. B. Rückfall „default“); zählt nicht als „fehlt“
class Vocabulary(BaseModel):
label: str
class_: str | None = Field(default=None, alias="class")
columns: list[str] = Field(default_factory=list) # "tabelle.spalte", in denen Werte dieses Vokabulars stehen
concepts: dict[str, Concept] = Field(default_factory=dict)
rejected: list[str] = Field(default_factory=list) # bewusst verworfene Werte (kein Konzept)
model_config = {"populate_by_name": True}
def resolve(self, value: str) -> str | None:
"""Wert → Konzept-ID (über ID, Label oder alternative Schreibweise), sonst None."""
v = value.strip().lower()
for cid, c in self.concepts.items():
if v == cid.lower() or v == c.label.lower() or v in (a.lower() for a in c.alt):
return cid
return None
def is_rejected(self, value: str) -> bool:
return value.strip().lower() in (r.lower() for r in self.rejected)
class Ontology(BaseModel):
id: str = "know"
label: str = ""
namespace: str = "urn:example:onto#"
version: int = 1
modules: dict[str, str] = Field(default_factory=dict)
classes: dict[str, OntologyClass] = Field(default_factory=dict)
relations: list[Relation] = Field(default_factory=list)
vocabularies: dict[str, Vocabulary] = Field(default_factory=dict)
ignore: list[str] = Field(default_factory=list) # bewusst hingenommene Befunde (Schlüssel aus `check`)
@classmethod
def from_file(cls, path: str | Path) -> Ontology:
return cls(**load_yaml(path))
def save(self, path: str | Path, header: str = "") -> None:
data = {"id": self.id, "version": self.version, **self.model_dump(by_alias=True, exclude_defaults=True)}
text = yaml.safe_dump(data, sort_keys=False, allow_unicode=True, width=120)
Path(path).write_text(header + text, encoding="utf-8")
# --- Abfragen ---------------------------------------------------------------------------
def class_for_table(self, table: str) -> str | None:
return next((cid for cid, c in self.classes.items() if c.table == table), None)
def tables(self) -> dict[str, str]:
return {c.table: cid for cid, c in self.classes.items() if c.table}
def vocabularies_for(self, table: str, column: str) -> list[str]:
ref = f"{table}.{column}"
return [vid for vid, v in self.vocabularies.items() if ref in v.columns]
def vocab_columns(self) -> dict[str, list[str]]:
out: dict[str, list[str]] = {}
for vid, v in self.vocabularies.items():
for ref in v.columns:
out.setdefault(ref, []).append(vid)
return out
# --- Prüfung ----------------------------------------------------------------------------
def validate_model(self) -> list[str]:
errors: list[str] = []
for cid, c in self.classes.items():
if c.module not in self.modules:
errors.append(f"Klasse {cid}: Modul {c.module} unbekannt")
if c.subclass_of and c.subclass_of not in self.classes:
errors.append(f"Klasse {cid}: Oberklasse {c.subclass_of} unbekannt")
if c.attributes and not c.table:
errors.append(f"Klasse {cid}: Attribute ohne Tabelle")
seen_tables: dict[str, str] = {}
for cid, c in self.classes.items():
if c.table:
if c.table in seen_tables:
errors.append(f"Tabelle {c.table} ist {seen_tables[c.table]} und {cid} zugeordnet")
seen_tables[c.table] = cid
for r in self.relations:
for end in (r.source, r.target):
if end not in self.classes:
errors.append(f"Beziehung {r.source} {r.name} {r.target}: Klasse {end} unbekannt")
for vid, v in self.vocabularies.items():
if v.class_ and v.class_ not in self.classes:
errors.append(f"Vokabular {vid}: Klasse {v.class_} unbekannt")
for ref in v.columns:
table, _, column = ref.partition(".")
owner = self.class_for_table(table)
if owner is None:
errors.append(f"Vokabular {vid}: Tabelle {table} keiner Klasse zugeordnet")
elif column not in self.classes[owner].attributes:
errors.append(f"Vokabular {vid}: Spalte {ref} fehlt in Klasse {owner}")
labels: dict[str, str] = {}
for cid, c in v.concepts.items():
for name in [cid, c.label, *c.alt]:
key = name.strip().lower()
if key and key in labels and labels[key] != cid:
errors.append(f"Vokabular {vid}: „{name}“ zeigt auf {labels[key]} und {cid}")
labels.setdefault(key, cid)
for r in v.rejected:
if v.resolve(r):
errors.append(f"Vokabular {vid}: „{r}“ ist zugleich verworfen und Konzept {v.resolve(r)}")
return errors
+325
View File
@@ -0,0 +1,325 @@
"""Momentaufnahme der n8n-Datenhaltung: KNOW-Spezifikation, Aufrufe, direktes SQL (nur lesend).
KNOW ist ein n8n-Sub-Workflow, der feste Operationen auf SQL-Vorlagen abbildet. Seine Spezifikation
(erlaubte Operationen, Schema, geschriebene Spalten) wird aus dem Code des Workflows gelesen, nicht aus der DB.
"""
from __future__ import annotations
import json
import re
import time
from dataclasses import dataclass, field
from typing import Any
import httpx
_ALLOWED = re.compile(r"const allowed\s*=\s*\[([^\]]+)\]")
_OP = re.compile(r"""if\s*\(\s*operation\s*===\s*['"]([a-z_0-9]+)['"]""")
_INSERT = re.compile(r"INSERT INTO \$\{t\.(\w+)\}(?:\s+AS\s+\w+)?\s*\(")
_UPDATE = re.compile(r"UPDATE \$\{t\.(\w+)\}(?:\s+AS)?(?:\s+\w+)?\s+SET\s")
_DELETE = re.compile(r"DELETE FROM \$\{t\.(\w+)\}")
_WRITE_SQL = re.compile(r"\b(INSERT\s+INTO|UPDATE|DELETE\s+FROM)\b", re.IGNORECASE)
@dataclass
class KnowSpec:
workflow_id: str
workflow_name: str
allowed: set[str] = field(default_factory=set)
schema: dict[str, dict[str, str]] = field(default_factory=dict) # Tabelle → Spalte → Typ
writes: dict[str, dict[str, set[str]]] = field(default_factory=dict) # Operation → Tabelle → Spalten
seeds: dict[str, set[str]] = field(default_factory=dict) # "tabelle.spalte" → Literalwerte aus Seed-INSERTs
assigned: dict[str, dict[str, set[str]]] = field(default_factory=dict) # Operation → "tab.spalte" → Literale
@dataclass
class KnowCall:
workflow: str
active: bool
node: str
operation: str | None # None = erst zur Laufzeit bestimmt
payload: str = ""
inferred: bool = False # Operation aus Literalen im Workflow abgeleitet (Laufzeit-Operation)
@dataclass
class DirectSql:
workflow: str
active: bool
node: str
query: str
tables: set[str]
writes: bool
@dataclass
class Snapshot:
know: KnowSpec | None
calls: list[KnowCall]
direct_sql: list[DirectSql]
workflow_count: int
taken_at: float = field(default_factory=time.time)
# --- Hilfsfunktionen für JS/SQL-Text ------------------------------------------------------------
def _balanced(text: str, start: int) -> tuple[str, int]:
"""text[start] ist eine öffnende Klammer → (Inhalt, Index der schließenden Klammer)."""
depth = 0
for i in range(start, len(text)):
if text[i] in "([{":
depth += 1
elif text[i] in ")]}":
depth -= 1
if depth == 0:
return text[start + 1:i], i
return text[start + 1:], len(text)
def _split_top(text: str) -> list[str]:
parts, depth, cur, quote = [], 0, "", ""
for ch in text:
if quote:
cur += ch
if ch == quote:
quote = ""
continue
if ch in "'\"`":
quote = ch
elif ch in "([{":
depth += 1
elif ch in ")]}":
depth -= 1
if ch == "," and depth == 0:
parts.append(cur)
cur = ""
else:
cur += ch
if cur.strip():
parts.append(cur)
return parts
def _column_type(definition: str) -> str:
words = definition.split()
if len(words) < 2:
return "TEXT"
return "DOUBLE PRECISION" if words[1].upper() == "DOUBLE" else words[1].upper()
def _take_sets(writes: dict[str, set[str]], assigned: dict[str, set[str]], table: str, setp: str) -> None:
"""SET-Klausel auswerten: geschriebene Spalten und dabei gesetzte Literale merken."""
for part in _split_top(setp):
if "=" not in part:
continue
col, _, expr = part.partition("=")
col = re.sub(r"^\w+\.", "", col.strip())
writes.setdefault(table, set()).add(col)
lits = {x for x in re.findall(r"'([^'$]*)'", expr) if x and "{" not in x}
if lits:
assigned.setdefault(f"{table}.{col}", set()).update(lits)
def parse_know_code(code: str, workflow_id: str = "", workflow_name: str = "") -> KnowSpec:
spec = KnowSpec(workflow_id=workflow_id, workflow_name=workflow_name)
m = _ALLOWED.search(code)
if m:
spec.allowed = set(re.findall(r"['\"]([a-z_0-9]+)['\"]", m.group(1)))
for m in re.finditer(r"CREATE TABLE IF NOT EXISTS \$\{t\.(\w+)\}\s*\(", code):
body, _ = _balanced(code, m.end() - 1)
cols = spec.schema.setdefault(m.group(1), {})
for part in _split_top(body):
part = " ".join(part.split())
if not part or re.match(r"^(UNIQUE|PRIMARY KEY|CONSTRAINT|CHECK)\b", part, re.IGNORECASE):
continue
cols.setdefault(part.split()[0], _column_type(part))
for m in re.finditer(r"ALTER TABLE \$\{t\.(\w+)\}([^;`]*)", code):
for a in re.finditer(r"ADD COLUMN IF NOT EXISTS\s+(\w+)\s+([^,]+)", m.group(2)):
spec.schema.setdefault(m.group(1), {}).setdefault(a.group(1), _column_type("x " + a.group(2)))
starts = [(m.start(), m.group(1)) for m in _OP.finditer(code)]
for i, (pos, op) in enumerate(starts):
block = code[pos:starts[i + 1][0] if i + 1 < len(starts) else len(code)]
writes = spec.writes.setdefault(op, {})
assigned = spec.assigned.setdefault(op, {})
for m in _INSERT.finditer(block):
inner, end = _balanced(block, m.end() - 1)
vm = re.match(r"\s*VALUES\s*\(", block[end + 1:end + 40])
if vm and "${" not in _balanced(block, end + 1 + vm.end() - 1)[0]:
continue # reines Literal-INSERT = Seed aus setup_schema, keine Schreiboperation von `op`
writes.setdefault(m.group(1), set()).update(c.strip() for c in inner.split(",") if c.strip())
rest = block[end:end + 6000]
conflict = re.search(r"ON CONFLICT\s*\([^)]*\)\s*DO\s+UPDATE SET\s", rest)
if conflict:
tail = rest[conflict.end():]
stop = re.search(r"\sRETURNING\s|\sWHERE\s|;|`", tail)
_take_sets(writes, assigned, m.group(1), tail[:stop.start()] if stop else tail)
for m in _UPDATE.finditer(block):
tail = block[m.end():m.end() + 6000]
stop = re.search(r"\sWHERE\s|\sRETURNING\s|\sFROM\s|;|`", tail)
_take_sets(writes, assigned, m.group(1), tail[:stop.start()] if stop else tail)
for m in _DELETE.finditer(block):
writes.setdefault(m.group(1), set())
# Literalwerte in INSERTs (Seeds wie die Hinweis-Konfiguration, feste Werte wie alias_type 'learned')
for m in _INSERT.finditer(code):
inner, end = _balanced(code, m.end() - 1)
cols = [c.strip() for c in inner.split(",")]
vm = re.match(r"\s*VALUES\s*\(", code[end + 1:end + 40])
if not vm:
continue
values, _ = _balanced(code, end + 1 + vm.end() - 1)
for col, val in zip(cols, _split_top(values), strict=False):
lit = re.fullmatch(r"\s*'([^'$]*)'\s*", val)
if lit and lit.group(1):
spec.seeds.setdefault(f"{m.group(1)}.{col}", set()).add(lit.group(1))
return spec
def value_segments(expression: str, key: str) -> list[str]:
"""Ausdrücke, die `key` auf der obersten Ebene des Payload-Objekts zugewiesen werden.
Verschachtelte Schlüssel (z. B. `raw_signals: { category: … }`) gehören zu einer JSON-Spalte, nicht zu `key`.
"""
anchor = expression.find("JSON.stringify(")
brace = expression.find("{", anchor if anchor >= 0 else 0)
while brace >= 0 and expression[brace + 1:brace + 2] == "{": # n8n-Ausdruck „={{ … }}“ überspringen
brace = expression.find("{", brace + 2)
if brace < 0:
return []
body, _ = _balanced(expression, brace)
out = []
for part in _split_top(body):
m = re.match(rf"\s*['\"]?{re.escape(key)}['\"]?\s*:", part)
if m:
out.append(part[m.end():])
return out
def literal_values(expression: str, key: str) -> set[str]:
"""Literale, die ein Payload-Ausdruck für `key` liefern kann.
Erkennt `key: 'x'`, `key: a || 'x'` und Zuordnungstabellen `key: ({'Anzeige': 'wert', …}[expr] || 'rest')`.
Literale in Knotenverweisen wie `$('Knoten')` zählen nicht.
"""
found: set[str] = set()
for seg in value_segments(expression, key):
seg = re.sub(r"\$\(\s*(['\"]).*?\1\s*\)", "", seg) # $('Knoten') entfernen
pairs = re.findall(r"(['\"])([^'\"]*)\1\s*:\s*(['\"])([^'\"]*)\3", seg)
found |= {p[3] for p in pairs}
seg = re.sub(r"(['\"])[^'\"]*\1\s*:\s*(['\"])[^'\"]*\2", "", seg)
for lit in re.findall(r"(['\"])([^'\"]*)\1(?!\s*:)", seg):
if lit[1] and not re.search(r"[{}$]", lit[1]):
found.add(lit[1])
return found
def is_closed(expression: str, key: str) -> bool:
"""True, wenn `key` nur feste Werte annehmen kann (Literal oder Zuordnungstabelle mit Literal-Rückfall)."""
segments = value_segments(expression, key)
if not segments:
return False
for seg in segments:
seg = re.sub(r"(['\"])(?:(?!\1).)*\1", "L", seg) # Literale
while True: # Zuordnungstabelle {L:L, …}[…] und Klammern entfernen
new = re.sub(r"\{[L:,\s]*\}\s*\[[^\[\]]*\]", "L", seg)
new = re.sub(r"\(\s*([L|\s]*)\s*\)", r"\1", new)
if new == seg:
break
seg = new
if not (re.fullmatch(r"[L|\s]+", seg) and "L" in seg):
return False
return True
# --- Workflows auswerten -----------------------------------------------------------------------
def find_know(workflows: list[dict[str, Any]]) -> KnowSpec | None:
for wf in workflows:
for node in wf.get("nodes", []):
code = (node.get("parameters") or {}).get("jsCode") or ""
if "const allowed" in code and "CREATE TABLE IF NOT EXISTS" in code:
return parse_know_code(code, wf.get("id", ""), wf.get("name", ""))
return None
def _inputs(node: dict[str, Any]) -> dict[str, Any]:
return ((node.get("parameters") or {}).get("workflowInputs") or {}).get("value") or {}
def extract_calls(workflows: list[dict[str, Any]], know_id: str, allowed: set[str] | None = None) -> list[KnowCall]:
calls = []
for wf in workflows:
if wf.get("id") == know_id:
continue
for node in wf.get("nodes", []):
if not node.get("type", "").endswith("executeWorkflow"):
continue
if know_id not in json.dumps(node.get("parameters") or {}):
continue
values = _inputs(node)
op = str(values.get("operation", "")).strip()
literal = op if re.fullmatch(r"[a-z_0-9]+", op) else None
if literal is None and allowed:
# Operation wird zur Laufzeit gesetzt: Kandidaten sind bekannte Operationen, die im Workflow als
# Literal vorkommen (z. B. in einem Code-Knoten davor)
text = json.dumps(wf.get("nodes", []))
for cand in sorted(o for o in allowed if re.search(rf"""['"\\]{o}['"\\]""", text)):
calls.append(KnowCall(wf.get("name", ""), bool(wf.get("active")), node.get("name", ""), cand,
"", inferred=True))
continue
calls.append(KnowCall(wf.get("name", ""), bool(wf.get("active")), node.get("name", ""), literal,
str(values.get("payload_json", ""))))
return calls
def extract_direct_sql(workflows: list[dict[str, Any]], know_id: str,
schema_name: str = "knowledge") -> list[DirectSql]:
out = []
ref = re.compile(rf"""["']?{re.escape(schema_name)}["']?\s*\.\s*["']?(\w+)""", re.IGNORECASE)
for wf in workflows:
if wf.get("id") == know_id:
continue
for node in wf.get("nodes", []):
if not node.get("type", "").endswith(".postgres"):
continue
query = str((node.get("parameters") or {}).get("query", ""))
tables = set(ref.findall(query))
if not tables:
continue
out.append(DirectSql(wf.get("name", ""), bool(wf.get("active")), node.get("name", ""), query, tables,
bool(_WRITE_SQL.search(query))))
return out
def build_snapshot(workflows: list[dict[str, Any]]) -> Snapshot:
know = find_know(workflows)
know_id = know.workflow_id if know else ""
return Snapshot(know=know, calls=extract_calls(workflows, know_id, know.allowed) if know else [],
direct_sql=extract_direct_sql(workflows, know_id), workflow_count=len(workflows))
def fetch_workflows(host: str, api_key: str, *, client: httpx.Client | None = None,
timeout: float = 30.0) -> list[dict[str, Any]]:
"""Alle Workflows über die n8n-API lesen (nur GET)."""
base = host if host.startswith("http") else f"https://{host}"
base = base.rstrip("/")
own = client is None
client = client or httpx.Client(timeout=timeout)
try:
out: list[dict[str, Any]] = []
cursor = None
while True:
params: dict[str, Any] = {"limit": 250}
if cursor:
params["cursor"] = cursor
r = client.get(f"{base}/api/v1/workflows", params=params, headers={"X-N8N-API-KEY": api_key})
r.raise_for_status()
body = r.json()
out.extend(body.get("data", []))
cursor = body.get("nextCursor")
if not cursor:
return out
finally:
if own:
client.close()
+8
View File
@@ -38,6 +38,10 @@ class Settings:
webhook_auth: str = "" # Wert des Authorization-Headers für ausgehende Webhooks (n8n Header Auth)
request_timeout: float = 120.0
canary_interval: float = 60.0 # 0 = Prober aus
ontology_path: Path = field(default_factory=lambda: REPO_ROOT / "ontology" / "know.yaml")
ontology_snapshot: str = "" # Datei mit n8n-Workflows (JSON) statt Live-Abruf, z. B. für Tests/offline
n8n_host: str = "" # nur lesend: GET /api/v1/workflows für den Ontologie-Abgleich
n8n_api_key: str = ""
@classmethod
def from_env(cls) -> Settings:
@@ -65,4 +69,8 @@ class Settings:
webhook_auth=env.get("KNASTER_WEBHOOK_AUTH", ""),
request_timeout=float(env.get("KNASTER_REQUEST_TIMEOUT", base.request_timeout)),
canary_interval=float(env.get("KNASTER_CANARY_INTERVAL", base.canary_interval)),
ontology_path=Path(env.get("KNASTER_ONTOLOGY", base.ontology_path)),
ontology_snapshot=env.get("KNASTER_ONTOLOGY_SNAPSHOT", ""),
n8n_host=env.get("N8N_HOST", ""),
n8n_api_key=env.get("N8N_API_KEY", ""),
)
+32
View File
@@ -144,6 +144,18 @@ healer_state = Table(
Column("updated_at", Float),
)
ontology_decisions = Table(
"ontology_decisions",
metadata,
Column("key", String(300), primary_key=True),
Column("decision", String(20), nullable=False), # accept | map | reject
Column("params", JSON),
Column("decided_by", String(100)),
Column("decided_at", Float),
Column("applied_at", Float),
Column("updated_at", Float),
)
events = Table(
"events",
metadata,
@@ -339,6 +351,26 @@ class Store:
with self.engine.connect() as conn:
return [dict(r._mapping) for r in conn.execute(q)]
# --- Ontologie-Entscheidungen ----------------------------------------------------------
def set_ontology_decision(self, key: str, decision: str, params: dict[str, Any], decided_by: str) -> None:
self._upsert(ontology_decisions, "key", key, {"decision": decision, "params": params,
"decided_by": decided_by, "decided_at": now(),
"applied_at": None})
def list_ontology_decisions(self, *, applied: bool | None = None) -> list[dict[str, Any]]:
q = select(ontology_decisions).order_by(ontology_decisions.c.decided_at)
if applied is True:
q = q.where(ontology_decisions.c.applied_at.is_not(None))
elif applied is False:
q = q.where(ontology_decisions.c.applied_at.is_(None))
with self.engine.connect() as conn:
return [dict(r._mapping) for r in conn.execute(q)]
def mark_ontology_applied(self, keys: list[str]) -> None:
with self.engine.begin() as conn:
conn.execute(update(ontology_decisions).where(ontology_decisions.c.key.in_(keys))
.values(applied_at=now()))
# --- Healer-Zustand --------------------------------------------------------------------
def get_healer_state(self, key: str) -> dict[str, Any] | None:
row = self._get(healer_state, "key", key)
+35 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import json
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
@@ -18,6 +19,7 @@ from .llm import ChatClient, OpenAIChatClient
from .maintenance.actions import ActionExecutor
from .maintenance.agent import IntentMapper, LLMIntentMapper, MaintenanceAgent, MaintenanceConfig
from .metrics import Metrics
from .ontology import Ontology, OntologyFinding, build_snapshot, check, fetch_workflows
from .policy import Policy
from .registry import Registry
from .router import Router
@@ -60,6 +62,35 @@ def _optional(path: Path, loader: Callable, default): # type: ignore[no-untyped
return loader(path) if path.exists() else default
def ontology_source_for(settings: Settings) -> Callable[[], list[dict]] | None:
"""Woher die n8n-Workflows für den Ontologie-Abgleich kommen: Datei (offline) oder n8n-API (nur lesend)."""
if settings.ontology_snapshot:
path = Path(settings.ontology_snapshot)
def from_file() -> list[dict]:
data = json.loads(path.read_text(encoding="utf-8"))
return data.get("data", data) if isinstance(data, dict) else data
return from_file
if settings.n8n_host and settings.n8n_api_key:
return lambda: fetch_workflows(settings.n8n_host, settings.n8n_api_key, timeout=settings.request_timeout)
return None
def ontology_checker(settings: Settings, store: Store,
source: Callable[[], list[dict]] | None) -> Callable[[], list[OntologyFinding]] | None:
source = source or ontology_source_for(settings)
if source is None or not settings.ontology_path.exists():
return None
def run() -> list[OntologyFinding]:
onto = Ontology.from_file(settings.ontology_path) # bei jedem Lauf neu: Änderungen im Repo zählen sofort
decided = {d["key"] for d in store.list_ontology_decisions()}
return check(onto, build_snapshot(source()), decided=decided)
return run
def build_system(
settings: Settings,
*,
@@ -70,6 +101,7 @@ def build_system(
notifier: Notifier | None = None,
jobs: JobRunner | None = None,
intent_mapper: IntentMapper | None = None,
ontology_source: Callable[[], list[dict]] | None = None,
clock: Callable[[], float] = now,
wallclock: Callable[[], datetime] = datetime.now,
) -> System:
@@ -117,7 +149,9 @@ def build_system(
intent_mapper = LLMIntentMapper(chat, registry.slot("tier1-general").spec.litellm_model)
maintenance = MaintenanceAgent(store=store, registry=registry, library=library, index=router.index,
policy=policy, actions=actions, lifecycle=lifecycle, config=maint_cfg,
intent_mapper=intent_mapper, clock=clock, wallclock=wallclock)
intent_mapper=intent_mapper,
ontology_check=ontology_checker(settings, store, ontology_source),
clock=clock, wallclock=wallclock)
healer = Healer(healer_cfg, store, actions, policy, propose=maintenance.propose, clock=clock)
prober = CanaryProber(registry, chat, embedder_for={settings.embed_model: embedder},
metrics_registry=metrics.registry)
+15
View File
@@ -87,6 +87,21 @@ def validate_repo(root: str | Path, site: str = "knaster") -> list[tuple[str, st
if kind not in known:
err(f"Policy: unbekannte Aktion {kind}")
onto_path = root / "ontology" / "know.yaml"
if onto_path.exists():
from .ontology import Ontology, to_turtle
try:
onto = Ontology.from_file(onto_path)
except (ValidationError, ValueError) as exc:
err(f"Ontologie: {exc}")
else:
for e in onto.validate_model():
err(f"Ontologie: {e}")
ttl = onto_path.with_suffix(".ttl")
if not ttl.exists() or ttl.read_text(encoding="utf-8") != to_turtle(onto):
warn(f"Ontologie: {ttl.name} ist nicht aktuell (knaster ontology export --ttl {ttl})")
if (root / "catalog").exists():
roles, _ = load_catalog(root / "catalog")
for role in roles:
+2 -1
View File
@@ -212,7 +212,8 @@ def make_system(tmp_path, skills_dir, fake_chat, clock):
(site / "registry.yaml").write_text(yaml.safe_dump(REGISTRY))
settings = Settings(db_url="sqlite://", site_dir=site, skills_dir=skills_dir,
config_dir=overrides.pop("config_dir", REPO_ROOT / "config"),
api_token=overrides.pop("api_token", "secret"))
api_token=overrides.pop("api_token", "secret"),
ontology_path=overrides.pop("ontology_path", tmp_path / "keine-ontologie.yaml"))
kw = dict(embedder=HashingEmbedder(), chat=fake_chat, jobs=LoggingJobRunner(), notifier=DryRunDriver(),
clock=clock)
kw.update(overrides)
+119 -11
View File
@@ -25,12 +25,102 @@ def test_healer_labels_available_on_alerts():
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 sc in jobs[job]["static_configs"]:
for sc in jobs[job].get("static_configs", []):
assert {"slot", "pve_node", "vmid"} <= set(sc["labels"]), (job, sc)
# GPU-VMs kommen per file_sd; die Labels setzt platform/proxmox/scripts/monitoring-targets.py
generated = _monitoring_targets()
for job in ("node", "gpu"):
files = [f for sd in jobs[job]["file_sd_configs"] for f in sd["files"]]
assert files == [f"/etc/knaster-sd/{job}.json"]
assert generated[job]
for entry in generated[job]:
assert {"slot", "pve_node", "vmid"} <= set(entry["labels"]), (job, entry)
from knaster.canary import OPS_LABELS
assert set(OPS_LABELS) == {"docker_host", "container", "pve_node", "vmid"}
def _targets_module():
import importlib.util
spec = importlib.util.spec_from_file_location(
"monitoring_targets", REPO_ROOT / "platform/proxmox/scripts/monitoring-targets.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _monitoring_targets() -> dict:
mod = _targets_module()
registry = yaml.safe_load((REPO_ROOT / "sites/knaster/registry.yaml").read_text(encoding="utf-8"))
gpu_vms = { # Form wie `tofu output -json gpu_vms`
"gpu-pve44": {"node": "pve44", "vmid": 244, "ipv4": "192.168.100.186"},
"gpu-pve48": {"node": "pve48", "vmid": 248, "ipv4": "192.168.100.183"},
"gpu-pve52": {"node": "pve52", "vmid": 252, "ipv4": "192.168.100.174"},
"gpu-pve46": {"node": "pve46", "vmid": 246, "ipv4": None},
}
return mod.build_targets(gpu_vms, registry)
def test_monitoring_targets_from_tofu_output():
t = _monitoring_targets()
assert [e["targets"][0] for e in t["node"]] == [
"192.168.100.186:9100", "192.168.100.183:9100", "192.168.100.174:9100"] # gpu-pve46 ohne IP fehlt
assert t["gpu"][0]["targets"] == ["192.168.100.186:9835"]
pve44 = t["node"][0]["labels"]
assert pve44["instance"] == "gpu-pve44" and pve44["vmid"] == "244" and pve44["pve_node"] == "pve44"
assert pve44["slot"] == "embed-default" and pve44["slots"] == "embed-default,rerank-default"
assert t["node"][2]["labels"]["slot"] == "none" # gpu-pve52 trägt noch keinen Slot
def _k3s_node(name: str, ip: str | None, ready: str = "True") -> dict:
addresses = [{"type": "Hostname", "address": name}] + ([{"type": "InternalIP", "address": ip}] if ip else [])
return {"metadata": {"name": name},
"status": {"addresses": addresses, "conditions": [{"type": "Ready", "status": ready}]}}
def test_k3s_targets_from_kubectl_nodes():
nodes = {"items": [ # Form wie `kubectl get nodes -o json`
_k3s_node("gpu-pve52", "192.168.100.174"),
_k3s_node("k3s", "192.168.100.124"),
_k3s_node("gpu-pve44", "192.168.100.186", ready="False"),
_k3s_node("gpu-pve46", None),
]}
t = _targets_module().build_k3s_targets(nodes)
assert [(e["targets"][0], e["labels"]["instance"]) for e in t["kubelet"]] == [
("192.168.100.186:10250", "gpu-pve44"), ("192.168.100.174:10250", "gpu-pve52"),
("192.168.100.124:10250", "k3s")] # gpu-pve46 ohne IP fehlt
assert t["kube-state-metrics"] == [
{"targets": ["192.168.100.124:30808"], "labels": {"instance": "kube-state-metrics"}}] # Dienst-VM bevorzugt
t = _targets_module().build_k3s_targets({"items": [nodes["items"][0], nodes["items"][2]]})
assert t["kube-state-metrics"][0]["targets"] == ["192.168.100.174:30808"] # erster bereiter Knoten
assert _targets_module().build_k3s_targets({}) == {"kubelet": [], "kube-state-metrics": []}
def test_k3s_jobs_use_generated_targets_and_nodeport():
prom = yaml.safe_load((DEPLOY / "prometheus/prometheus.yml").read_text())
jobs = {j["job_name"]: j for j in prom["scrape_configs"]}
mod = _targets_module()
written = set(mod.build_k3s_targets({}))
for job, sd in {"kubelet": "kubelet", "cadvisor": "kubelet", "kube-state-metrics": "kube-state-metrics"}.items():
assert jobs[job]["file_sd_configs"] == [{"files": [f"/etc/knaster-sd/{sd}.json"]}]
assert sd in written
for job in ("kubelet", "cadvisor"):
assert jobs[job]["scheme"] == "https" and jobs[job]["tls_config"]["ca_file"].startswith("/etc/knaster-k3s/")
docs = list(yaml.safe_load_all((DEPLOY / "k8s/monitoring/kube-state-metrics.yaml").read_text()))
svc = next(d for d in docs if d["kind"] == "Service")
assert svc["spec"]["ports"][0]["nodePort"] == mod.KSM_NODEPORT
DASHBOARDS = sorted((DEPLOY / "grafana/dashboards").glob("*.json"))
@pytest.mark.parametrize("path", DASHBOARDS, ids=[p.name for p in DASHBOARDS])
def test_grafana_dashboards_parse_with_unique_uid(path):
d = json.loads(path.read_text(encoding="utf-8"))
assert d["uid"] and d["title"] and d["panels"]
assert [x for x in DASHBOARDS if json.loads(x.read_text(encoding="utf-8"))["uid"] == d["uid"]] == [path]
def test_alertmanager_targets_router_endpoint():
am = yaml.safe_load((DEPLOY / "alertmanager/alertmanager.yml").read_text())
url = am["receivers"][0]["webhook_configs"][0]["url"]
@@ -52,6 +142,8 @@ def test_ops_labels_consistent_between_registry_and_prometheus():
next(j for j in prom["scrape_configs"] if j["job_name"] == "vllm")["static_configs"]}
for slot, labels in vllm.items():
ops = reg["slots"][slot]["ops"]
if "vmid" not in ops:
continue # Ada-Slots ohne Proxmox-VM (D3); die Prometheus-Ziele folgen in Task 5.2
assert ops["vmid"] == labels["vmid"] and ops["container"] == labels["container"]
@@ -160,22 +252,38 @@ 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
assert vm["gpus"], vm # eine GPU-VM pro Knoten mit allen Karten des Knotens (D2)
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()]
gpus = [gpu for vm in vms.values() for gpu 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:
continue
if not ops or "vmid" not in ops:
continue # Ada-Slots ohne Proxmox-VM (D3, Worker-Registry v2)
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
# vLLM und PyTorch nur auf modernen Karten (ADR-0006)
if spec["engine"] in {"vllm", "pytorch"} and name in vms:
assert vm["driver_generation"] == "modern", slot
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
for vm in vms.values():
if vm["role"] == "gpu-vllm":
assert vm["driver_generation"] == "modern"
def test_ops_admin_keys_in_site_file():
"""Weg 1: Admins kommen mit eigenem Schlüssel auf ops (playbooks/admins.yml)."""
access = yaml.safe_load((REPO_ROOT / "sites/knaster/proxmox.yaml").read_text())["access"]
keys, revoked = access["ops_ssh_keys"], access["ops_ssh_keys_revoked"]
assert keys # nie leer, sonst sperrt sich die Verwaltung aus
names = [k["name"] for k in keys]
assert len(names) == len(set(names))
for k in [*keys, *revoked]:
parts = k["key"].split()
assert parts[0] in {"ssh-ed25519", "ssh-rsa"} and len(parts) >= 2, k["name"]
assert "PRIVATE" not in k["key"], k["name"]
assert not {k["key"].split()[1] for k in keys} & {k["key"].split()[1] for k in revoked}
+36 -2
View File
@@ -1,4 +1,8 @@
import ipaddress
import json
import re
import yaml
from knaster.cli import main
from knaster.inventory.placement import load_catalog, load_nodes, plan
@@ -28,29 +32,59 @@ def test_parsers():
assert disks[0].size_gb == 256.1 and not disks[0].rotational
PVE_GPU_HOSTS = {"pve44", "pve46", "pve48", "pve52"}
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 == "gode-pc-06"
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_site_knaster_matches_real_nodes():
"""Site-Dateien beschreiben die echten Knoten (Stand 12.10.2026), nicht mehr die Annahmen pve-ada/pve1–pve4."""
names = {n.name for n in load_nodes(REPO_ROOT / "sites/knaster/inventory.yaml")}
assert names == {"pve44", "pve46", "pve48", "pve52", "pve61", "gode-pc-05", "gode-pc-06"}
text = (REPO_ROOT / "sites/knaster/proxmox.yaml").read_text(encoding="utf-8")
old_net = ipaddress.ip_network("10.20.0.0/24")
ips = re.findall(r"\b\d{1,3}(?:\.\d{1,3}){3}\b", text)
assert [ip for ip in ips if ipaddress.ip_address(ip) in old_net] == []
site = yaml.safe_load(text)
gpu_nodes = {"pve44", "pve46", "pve48", "pve52"}
assert {m["node"] for m in site["pci_mappings"].values()} <= gpu_nodes
assert {vm["node"] for vm in site["vms"].values()} <= gpu_nodes
# Ada-Rechner und Kiosk nehmen keine CPU-Rollen (Placement, cpu_roles)
nodes = {n.name: n for n in load_nodes(REPO_ROOT / "sites/knaster/inventory.yaml")}
assert not nodes["gode-pc-05"].cpu_roles and not nodes["gode-pc-06"].cpu_roles and not nodes["pve61"].cpu_roles
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)
+267
View File
@@ -0,0 +1,267 @@
"""Ontologie: Modell, Export, Abgleich mit n8n (synthetische Workflows), Übernahme, Service-Routine."""
import json
import pytest
from knaster.ontology import Ontology, apply_decisions, build_snapshot, check, to_graph, to_turtle
from knaster.ontology.n8n import is_closed, literal_values, parse_know_code
from knaster.settings import REPO_ROOT
KNOW_CODE = r"""
const allowed=['setup_schema','store_mail_event','upsert_hint','promote_hint','find_mail'];
if(operation==='setup_schema'){ query=`CREATE TABLE IF NOT EXISTS ${t.mail_events} (id BIGSERIAL PRIMARY KEY,
message_id TEXT UNIQUE NOT NULL, category TEXT, sender TEXT);
CREATE TABLE IF NOT EXISTS ${t.hints} (hint_key TEXT UNIQUE NOT NULL, hint_type TEXT NOT NULL,
quality_status TEXT NOT NULL DEFAULT 'observed');
CREATE TABLE IF NOT EXISTS ${t.config} (config_key TEXT UNIQUE NOT NULL, weight NUMERIC);
CREATE TABLE IF NOT EXISTS ${t.orphans} (id BIGSERIAL PRIMARY KEY, x TEXT);
ALTER TABLE ${t.mail_events} ADD COLUMN IF NOT EXISTS received_at TIMESTAMPTZ, ADD COLUMN IF NOT EXISTS mood TEXT;
INSERT INTO ${t.config} (config_key, weight) VALUES ('mahnung', 3) ON CONFLICT (config_key) DO NOTHING;
INSERT INTO ${t.config} (config_key, weight) VALUES ('default', 0) ON CONFLICT (config_key) DO NOTHING;`;}
if(operation==='store_mail_event'){ query=`INSERT INTO ${t.mail_events} (message_id,category,sender)
VALUES (${sqlString(payload.message_id)},${sqlString(payload.category)},${sqlString(payload.sender)})
ON CONFLICT (message_id) DO UPDATE SET category=EXCLUDED.category RETURNING *`;}
if(operation==='upsert_hint'){ query=`INSERT INTO ${t.hints} (hint_key,hint_type) VALUES (${k},${ht})
ON CONFLICT (hint_key) DO UPDATE SET quality_status=CASE WHEN x THEN 'proposed' ELSE ${t.hints}.quality_status END`;}
if(operation==='promote_hint'){ query=`UPDATE ${t.hints} SET quality_status='confirmed' WHERE hint_key=${k}`;}
if(operation==='find_mail'){ query=`SELECT * FROM ${t.mail_events}`;}
"""
ONTO = {
"id": "t", "namespace": "urn:test#", "modules": {"m": "Mail"},
"classes": {
"Mail": {"label": "Mail", "module": "m", "table": "mail_events", "key": "message_id",
"attributes": {"id": "BIGSERIAL", "message_id": "TEXT", "category": "TEXT", "sender": "TEXT",
"received_at": "TIMESTAMPTZ"}},
"Hinweis": {"label": "Hinweis", "module": "m", "table": "hints",
"attributes": {"hint_key": "TEXT", "hint_type": "TEXT", "quality_status": "TEXT"}},
"Kategorie": {"label": "Kategorie", "module": "m", "table": "config",
"attributes": {"config_key": "TEXT", "weight": "NUMERIC"}},
"Alt": {"label": "Alt", "module": "m", "table": "removed_table", "attributes": {"a": "TEXT"}},
},
"relations": [{"source": "Hinweis", "name": "hatKategorie", "target": "Kategorie", "via": "hint_type"}],
"vocabularies": {
"Kat": {"label": "Kategorie", "class": "Kategorie",
"columns": ["mail_events.category", "hints.hint_type", "config.config_key"],
"concepts": {"mahnung": {"label": "Mahnung"}, "rechnung": {"label": "Rechnung", "alt": ["Buchhaltung"]},
"sonstiges": {}, "default": {"technical": True}}},
"QS": {"label": "Qualität", "columns": ["hints.quality_status"],
"concepts": {"observed": {}, "proposed": {}, "confirmed": {}, "rejected": {}}},
},
}
CAT_PAYLOAD = ("={{ JSON.stringify({ message_id: $json.id, sender: ($('Vorfilter').item.json.from||'').toLowerCase(), "
"category: ({'Buchhaltung':'rechnung','Bauvorhaben':'bauvorhaben','Sonstiges':'sonstiges'}"
"[$json.output?.category]||'sonstiges'), raw_signals: { category: 'x' } }) }}")
def _call(name, op, payload="", node="KNOW"):
return {"name": node, "type": "n8n-nodes-base.executeWorkflow",
"parameters": {"workflowId": {"value": "know1"},
"workflowInputs": {"value": {"operation": op, "payload_json": payload}}}}
WORKFLOWS = [
{"id": "know1", "name": "KNOW", "active": True,
"nodes": [{"name": "Normalize", "type": "n8n-nodes-base.code", "parameters": {"jsCode": KNOW_CODE}}]},
{"id": "w1", "name": "CAT", "active": True, "nodes": [
_call("CAT", "store_mail_event", CAT_PAYLOAD),
{"name": "Mark", "type": "n8n-nodes-base.postgres",
"parameters": {"query": "UPDATE knowledge.hints SET x=1 WHERE hint_key='{{ $json.k }}'"}}]},
{"id": "w2", "name": "MPA", "active": True, "nodes": [
_call("MPA", "store_mail_event", '={{ JSON.stringify({ message_id: $json.id, category: "MPA" }) }}'),
_call("MPA", "log_decision", "{}", node="Log1"), _call("MPA", "log_decision", "{}", node="Log2")]},
{"id": "w3", "name": "Dyn", "active": True, "nodes": [
{"name": "Build", "type": "n8n-nodes-base.code", "parameters": {"jsCode": "return {operation: 'upsert_hint'}"}},
_call("Dyn", "={{ $json.operation }}")]},
{"id": "w4", "name": "Old", "active": False, "nodes": [
_call("Old", "store_mail_event", "={{ JSON.stringify({ category: 'legacy_kat' }) }}")]},
]
@pytest.fixture
def onto():
return Ontology(**json.loads(json.dumps(ONTO)))
def test_repo_ontology_is_valid_and_turtle_current():
path = REPO_ROOT / "ontology" / "know.yaml"
o = Ontology.from_file(path)
assert o.validate_model() == []
assert len(o.classes) >= 49 and "MailKategorie" in o.vocabularies
assert (REPO_ROOT / "ontology" / "know.ttl").read_text(encoding="utf-8") == to_turtle(o)
def test_model_validation_finds_errors(onto):
onto.relations.append(onto.relations[0].model_copy(update={"target": "Gibtsnicht"}))
onto.vocabularies["Kat"].concepts["sonstiges"].alt = ["Mahnung"]
onto.vocabularies["Kat"].columns.append("mail_events.fehlt")
onto.vocabularies["Kat"].rejected = ["rechnung"]
errors = "\n".join(onto.validate_model())
assert "Gibtsnicht" in errors and "„Mahnung“" in errors and "mail_events.fehlt" in errors
assert "zugleich verworfen" in errors
def test_vocabulary_resolve(onto):
v = onto.vocabularies["Kat"]
assert v.resolve("Buchhaltung") == "rechnung" and v.resolve("MAHNUNG") == "mahnung" and v.resolve("x") is None
def test_exports(onto):
ttl = to_turtle(onto)
assert "t:Mail a owl:Class" in ttl and "skos:altLabel \"Buchhaltung\"@de" in ttl
assert "t:hatKategorie a owl:ObjectProperty" in ttl
g = to_graph(onto, generated="2026-10-13")
mail = next(c for c in g["classes"] if c["id"] == "Mail")
assert "id" not in [a[0] for a in mail["attrs"]]
hint = next(c for c in g["classes"] if c["id"] == "Hinweis")
assert hint["enums"]["quality_status"] == ["observed", "proposed", "confirmed", "rejected"]
assert "Mahnung" in next(c for c in g["classes"] if c["id"] == "Kategorie")["individuals"]
def test_parse_know_code():
spec = parse_know_code(KNOW_CODE)
assert spec.allowed == {"setup_schema", "store_mail_event", "upsert_hint", "promote_hint", "find_mail"}
assert set(spec.schema["mail_events"]) == {"id", "message_id", "category", "sender", "received_at", "mood"}
assert spec.writes["store_mail_event"]["mail_events"] == {"message_id", "category", "sender"}
assert spec.writes["promote_hint"]["hints"] == {"quality_status"}
assert spec.seeds["config.config_key"] == {"mahnung", "default"}
assert "config" not in spec.writes.get("setup_schema", {}) # Seeds gehören zu keiner Operation
assert spec.assigned["upsert_hint"]["hints.quality_status"] == {"proposed"}
assert spec.assigned["promote_hint"]["hints.quality_status"] == {"confirmed"}
@pytest.mark.parametrize("expr,key,values,closed", [
(CAT_PAYLOAD, "category", {"rechnung", "bauvorhaben", "sonstiges"}, True),
(CAT_PAYLOAD, "sender", set(), False),
('{ category: "MPA", x: 1 }', "category", {"MPA"}, True),
("{ category: $json.cat || 'info' }", "category", {"info"}, False),
("={{ JSON.stringify({ x: { category: 'tief' } }) }}", "category", set(), False),
])
def test_literal_values_and_closed(expr, key, values, closed):
assert literal_values(expr, key) == values
assert is_closed(expr, key) is closed
def test_check_against_synthetic_n8n(onto):
snap = build_snapshot(WORKFLOWS)
assert snap.know and snap.know.workflow_id == "know1"
dyn = [c for c in snap.calls if c.workflow == "Dyn"]
assert [(c.operation, c.inferred) for c in dyn] == [("upsert_hint", True)]
findings = check(onto, snap)
by_kind = {}
for f in findings:
by_kind.setdefault(f.kind, []).append(f)
# unbekannte Operation: zwei Knoten → ein Befund
[unknown] = by_kind["unknown_operation"]
assert unknown.severity == "high" and "Log1, Log2" in unknown.detail
[sql] = by_kind["direct_sql"]
assert sql.severity == "medium" and "hints" in sql.summary
vocab = {f.change["value"]: f for f in by_kind["vocab_value"]}
assert set(vocab) == {"bauvorhaben", "MPA", "legacy_kat"}
assert vocab["legacy_kat"].severity == "low" and vocab["MPA"].severity == "medium"
# mahnung wird von keiner festen Zuordnung erzeugt, default ist technisch
[unused] = [f for f in by_kind["concept_without_producer"] if f.key == "unused:Kat"]
assert "mahnung" in unused.summary and "default" not in unused.summary
# Qualitätsstatus: proposed/confirmed setzt KNOW selbst (upsert_hint ist aktiv abgeleitet, promote_hint nicht)
qs = [f for f in by_kind.get("concept_without_producer", []) if f.key == "unused:QS"]
assert not qs # keine feste Zuordnung aus Workflows → keine Aussage
assert {f.change["column"] for f in by_kind["unmapped_column"]} == {"mood"}
assert by_kind["unmapped_table"][0].change["table"] == "orphans"
assert by_kind["missing_table"][0].key == "gone:removed_table"
assert "no_write_operation" not in by_kind # orphans ist keiner Klasse zugeordnet, config ist Seed-Tabelle
assert "nowriter:config" not in {f.key for f in findings} # Seed-Tabelle
# entschiedene bzw. ignorierte Schlüssel verschwinden
onto.ignore.append("table:orphans")
keys = {f.key for f in check(onto, snap, decided={"vocab:Kat:mpa"})}
assert "vocab:Kat:mpa" not in keys and "table:orphans" not in keys
def test_apply_decisions(onto):
decisions = [
{"key": "vocab:Kat:bauvorhaben", "decision": "accept",
"params": {"type": "vocab_value", "vocabulary": "Kat", "value": "Bauvorhaben"}},
{"key": "vocab:Kat:buchhaltg", "decision": "map",
"params": {"type": "vocab_value", "vocabulary": "Kat", "value": "Buchhaltg", "concept": "rechnung"}},
{"key": "vocab:Kat:mpa", "decision": "reject", "params": {"type": "vocab_value", "vocabulary": "Kat",
"value": "MPA"}},
{"key": "column:mail_events.mood", "decision": "accept",
"params": {"type": "add_attribute", "class": "Mail", "column": "mood", "sql_type": "TEXT"}},
{"key": "table:orphans", "decision": "accept",
"params": {"type": "add_class", "table": "orphans", "attributes": {"x": "TEXT"}}},
{"key": "sql:CAT:Mark", "decision": "reject", "params": {"type": "x"}},
{"key": "vocab:Kat:kaputt", "decision": "map",
"params": {"type": "vocab_value", "vocabulary": "Kat", "value": "k", "concept": "gibtsnicht"}},
]
applied, messages = apply_decisions(onto, decisions)
v = onto.vocabularies["Kat"]
assert v.resolve("Bauvorhaben") == "bauvorhaben" and v.resolve("Buchhaltg") == "rechnung"
assert v.is_rejected("mpa") and "mood" in onto.classes["Mail"].attributes
assert onto.classes["Orphans"].module == "neu" and "sql:CAT:Mark" in onto.ignore
assert "vocab:Kat:kaputt" not in applied and any("nicht übernommen" in m for m in messages)
assert onto.version == 2 and onto.validate_model() == []
def test_routine_proposes_and_remembers_decisions(make_system, tmp_path, onto):
path = tmp_path / "know.yaml"
onto.save(path)
sysm = make_system(ontology_path=path, ontology_source=lambda: WORKFLOWS)
m = sysm.maintenance
report = m.run_routine()
assert any("log_decision" in f for f in report.findings)
vocab = {p.params["value"]: p for p in report.proposals if p.kind == "ontology_change"
and p.params["type"] == "vocab_value"}
assert {"bauvorhaben", "MPA", "legacy_kat"} <= set(vocab)
assert all(p.state == "open" for p in vocab.values()) # Ontologie-Änderungen nie automatisch
# zuordnen, ablehnen, aufnehmen
assert "executed" in m.handle_command(f"ordne {vocab['legacy_kat'].id} sonstiges zu", user="robert").text
assert "rejected" in m.handle_command(f"lehne {vocab['MPA'].id} ab: Workflow-Name, keine Kategorie").text
assert "executed" in m.handle_command(f"genehmige {vocab['bauvorhaben'].id}").text
assert "Fehler" in m.handle_command("ordne P-999 sonstiges zu").text
decisions = {d["key"]: d for d in sysm.store.list_ontology_decisions()}
assert decisions["vocab:Kat:legacy_kat"]["decision"] == "map"
assert decisions["vocab:Kat:legacy_kat"]["decided_by"] == "robert"
assert decisions["vocab:Kat:mpa"]["decision"] == "reject"
# zweiter Lauf: entschiedene Werte kommen nicht wieder
again = m.run_routine()
values = {p.params.get("value") for p in again.proposals if p.kind == "ontology_change"}
assert not values & {"bauvorhaben", "MPA", "legacy_kat"}
# Übernahme in die Datei
pending = sysm.store.list_ontology_decisions(applied=False)
applied, _ = apply_decisions(onto, pending)
assert set(applied) == {"vocab:Kat:legacy_kat", "vocab:Kat:mpa", "vocab:Kat:bauvorhaben"}
assert onto.vocabularies["Kat"].resolve("legacy_kat") == "sonstiges"
def test_routine_survives_unreachable_n8n(make_system, tmp_path, onto):
path = tmp_path / "know.yaml"
onto.save(path)
def broken():
raise ConnectionError("n8n nicht erreichbar")
report = make_system(ontology_path=path, ontology_source=broken).maintenance.run_routine()
assert any("Ontologie-Abgleich nicht möglich" in f for f in report.findings)
def test_cli_check_with_snapshot(tmp_path, onto, monkeypatch, capsys):
from knaster.cli import main
path = tmp_path / "know.yaml"
onto.save(path)
snap = tmp_path / "wf.json"
snap.write_text(json.dumps({"data": WORKFLOWS}))
monkeypatch.setenv("KNASTER_DB_URL", "sqlite://")
assert main(["ontology", "--path", str(path), "check", "--snapshot", str(snap)]) == 1 # high-Befund
out = capsys.readouterr().out
assert "log_decision" in out and "Änderungsvorschläge" in out
assert main(["ontology", "--path", str(path), "validate"]) == 0
html = tmp_path / "o.html"
assert main(["ontology", "--path", str(path), "export", "--html", str(html),
"--template", str(REPO_ROOT / "ontology" / "viewer.html.tmpl")]) == 0
assert "__DATA__" not in html.read_text() and "owl:Class" in html.read_text()
+19 -1
View File
@@ -1,11 +1,20 @@
# Alertmanager → Router-Service (Healer). Kurzes repeat_interval treibt die Eskalationsleiter voran;
# Cooldowns pro Stufe regelt der Healer selbst.
# Cooldowns pro Stufe regelt der Healer selbst. Der Router läuft mit KNASTER_DRY_RUN=true.
route:
receiver: knaster-healer
group_by: [alertname, slot, instance]
group_wait: 30s
group_interval: 1m
repeat_interval: 5m
# Vorbereitet, noch NICHT aktiv (Task 5.2, braucht den Nutzer: Workflow knaster-notify in n8n
# inaktiv importieren, Header-Auth setzen, aktivieren). Achtung: knaster-notify erwartet {text, severity, data},
# Alertmanager sendet sein eigenes Format – entweder den Workflow anpassen oder nur über den Router melden
# (Healer-Aktion notify → NOTIFY_WEBHOOK_URL). Danach einkommentieren:
# routes:
# - receiver: knaster-healer
# continue: true
# - receiver: n8n-notify
# matchers: ['severity=~"high|warning"']
receivers:
- name: knaster-healer
@@ -16,3 +25,12 @@ receivers:
authorization:
type: Bearer
credentials_file: /etc/alertmanager/router_token
# - name: n8n-notify
# webhook_configs:
# - url: https://n8n.mindboost.app/webhook/knaster/notify
# send_resolved: true
# http_config:
# authorization:
# type: Bearer
# credentials_file: /etc/alertmanager/n8n_token # Datei in /opt/knaster/infra/secrets, Mount ergänzen
+6
View File
@@ -1,4 +1,6 @@
COMPOSE_PROFILES=embed
LLAMACPP_REF=b6000
LLAMACPP_COMMIT=4762ad7316dcdec20016ab5985fb46a27902204d
CUDA_VERSION=12.4.0
CUDA_DOCKER_ARCH=52;61
MODELS_DIR=/srv/models
@@ -6,3 +8,7 @@ EMBED_GGUF=Qwen3-Embedding-0.6B-Q8_0.gguf
EMBED_POOLING=last
RERANK_GGUF=bge-reranker-v2-m3-Q8_0.gguf
CHAT_GGUF=Qwen3-4B-Q4_K_M.gguf
# Karte je Dienst: Index oder UUID aus `nvidia-smi -L` (Rolle gpu-llamacpp setzt die UUID der P2000)
EMBED_GPU=1
RERANK_GPU=1
CHAT_GPU=1
+52 -39
View File
@@ -1,25 +1,37 @@
# Rollen "gpu-embed" und "gpu-small" (VMs mit Quadro P2000 / M4000): llama.cpp-Server.
# Maxwell (sm_52) und Pascal (sm_61) brauchen CUDA 12.x – daher eigener Build mit festen Architekturen.
# Profile: embed (Embedding + Rerank), chat (Verifier/Signatur), z. B. `docker compose --profile embed up -d`
# Rollen "gpu-embed" und "gpu-small" (GPU-VMs mit Quadro P2000 + M4000): llama.cpp-Server (Task 6.1).
# Maxwell (sm_52) und Pascal (sm_61) brauchen CUDA 12.x – daher eigener Build mit festen Architekturen,
# derselbe Stand wie im Benchmark (ADR-0006): b6000 = Commit 4762ad7, CUDA 12.4.0, sm_52;61.
# Profile: embed (Embedding + Rerank, gpu-pve44/46), chat (verifier-small/signature-extractor, gpu-pve48).
# Ausgerollt von der Ansible-Rolle gpu-llamacpp (.env mit COMPOSE_PROFILES und Karten-UUIDs).
#
# Kartenwahl: je Dienst device_ids (UUID oder Index aus `nvidia-smi -L`). Ohne Angabe Index 1 = P2000
# (in gpu-pve44/46/48 ist Index 0 die M4000, sie bleibt frei). `count: 1` griffe die M4000.
# Netz: Host-Netz wie nex-speech. Veröffentlichte Docker-Ports liefen über DNAT und umgingen ufw;
# so gelten spätere ufw-Regeln der VM (Task 4.4).
# Datenschutz: kein --verbose (der loggt Prompts), /slots aus (zeigt Prompt-Inhalte), keine Web-Oberfläche.
name: knaster-gpu-llamacpp
x-logging: &logging
driver: local
options: {max-size: "5m", max-file: "2"}
x-llamacpp: &llamacpp
build:
context: https://github.com/ggml-org/llama.cpp.git#${LLAMACPP_REF:-b6000}
context: https://github.com/ggml-org/llama.cpp.git#${LLAMACPP_COMMIT:-4762ad7316dcdec20016ab5985fb46a27902204d}
dockerfile: .devops/cuda.Dockerfile
target: server
args:
CUDA_VERSION: ${CUDA_VERSION:-12.4.0}
CUDA_DOCKER_ARCH: ${CUDA_DOCKER_ARCH:-52;61}
image: knaster/llamacpp-server:${LLAMACPP_REF:-b6000}-cu12
image: knaster/llamacpp-server:${LLAMACPP_REF:-b6000}-cu${CUDA_VERSION:-12.4.0}
restart: unless-stopped
network_mode: host
read_only: true
tmpfs: [/tmp:size=64m]
labels: {autoheal: "true"}
volumes:
- ${MODELS_DIR:-/srv/models}:/models:ro
deploy:
resources:
reservations:
devices: [{driver: nvidia, count: 1, capabilities: [gpu]}]
logging: *logging
services:
llamacpp-embed:
@@ -27,60 +39,61 @@ services:
container_name: llamacpp-embed
profiles: ["embed"]
command: ["-m", "/models/${EMBED_GGUF:-Qwen3-Embedding-0.6B-Q8_0.gguf}", "--embedding",
"--pooling", "${EMBED_POOLING:-last}", "-c", "8192", "-ub", "8192", "-ngl", "99",
"--alias", "embed-default", "--metrics", "--host", "0.0.0.0", "--port", "8081"]
ports: ["8081:8081"]
"--pooling", "${EMBED_POOLING:-last}", "-c", "8192", "-b", "4096", "-ub", "4096", "-np", "1", # ub 8192 sprengt die P2000 (Task 6.1)
"-ngl", "99", "--alias", "embed-default", "--metrics", "--no-slots", "--no-webui",
"--host", "0.0.0.0", "--port", "8081"]
deploy:
resources:
reservations:
devices: [{driver: nvidia, device_ids: ["${EMBED_GPU:-1}"], capabilities: [gpu]}]
healthcheck: &hc
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8081/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
start_period: 120s
llamacpp-rerank:
<<: *llamacpp
container_name: llamacpp-rerank
profiles: ["embed"]
command: ["-m", "/models/${RERANK_GGUF:-bge-reranker-v2-m3-Q8_0.gguf}", "--reranking", "-c", "8192",
"-ub", "8192", "-ngl", "99", "--alias", "rerank-default", "--metrics", "--host", "0.0.0.0",
"--port", "8082"]
ports: ["8082:8082"]
command: ["-m", "/models/${RERANK_GGUF:-bge-reranker-v2-m3-Q8_0.gguf}", "--reranking",
"-c", "8192", "-b", "8192", "-ub", "8192", "-np", "1", "-ngl", "99", "--alias", "rerank-default",
"--metrics", "--no-slots", "--no-webui", "--host", "0.0.0.0", "--port", "8082"]
deploy:
resources:
reservations:
devices: [{driver: nvidia, device_ids: ["${RERANK_GPU:-1}"], capabilities: [gpu]}]
healthcheck:
<<: *hc
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8082/health"]
# Qwen3 ohne Denkphase (enable_thinking=false): Verifier und Signatur brauchen kurze, direkte Antworten,
# und der Canary-Prober fragt mit max_tokens=5 nach "OK".
llamacpp-chat:
<<: *llamacpp
container_name: llamacpp-chat
profiles: ["chat"]
command: ["-m", "/models/${CHAT_GGUF:-Qwen3-4B-Q4_K_M.gguf}", "-c", "8192", "-ngl", "99",
"--alias", "verifier-small", "--metrics", "--host", "0.0.0.0", "--port", "8080"]
ports: ["8080:8080"]
command: ["-m", "/models/${CHAT_GGUF:-Qwen3-4B-Q4_K_M.gguf}", "-c", "8192", "-np", "1", "-ngl", "99",
"--jinja", "--chat-template-kwargs", "{\"enable_thinking\": false}",
"--alias", "verifier-small", "--metrics", "--no-slots", "--no-webui",
"--host", "0.0.0.0", "--port", "8080"]
deploy:
resources:
reservations:
devices: [{driver: nvidia, device_ids: ["${CHAT_GPU:-1}"], capabilities: [gpu]}]
healthcheck:
<<: *hc
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"]
gpu-exporter:
image: utkuozdemir/nvidia_gpu_exporter:1.2.1
restart: unless-stopped
ports: ["9835:9835"]
deploy:
resources:
reservations:
devices: [{driver: nvidia, count: all, capabilities: [gpu, utility]}]
node-exporter:
image: prom/node-exporter:v1.8.1
restart: unless-stopped
command: ["--path.rootfs=/host", "--collector.textfile.directory=/textfile"]
pid: host
volumes:
- /:/host:ro,rslave
- /var/lib/node_exporter/textfile:/textfile:ro
ports: ["9100:9100"]
# Startet Container mit Label autoheal=true neu, wenn ihr Healthcheck dauerhaft scheitert
# (restart: unless-stopped greift nur beim Absturz). Läuft in jedem Profil mit.
autoheal:
image: willfarrell/autoheal:1.2.0
container_name: llamacpp-autoheal
restart: unless-stopped
environment: {AUTOHEAL_CONTAINER_LABEL: autoheal}
volumes: ["/var/run/docker.sock:/var/run/docker.sock"]
logging: *logging
# Exporter (node_exporter 9100, nvidia_gpu_exporter 9835) laufen nativ in der VM (Rolle gpu-exporters).
+13 -5
View File
@@ -1,13 +1,21 @@
# Vorlage für deploy/compose/infra. Im Cluster schreibt die Ansible-Rolle infra-stack die Datei
# /opt/knaster/infra/.env (0600) im LXC infra; Geheimnisse erzeugt sie dort mit `openssl rand`.
KNASTER_VERSION=dev
KNASTER_SITE=knaster
KNASTER_STATE_DIR=.
KNASTER_API_TOKEN=change-me
KNASTER_DRY_RUN=true
KNASTER_CANARY_INTERVAL=60
POSTGRES_PASSWORD=change-me
LITELLM_API_KEY=change-me
LITELLM_MASTER_KEY=change-me
OPENROUTER_API_KEY=
RERANK_URL=http://gpu-embed-1:8082
GRAFANA_ADMIN_PASSWORD=change-me
GRAFANA_KIOSK_PASSWORD=change-me
# D4: LiteLLM mit PSE auf appsrv08, kein eigener LiteLLM im Stack.
# TODO (Nutzer, Task 6.3): Port festlegen. Der LiteLLM läuft auf appsrv08 nur im internen Netz
# (`litellm:4000`); am 12.10. waren von ops aus 4000/4001/8000 auf 192.168.100.29 geschlossen.
# Port auf appsrv08 für 192.168.100.0/24 freigeben (oder per Traefik) und hier eintragen.
LITELLM_BASE_URL=http://192.168.100.29:4000
LITELLM_API_KEY=
RERANK_URL=
NOTIFY_WEBHOOK_URL=
KNASTER_JOB_WEBHOOK_URL=
PORTAINER_HOST=
@@ -15,4 +23,4 @@ PORTAINER_KEY=
PROXMOX_URL=
PROXMOX_TOKEN_ID=
PROXMOX_TOKEN_SECRET=
KNASTER_WEBHOOK_AUTH=Bearer change-me
KNASTER_WEBHOOK_AUTH=
+54 -30
View File
@@ -1,5 +1,10 @@
# Rolle "infra" (LXC/VM auf einem i7-Node): Router, LiteLLM, Postgres, Monitoring.
# Start: cp .env.example .env && docker compose up -d
# Rolle "infra" (LXC 101 auf pve44, HA nach pve48/pve52): Router, TTS, Postgres und Monitoring in einem Stack.
# Kein eigener LiteLLM (D4): Der Router nutzt den LiteLLM mit PSE auf appsrv08 (LITELLM_BASE_URL).
#
# Lokal: cp .env.example .env && docker compose up -d
# Cluster: Ansible-Rolle infra-stack (platform/proxmox/ansible). Zustand und Geheimnisse liegen dort
# außerhalb des Repos in KNASTER_STATE_DIR=/opt/knaster/infra (.env, secrets/, file_sd/), Aufruf:
# docker compose -p knaster-infra --env-file /opt/knaster/infra/.env -f <repo>/deploy/compose/infra/docker-compose.yml up -d
name: knaster-infra
x-restart: &restart
@@ -13,29 +18,15 @@ services:
context: ../../..
dockerfile: deploy/docker/router.Dockerfile
image: knaster/router:${KNASTER_VERSION:-dev}
env_file: [{path: .env, required: false}]
env_file: [{path: "${KNASTER_STATE_DIR:-.}/.env", required: false}]
environment:
KNASTER_DB_URL: postgresql+psycopg://knaster:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD fehlt}@postgres:5432/knaster
KNASTER_SITE_DIR: /app/sites/${KNASTER_SITE:-knaster}
LITELLM_BASE_URL: http://litellm:4000
KNASTER_DRY_RUN: ${KNASTER_DRY_RUN:-true}
LITELLM_BASE_URL: ${LITELLM_BASE_URL:?LITELLM_BASE_URL fehlt (LiteLLM auf appsrv08, D4)}
ports: ["8080:8080"]
depends_on:
postgres: {condition: service_healthy}
litellm: {condition: service_started}
litellm:
<<: *restart
image: ghcr.io/berriai/litellm:${LITELLM_TAG:-main-stable} # vor Deployment auf feste Version pinnen
command: ["--config", "/app/config.yaml", "--port", "4000"]
env_file: [{path: .env, required: false}]
volumes:
- ../../litellm/config.yaml:/app/config.yaml:ro
ports: ["4000:4000"]
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:4000/health/liveliness', timeout=4)"]
interval: 30s
timeout: 5s
retries: 3
tts-piper:
<<: *restart
@@ -53,7 +44,7 @@ services:
postgres:
<<: *restart
image: pgvector/pgvector:pg16
image: pgvector/pgvector:0.8.7-pg16-trixie
environment:
POSTGRES_USER: knaster
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD fehlt}
@@ -66,42 +57,74 @@ services:
timeout: 5s
retries: 5
# --- Monitoring ---------------------------------------------------------------------------------
prometheus:
<<: *restart
image: prom/prometheus:v2.53.0
image: prom/prometheus:v3.13.4
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=15d
- --web.enable-lifecycle
volumes:
- ../../prometheus:/etc/prometheus:ro
# file_sd: Ziele der GPU-VMs (DHCP, IPs wechseln), erzeugt auf ops aus `tofu output -json gpu_vms`
- ${KNASTER_STATE_DIR:-.}/file_sd:/etc/knaster-sd:ro
# Token und CA für kubelet/cAdvisor in k3s (Task 6.5), nur dieser Unterordner der Geheimnisse
- ${KNASTER_STATE_DIR:-.}/secrets/k3s:/etc/knaster-k3s:ro
- prometheus:/prometheus
extra_hosts: ["host.docker.internal:host-gateway"] # node-exporter des LXC läuft im Host-Netz
ports: ["9090:9090"]
alertmanager:
<<: *restart
image: prom/alertmanager:v0.27.0
command: ["--config.file=/etc/alertmanager/alertmanager.yml"]
image: prom/alertmanager:v0.34.1
command:
- --config.file=/etc/alertmanager/alertmanager.yml
- --storage.path=/alertmanager
volumes:
- ../../alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
- ./secrets/router_token:/etc/alertmanager/router_token:ro
- ${KNASTER_STATE_DIR:-.}/secrets/router_token:/etc/alertmanager/router_token:ro
- alertmanager:/alertmanager
ports: ["9093:9093"]
grafana:
<<: *restart
image: grafana/grafana:11.1.0
profiles: ["dashboards"]
image: grafana/grafana:12.4.12
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?GRAFANA_ADMIN_PASSWORD fehlt} # wirkt nur beim ersten Start
GF_USERS_ALLOW_SIGN_UP: "false"
GF_USERS_DEFAULT_THEME: dark
GF_ANALYTICS_REPORTING_ENABLED: "false"
GF_ANALYTICS_CHECK_FOR_UPDATES: "false"
GF_ANALYTICS_CHECK_FOR_PLUGIN_UPDATES: "false"
GF_NEWS_NEWS_FEED_ENABLED: "false"
GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH: /var/lib/grafana/dashboards/knaster-uebersicht.json
volumes:
- ../../grafana/provisioning:/etc/grafana/provisioning:ro
- ../../grafana/dashboards:/var/lib/grafana/dashboards:ro
- grafana:/var/lib/grafana
ports: ["3000:3000"]
depends_on: [prometheus]
pve-exporter:
<<: *restart
image: prompve/prometheus-pve-exporter:${PVE_EXPORTER_TAG:-latest} # vor Deployment pinnen
profiles: ["proxmox"]
image: prompve/prometheus-pve-exporter:3.10.1
# Token monitoring@pve!prom (PVEAuditor) als PVE_USER/PVE_TOKEN_NAME/PVE_TOKEN_VALUE/PVE_VERIFY_SSL
env_file: [{path: "${KNASTER_STATE_DIR:-.}/secrets/pve-exporter.env", required: false}]
expose: ["9221"]
node-exporter:
<<: *restart
image: prom/node-exporter:v1.12.1
command:
- --path.rootfs=/host
- --web.listen-address=:9100
network_mode: host
pid: host
volumes:
- ./secrets/pve.yml:/etc/prometheus/pve.yml:ro
ports: ["9221:9221"]
- /:/host:ro # ohne rslave: im unprivilegierten LXC nicht erlaubt
autoheal:
restart: unless-stopped
@@ -115,4 +138,5 @@ services:
volumes:
postgres: {}
prometheus: {}
alertmanager: {}
grafana: {}
+21
View File
@@ -0,0 +1,21 @@
# NEX-Sprachdienste (deploy/compose/nex-speech). Keine Geheimnisse nötig, kein API-Key.
MODELS_DIR=/srv/models
# Erkennung (whisper.cpp, GPU)
STT_PORT=8178
STT_GPU=0
WHISPER_TAG=v1.9.4
WHISPER_COMMIT=927cfce34f31707e17f2bff35c349632fb9e2c3a
CUDA_ARCHS=52;61
WHISPER_MODEL_FILE=ggml-large-v3-turbo.bin
WHISPER_BEAM_SIZE=5
# z. B. -nfa (Flash-Attention aus)
WHISPER_EXTRA_ARGS=
# Ausgabe (Piper, CPU)
TTS_PORT=8179
PIPER_TTS_VERSION=1.8.0
PIPER_VOICES=de_DE-thorsten-high,de_DE-kerstin-low
PIPER_DEFAULT_VOICE=de_DE-thorsten-high
# 0 = native Rate der Stimme (thorsten-high 22050 Hz, kerstin-low 16000 Hz)
PCM_SAMPLE_RATE=24000
TTS_MAX_CHARS=5000
TTS_PARALLEL=2
@@ -0,0 +1,76 @@
# NEX-Sprachdienste (Task 6.2, Auftrag docs/auftraege/2026-10-12-sprachmodelle-nex.md)
# nex-stt Erkennung: whisper.cpp whisper-server (CUDA 12, sm_52/sm_61), Port 8178
# nex-tts Ausgabe: Piper (CPU) hinter OpenAI-kompatiblem Wrapper, Port 8179
# Beide im Host-Netz: Veröffentlichte Docker-Ports laufen über DNAT/FORWARD und umgehen ufw.
# So gelten die ufw-Regeln der VM (8178/8179 nur von 192.168.100.29), siehe Ansible-Rolle nex-speech.
# Datenschutz: kein Access-Log, keine Texte im Log, Temp-Dateien nur im tmpfs.
name: nex-speech
x-logging: &logging
driver: local
options: {max-size: "5m", max-file: "2"}
services:
nex-stt:
build:
context: ./whisper
args:
WHISPER_TAG: ${WHISPER_TAG:-v1.9.4}
WHISPER_COMMIT: ${WHISPER_COMMIT:-927cfce34f31707e17f2bff35c349632fb9e2c3a}
CUDA_ARCHS: ${CUDA_ARCHS:-52;61}
image: knaster/nex-whisper:${WHISPER_TAG:-v1.9.4}-cu12.6
container_name: nex-stt
restart: unless-stopped
network_mode: host
read_only: true
environment:
WHISPER_PORT: ${STT_PORT:-8178}
WHISPER_MODEL: /models/whisper/${WHISPER_MODEL_FILE:-ggml-large-v3-turbo.bin}
WHISPER_LANGUAGE: de
WHISPER_BEAM_SIZE: ${WHISPER_BEAM_SIZE:-5}
WHISPER_EXTRA_ARGS: ${WHISPER_EXTRA_ARGS:-}
volumes:
- ${MODELS_DIR:-/srv/models}/whisper:/models/whisper:ro
tmpfs:
- /run/nex-whisper:size=512m,mode=0700
deploy:
resources:
reservations:
devices: [{driver: nvidia, device_ids: ["${STT_GPU:-0}"], capabilities: [gpu]}]
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:$${WHISPER_PORT}/health >/dev/null || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 300s
logging: *logging
nex-tts:
build: ./piper
image: knaster/nex-tts-piper:${PIPER_TTS_VERSION:-1.8.0}
container_name: nex-tts
restart: unless-stopped
network_mode: host
read_only: true
environment:
TTS_PORT: ${TTS_PORT:-8179}
PIPER_VOICES_DIR: /voices
PIPER_VOICES: ${PIPER_VOICES:-de_DE-thorsten-high,de_DE-kerstin-low}
PIPER_DEFAULT_VOICE: ${PIPER_DEFAULT_VOICE:-de_DE-thorsten-high}
PCM_SAMPLE_RATE: ${PCM_SAMPLE_RATE:-24000}
TTS_MAX_CHARS: ${TTS_MAX_CHARS:-5000}
TTS_PARALLEL: ${TTS_PARALLEL:-2}
volumes:
- ${MODELS_DIR:-/srv/models}/piper:/voices:ro
tmpfs:
- /tmp:size=64m
deploy:
resources:
limits: {cpus: "3", memory: 2G} # ein Kern bleibt für whisper-server und das System
healthcheck:
test: ["CMD", "python", "-c", "import os, urllib.request; urllib.request.urlopen(f\"http://127.0.0.1:{os.environ.get('TTS_PORT', '8179')}/health\", timeout=4)"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
logging: *logging
@@ -0,0 +1,15 @@
# NEX-Sprachausgabe: Piper (CPU) hinter einem OpenAI-kompatiblen Wrapper (app.py).
# Basis per Digest gepinnt; ffmpeg aus Debian (libmp3lame, libopus).
FROM python:3.12-slim-bookworm@sha256:34386ef0cb081344d7ec1c103ba398e6e9f64e9ab3a1509accc92a4e24a07258
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
# /app vorher anlegen: COPY --chmod würde den Elternordner sonst ohne x-Bit erzeugen
WORKDIR /app
COPY --chmod=0644 requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir --disable-pip-version-check -r /app/requirements.txt
COPY --chmod=0644 app.py /app/app.py
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
USER nobody
# Kein Access-Log (Datenschutz), nur Warnungen und Fehler
CMD ["sh", "-c", "exec uvicorn app:app --host ${TTS_HOST:-0.0.0.0} --port ${TTS_PORT:-8179} --no-access-log --log-level warning --timeout-graceful-shutdown 5"]
+268
View File
@@ -0,0 +1,268 @@
"""OpenAI-kompatible Sprachausgabe für NEX: POST /v1/audio/speech mit Piper auf der CPU.
Schlanker Wrapper um piper-tts (Python-API) und ffmpeg:
- Formate: mp3 (Standard), opus (Ogg-Container), aac, flac, pcm (int16 LE mono) und wav.
- mp3/opus/aac/flac/pcm werden satzweise gestreamt (chunked), wav kommt vollständig mit
korrektem Header (kompatibel mit `knaster bench tts` und dem Canary-Prober).
- pcm wird auf PCM_SAMPLE_RATE umgerechnet (Standard 24000 Hz wie bei OpenAI); die Rate steht in
`X-Audio-Sample-Rate` und in `Content-Type: audio/pcm; rate=<Hz>`.
- Datenschutz: Text und Audio werden weder gespeichert noch geloggt; alles läuft über Pipes im RAM.
Umgebung:
PIPER_VOICES_DIR Verzeichnis mit <stimme>.onnx und <stimme>.onnx.json (Standard /voices)
PIPER_VOICES Kommagetrennte Stimmen-IDs (Standard de_DE-thorsten-high,de_DE-kerstin-low)
PIPER_DEFAULT_VOICE Standardstimme (Standard: erste aus PIPER_VOICES)
PCM_SAMPLE_RATE Rate für response_format=pcm, 0 = native Rate der Stimme (Standard 24000)
TTS_MAX_CHARS Höchstlänge von input in Zeichen (Standard 5000)
TTS_PARALLEL Gleichzeitige Synthesen (Standard 2)
"""
from __future__ import annotations
import asyncio
import io
import logging
import os
import threading
import wave
from collections.abc import AsyncGenerator, AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse, Response, StreamingResponse
from piper import PiperVoice, SynthesisConfig
log = logging.getLogger("nex-tts")
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "WARNING").upper(),
format="%(asctime)s %(levelname)s %(name)s: %(message)s")
VOICES_DIR = Path(os.environ.get("PIPER_VOICES_DIR", "/voices"))
VOICE_IDS = [v.strip() for v in os.environ.get("PIPER_VOICES", "de_DE-thorsten-high,de_DE-kerstin-low").split(",")
if v.strip()]
DEFAULT_VOICE = os.environ.get("PIPER_DEFAULT_VOICE") or VOICE_IDS[0]
PCM_RATE = int(os.environ.get("PCM_SAMPLE_RATE", "24000"))
MAX_CHARS = int(os.environ.get("TTS_MAX_CHARS", "5000"))
PARALLEL = threading.Semaphore(int(os.environ.get("TTS_PARALLEL", "2"))) # CPU nicht überbuchen
# ffmpeg-Ausgabeparameter je Format: (Argumente, Content-Type)
ENCODERS: dict[str, tuple[list[str], str]] = {
"mp3": (["-c:a", "libmp3lame", "-b:a", "64k", "-id3v2_version", "0", "-write_xing", "0", "-f", "mp3"], "audio/mpeg"),
# Ogg-Seiten klein halten (20 ms), sonst puffert der Muxer bis zu 1 s
"opus": (["-ar", "48000", "-c:a", "libopus", "-b:a", "32k", "-f", "ogg", "-page_duration", "20000"], "audio/ogg"),
"aac": (["-c:a", "aac", "-b:a", "64k", "-f", "adts"], "audio/aac"),
"flac": (["-c:a", "flac", "-f", "flac"], "audio/flac"),
}
FORMATS = {*ENCODERS, "pcm", "wav"}
voices: dict[str, PiperVoice] = {}
aliases: dict[str, str] = {}
def load_voices() -> None:
for vid in VOICE_IDS:
model = VOICES_DIR / f"{vid}.onnx"
voice = PiperVoice.load(str(model), config_path=str(model) + ".json", use_cuda=False)
voices[vid] = voice
# Kurzname: de_DE-thorsten-high → thorsten-high und thorsten
short = vid.split("-", 1)[1] if "-" in vid else vid
aliases.setdefault(short, vid)
aliases.setdefault(short.rsplit("-", 1)[0], vid)
# Aufwärmen, damit der erste echte Aufruf nicht die ONNX-Initialisierung bezahlt
for _ in voice.synthesize("Hallo."):
pass
if DEFAULT_VOICE not in voices:
raise RuntimeError(f"Standardstimme {DEFAULT_VOICE} ist nicht geladen")
def resolve_voice(name: str | None) -> str:
if not name or name == "default":
return DEFAULT_VOICE
if name in voices:
return name
if name in aliases:
return aliases[name]
# OpenAI-Clients schicken oft "alloy" o. Ä.: nicht scheitern, sondern Standardstimme nehmen
log.warning("Stimme %r unbekannt, nutze %s", name[:40], DEFAULT_VOICE)
return DEFAULT_VOICE
async def synth_pcm(voice: PiperVoice, text: str, cfg: SynthesisConfig) -> AsyncIterator[bytes]:
"""Piper satzweise in einem Thread rechnen und die int16-Blöcke asynchron weiterreichen."""
loop = asyncio.get_running_loop()
queue: asyncio.Queue[bytes | BaseException | None] = asyncio.Queue()
stop = threading.Event()
def work() -> None:
try:
with PARALLEL:
for chunk in voice.synthesize(text, syn_config=cfg):
if stop.is_set():
break
loop.call_soon_threadsafe(queue.put_nowait, chunk.audio_int16_bytes)
except BaseException as exc: # noqa: BLE001 – an den Aufrufer weitergeben
loop.call_soon_threadsafe(queue.put_nowait, exc)
finally:
loop.call_soon_threadsafe(queue.put_nowait, None)
loop.run_in_executor(None, work)
try:
while True:
item = await queue.get()
if item is None:
return
if isinstance(item, BaseException):
raise item
yield item
finally:
stop.set() # Client weg oder fertig: Thread bricht nach dem laufenden Satz ab
async def encode(pcm: AsyncIterator[bytes], rate: int, out_args: list[str]) -> AsyncIterator[bytes]:
"""Rohes PCM per ffmpeg (stdin → stdout) umwandeln und dabei weiterstreamen."""
proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error",
"-f", "s16le", "-ar", str(rate), "-ac", "1", "-i", "pipe:0",
*out_args, "-flush_packets", "1", "pipe:1",
stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
assert proc.stdin is not None and proc.stdout is not None
async def feed() -> None:
try:
async for block in pcm:
proc.stdin.write(block)
await proc.stdin.drain()
finally:
proc.stdin.close()
feeder = asyncio.create_task(feed())
try:
while data := await proc.stdout.read(8192):
yield data
await feeder
if await proc.wait() != 0:
raise RuntimeError(f"ffmpeg beendet mit {proc.returncode}")
finally:
if not feeder.done():
feeder.cancel()
if proc.returncode is None:
proc.kill()
await proc.wait()
async def primed(stream: AsyncGenerator[bytes, None]) -> AsyncIterator[bytes]:
"""Erst den ersten Audioblock abwarten, dann die Header senden.
So misst die Zeit bis zum ersten Byte echtes Audio, und ein Fehler beim Start wird noch
als HTTP-Fehler gemeldet. Fehler mitten im Stream werden nur ohne Text protokolliert.
"""
try:
first = await anext(stream)
except StopAsyncIteration:
first = b""
except Exception as exc: # noqa: BLE001
log.error("Synthese fehlgeschlagen: %s", type(exc).__name__)
raise HTTPException(500, "Synthese fehlgeschlagen") from None
async def rest() -> AsyncIterator[bytes]:
try:
if first:
yield first
async for data in stream:
yield data
except Exception as exc:
log.error("Synthese abgebrochen: %s", type(exc).__name__)
raise
finally:
await stream.aclose()
return rest()
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
load_voices()
log.warning("nex-tts bereit: Stimmen %s, Standard %s, PCM %s Hz", ", ".join(voices), DEFAULT_VOICE,
PCM_RATE or "nativ")
yield
app = FastAPI(title="nex-tts (Piper)", docs_url=None, redoc_url=None, openapi_url=None, lifespan=lifespan)
@app.get("/health")
def health() -> dict:
return {"status": "ok", "voices": list(voices), "default": DEFAULT_VOICE}
@app.get("/v1/models")
def models() -> dict:
return {"object": "list", "data": [{"id": "piper", "object": "model", "owned_by": "knaster"}]}
@app.get("/v1/audio/voices")
def list_voices() -> dict:
return {"voices": [{"id": vid, "sample_rate": v.config.sample_rate} for vid, v in voices.items()],
"default": DEFAULT_VOICE}
@app.post("/v1/audio/speech")
async def speech(request: Request) -> Response:
try:
body = await request.json()
except ValueError:
raise HTTPException(400, "Body muss JSON sein") from None
if not isinstance(body, dict):
raise HTTPException(400, "Body muss ein JSON-Objekt sein")
# Unbekannte Felder (model, instructions, stream_format, …) werden ignoriert.
text = body.get("input")
if not isinstance(text, str) or not text.strip():
raise HTTPException(400, "Feld input fehlt oder ist leer")
if len(text) > MAX_CHARS:
raise HTTPException(400, f"input ist länger als {MAX_CHARS} Zeichen")
fmt = str(body.get("response_format") or "mp3").lower()
if fmt not in FORMATS:
raise HTTPException(400, f"response_format {fmt[:20]!r} nicht unterstützt ({', '.join(sorted(FORMATS))})")
try:
speed = float(body.get("speed") or 1.0)
except (TypeError, ValueError):
raise HTTPException(400, "speed muss eine Zahl sein") from None
speed = min(max(speed, 0.25), 4.0)
vid = resolve_voice(body.get("voice") if isinstance(body.get("voice"), str) else None)
voice = voices[vid]
rate = voice.config.sample_rate
cfg = SynthesisConfig(length_scale=(voice.config.length_scale or 1.0) / speed)
headers = {"X-Voice": vid, "Cache-Control": "no-store"}
if fmt == "wav":
# Vollständig mit korrektem Header (kurze Texte; für Streaming pcm/mp3/opus nehmen)
buf = io.BytesIO()
with wave.open(buf, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(rate)
async for block in synth_pcm(voice, text, cfg):
w.writeframes(block)
return Response(buf.getvalue(), media_type="audio/wav", headers=headers)
if fmt == "pcm":
out_rate = PCM_RATE or rate
headers |= {"X-Audio-Sample-Rate": str(out_rate), "X-Audio-Channels": "1", "X-Audio-Format": "s16le"}
media = f"audio/pcm; rate={out_rate}"
if out_rate == rate:
stream = synth_pcm(voice, text, cfg)
else:
stream = encode(synth_pcm(voice, text, cfg), rate, ["-ar", str(out_rate), "-f", "s16le"])
return StreamingResponse(await primed(stream), media_type=media, headers=headers)
out_args, media = ENCODERS[fmt]
stream = encode(synth_pcm(voice, text, cfg), rate, out_args)
return StreamingResponse(await primed(stream), media_type=media, headers=headers)
@app.exception_handler(HTTPException)
async def _http_error(_: Request, exc: HTTPException) -> JSONResponse:
# OpenAI-Fehlerformat, ohne den Eingabetext zu wiederholen
return JSONResponse({"error": {"message": exc.detail, "type": "invalid_request_error"}},
status_code=exc.status_code)
@@ -0,0 +1,6 @@
# Feste Versionen für den NEX-Sprachausgabe-Wrapper (Stand 12.10.2026)
piper-tts==1.8.0 # GPL-3.0-or-later, eigener Dienst (siehe catalog/models.yaml)
onnxruntime==1.30.0
fastapi==0.141.1
pydantic==2.13.5
uvicorn==0.53.0
@@ -0,0 +1,38 @@
# whisper.cpp whisper-server für Maxwell (sm_52, Quadro M4000) und Pascal (sm_61, Quadro P2000).
# CUDA 12 ist Pflicht: CUDA 13 unterstützt Maxwell/Pascal nicht mehr. Treiber R580 in der VM.
# Version und Commit sind fest; ein Wechsel ist ein bewusster Schritt (Tag und Commit gemeinsam ändern).
ARG CUDA_DEVEL=nvidia/cuda:12.6.3-devel-ubuntu24.04@sha256:392c0df7b577ecae17a17f6ba7f2009c217bb4422f8431c053ae9af61a8c148a
ARG CUDA_RUNTIME=nvidia/cuda:12.6.3-runtime-ubuntu24.04@sha256:92906d87596d638d35015c6353053121bd299d25943b875763321653884ba924
FROM ${CUDA_DEVEL} AS build
ARG WHISPER_TAG=v1.9.4
ARG WHISPER_COMMIT=927cfce34f31707e17f2bff35c349632fb9e2c3a
ARG CUDA_ARCHS="52;61"
RUN apt-get update \
&& apt-get install -y --no-install-recommends git cmake build-essential ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
RUN git clone --depth 1 --branch "${WHISPER_TAG}" https://github.com/ggml-org/whisper.cpp.git . \
&& test "$(git rev-parse HEAD)" = "${WHISPER_COMMIT}"
# Statisch gegen ggml/whisper linken: eine einzelne Binary, keine .so-Kopien in der Laufzeit-Stufe.
# libcuda kommt zur Laufzeit vom Treiber; beim Bauen reicht der Stub.
RUN cmake -B build \
-DGGML_CUDA=ON \
-DCMAKE_CUDA_ARCHITECTURES="${CUDA_ARCHS}" \
-DBUILD_SHARED_LIBS=OFF \
-DWHISPER_BUILD_TESTS=OFF \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_EXE_LINKER_FLAGS="-L/usr/local/cuda/lib64/stubs" \
&& cmake --build build --target whisper-server -j"$(nproc)" \
&& strip build/bin/whisper-server
FROM ${CUDA_RUNTIME}
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /src/build/bin/whisper-server /usr/local/bin/whisper-server
# ffmpeg-Hülle: whisper-server ruft "ffmpeg … 2>&1" auf und schriebe sonst Banner und
# Container-Metadaten des Uploads ins Log. Die Hülle liegt vor /usr/bin im PATH.
COPY --chmod=0755 ffmpeg-quiet.sh /usr/local/bin/ffmpeg
COPY --chmod=0755 entrypoint.sh /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
@@ -0,0 +1,57 @@
#!/bin/sh
# Start von whisper-server für NEX (Spracherkennung, OpenAI-kompatibel).
# - Parameter laut Auftrag: -l de -bs 5 --convert, ohne --print-realtime (kein Text im Log).
# - Temp-Dateien nur im tmpfs; Reste (Fehlerpfad von whisper-server) werden regelmäßig gelöscht.
# - Nach dem Start einmal mit 1 s Stille aufwärmen.
set -eu
MODEL="${WHISPER_MODEL:-/models/whisper/ggml-large-v3-turbo.bin}"
PORT="${WHISPER_PORT:-8178}"
HOST="${WHISPER_HOST:-0.0.0.0}"
TMP_DIR="${WHISPER_TMP_DIR:-/run/nex-whisper}"
BEAM="${WHISPER_BEAM_SIZE:-5}"
LANGUAGE="${WHISPER_LANGUAGE:-de}"
# Zusätzliche Schalter, z. B. "-nfa" (Flash-Attention aus) oder "-t 4"
EXTRA="${WHISPER_EXTRA_ARGS:-}"
mkdir -p "$TMP_DIR"
chmod 0700 "$TMP_DIR"
# Aufräumer: Dateien älter als 10 Minuten im tmpfs löschen
( while sleep 300; do find "$TMP_DIR" -type f -mmin +10 -delete 2>/dev/null || true; done ) &
cleaner=$!
# shellcheck disable=SC2086
whisper-server -m "$MODEL" -l "$LANGUAGE" -bs "$BEAM" --convert --tmp-dir "$TMP_DIR" \
--inference-path /v1/audio/transcriptions --host "$HOST" --port "$PORT" $EXTRA &
pid=$!
stop() { kill -TERM "$pid" 2>/dev/null || true; kill "$cleaner" 2>/dev/null || true; }
trap stop TERM INT
# Warten, bis das Modell geladen ist (/health liefert bis dahin 503)
i=0
until curl -fsS "http://127.0.0.1:${PORT}/health" >/dev/null 2>&1; do
kill -0 "$pid" 2>/dev/null || { echo "whisper-server beendet sich beim Start" >&2; wait "$pid" || exit $?; exit 1; }
i=$((i + 1))
[ "$i" -le 600 ] || { echo "whisper-server nach 600 s nicht bereit" >&2; stop; exit 1; }
sleep 1
done
# Aufwärmen: 1 s Stille, Ergebnis wird verworfen
warm="$TMP_DIR/warmup.wav"
if ffmpeg -f lavfi -i anullsrc=r=16000:cl=mono -t 1 -c:a pcm_s16le -y "$warm" \
&& curl -fsS -o /dev/null -F "file=@${warm}" -F response_format=json \
"http://127.0.0.1:${PORT}/v1/audio/transcriptions"; then
echo "whisper-server bereit und aufgewärmt (Port ${PORT})"
else
echo "Aufwärmen fehlgeschlagen, Dienst läuft trotzdem" >&2
fi
rm -f "$warm"
# Auf whisper-server warten; nach einem Signal erneut warten, bis er wirklich beendet ist
rc=0
wait "$pid" || rc=$?
while kill -0 "$pid" 2>/dev/null; do wait "$pid" || rc=$?; done
kill "$cleaner" 2>/dev/null || true
exit "$rc"
@@ -0,0 +1,3 @@
#!/bin/sh
# Leise ffmpeg-Hülle für whisper-server (--convert): kein Banner, keine Metadaten, nur Fehler.
exec /usr/bin/ffmpeg -hide_banner -nostdin -loglevel error "$@"
+2
View File
@@ -13,10 +13,12 @@ COPY config config
COPY catalog catalog
COPY skills skills
COPY sites sites
COPY ontology ontology
ENV KNASTER_CONFIG_DIR=/app/config \
KNASTER_SKILLS_DIR=/app/skills \
KNASTER_SITE_DIR=/app/sites/knaster \
KNASTER_ONTOLOGY=/app/ontology/know.yaml \
KNASTER_DRY_RUN=true
RUN useradd --system --uid 10001 knaster
+824
View File
@@ -0,0 +1,824 @@
{
"uid": "k3s-pods",
"title": "k3s – Pods",
"tags": [
"knaster",
"k3s"
],
"timezone": "browser",
"schemaVersion": 39,
"version": 1,
"editable": false,
"refresh": "30s",
"time": {
"from": "now-6h",
"to": "now"
},
"templating": {
"list": [
{
"name": "namespace",
"label": "Namespace",
"type": "query",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"query": {
"query": "label_values(kube_namespace_status_phase, namespace)",
"refId": "ns"
},
"definition": "label_values(kube_namespace_status_phase, namespace)",
"refresh": 2,
"includeAll": true,
"multi": true,
"sort": 1,
"current": {
"selected": true,
"text": [
"nex"
],
"value": [
"nex"
]
}
}
]
},
"annotations": {
"list": []
},
"panels": [
{
"id": 1,
"type": "stat",
"title": "Pods laufend",
"description": "",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 0,
"w": 4,
"h": 4
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"refId": "A",
"expr": "sum(kube_pod_status_phase{namespace=~\"$namespace\", phase=\"Running\"}) or vector(0)",
"legendFormat": ""
}
],
"fieldConfig": {
"defaults": {
"unit": "none",
"decimals": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
}
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "background",
"graphMode": "none",
"textMode": "auto"
}
},
{
"id": 2,
"type": "stat",
"title": "Pods nicht bereit",
"description": "",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 4,
"y": 0,
"w": 4,
"h": 4
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"refId": "A",
"expr": "sum(kube_pod_status_ready{namespace=~\"$namespace\", condition=\"false\"}) or vector(0)",
"legendFormat": ""
}
],
"fieldConfig": {
"defaults": {
"unit": "none",
"decimals": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 1
}
]
}
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "background",
"graphMode": "none",
"textMode": "auto"
}
},
{
"id": 3,
"type": "stat",
"title": "Neustarts (24 h)",
"description": "",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 8,
"y": 0,
"w": 4,
"h": 4
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"refId": "A",
"expr": "round(sum(increase(kube_pod_container_status_restarts_total{namespace=~\"$namespace\"}[24h]))) or vector(0)",
"legendFormat": ""
}
],
"fieldConfig": {
"defaults": {
"unit": "none",
"decimals": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 1
}
]
}
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "background",
"graphMode": "none",
"textMode": "auto"
}
},
{
"id": 4,
"type": "stat",
"title": "Speicher (Arbeitssatz)",
"description": "",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 12,
"y": 0,
"w": 4,
"h": 4
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"refId": "A",
"expr": "sum(container_memory_working_set_bytes{job=\"cadvisor\", namespace=~\"$namespace\"})",
"legendFormat": ""
}
],
"fieldConfig": {
"defaults": {
"unit": "bytes",
"decimals": null,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
}
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "background",
"graphMode": "none",
"textMode": "auto"
}
},
{
"id": 5,
"type": "stat",
"title": "CPU (Kerne)",
"description": "",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 16,
"y": 0,
"w": 4,
"h": 4
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"refId": "A",
"expr": "sum(rate(container_cpu_usage_seconds_total{job=\"cadvisor\", namespace=~\"$namespace\"}[5m]))",
"legendFormat": ""
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": null,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
}
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "background",
"graphMode": "none",
"textMode": "auto"
}
},
{
"id": 6,
"type": "stat",
"title": "k3s-Knoten bereit",
"description": "",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 20,
"y": 0,
"w": 4,
"h": 4
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"refId": "A",
"expr": "sum(kube_node_status_condition{condition=\"Ready\", status=\"true\"}) or vector(0)",
"legendFormat": ""
}
],
"fieldConfig": {
"defaults": {
"unit": "none",
"decimals": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
},
{
"color": "green",
"value": 5
}
]
}
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "background",
"graphMode": "none",
"textMode": "auto"
}
},
{
"id": 7,
"type": "timeseries",
"title": "Speicher je Pod",
"description": "Arbeitssatz (container_memory_working_set_bytes), danach richtet sich das OOM-Limit",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 4,
"w": 12,
"h": 9
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"refId": "A",
"expr": "sum by (pod) (container_memory_working_set_bytes{job=\"cadvisor\", namespace=~\"$namespace\"})",
"legendFormat": "{{pod}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "bytes",
"custom": {
"fillOpacity": 10,
"lineWidth": 1,
"showPoints": "never"
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "right",
"calcs": [
"lastNotNull",
"max"
]
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 8,
"type": "timeseries",
"title": "CPU je Pod",
"description": "Kerne, Mittel über 5 min",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 12,
"y": 4,
"w": 12,
"h": 9
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"refId": "A",
"expr": "sum by (pod) (rate(container_cpu_usage_seconds_total{job=\"cadvisor\", namespace=~\"$namespace\"}[5m]))",
"legendFormat": "{{pod}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"custom": {
"fillOpacity": 10,
"lineWidth": 1,
"showPoints": "never"
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "right",
"calcs": [
"lastNotNull",
"max"
]
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 9,
"type": "table",
"title": "Pods",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 13,
"w": 24,
"h": 9
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"refId": "A",
"expr": "max by (pod, node) (kube_pod_info{namespace=~\"$namespace\"})",
"legendFormat": "",
"format": "table",
"instant": true
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"refId": "B",
"expr": "sum by (pod) (kube_pod_container_status_restarts_total{namespace=~\"$namespace\"})",
"legendFormat": "",
"format": "table",
"instant": true
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"refId": "C",
"expr": "sum by (pod) (container_memory_working_set_bytes{job=\"cadvisor\", namespace=~\"$namespace\"})",
"legendFormat": "",
"format": "table",
"instant": true
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"refId": "D",
"expr": "sum by (pod) (kube_pod_container_resource_requests{namespace=~\"$namespace\", resource=\"memory\"})",
"legendFormat": "",
"format": "table",
"instant": true
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"refId": "E",
"expr": "sum by (pod) (kube_pod_container_resource_limits{namespace=~\"$namespace\", resource=\"memory\"})",
"legendFormat": "",
"format": "table",
"instant": true
}
],
"transformations": [
{
"id": "merge",
"options": {}
},
{
"id": "organize",
"options": {
"excludeByName": {
"Time": true,
"Value #A": true
},
"indexByName": {
"pod": 0,
"node": 1,
"Value #B": 2,
"Value #C": 3,
"Value #D": 4,
"Value #E": 5
},
"renameByName": {
"pod": "Pod",
"node": "Knoten",
"Value #B": "Neustarts",
"Value #C": "Speicher",
"Value #D": "Request",
"Value #E": "Limit"
}
}
}
],
"fieldConfig": {
"defaults": {},
"overrides": [
{
"matcher": {
"id": "byRegexp",
"options": "Speicher|Request|Limit"
},
"properties": [
{
"id": "unit",
"value": "bytes"
}
]
},
{
"matcher": {
"id": "byName",
"options": "Neustarts"
},
"properties": [
{
"id": "custom.cellOptions",
"value": {
"type": "color-background"
}
},
{
"id": "thresholds",
"value": {
"mode": "absolute",
"steps": [
{
"color": "transparent",
"value": null
},
{
"color": "orange",
"value": 1
}
]
}
}
]
}
]
},
"options": {
"showHeader": true,
"sortBy": [
{
"displayName": "Speicher",
"desc": true
}
]
}
},
{
"id": 10,
"type": "timeseries",
"title": "Pods je Knoten (alle Namespaces)",
"description": "",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 22,
"w": 12,
"h": 8
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"refId": "A",
"expr": "sum by (node) (kube_pod_info)",
"legendFormat": "{{node}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "none",
"custom": {
"fillOpacity": 10,
"lineWidth": 1,
"showPoints": "never"
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "right",
"calcs": [
"lastNotNull",
"max"
]
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 11,
"type": "timeseries",
"title": "Speicher der Pods je Knoten (alle Namespaces)",
"description": "",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 12,
"y": 22,
"w": 12,
"h": 8
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"refId": "A",
"expr": "sum by (instance) (container_memory_working_set_bytes{job=\"cadvisor\"})",
"legendFormat": "{{instance}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "bytes",
"custom": {
"fillOpacity": 10,
"lineWidth": 1,
"showPoints": "never"
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "right",
"calcs": [
"lastNotNull",
"max"
]
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 12,
"type": "bargauge",
"title": "Belegung der Volumes (PVC)",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 30,
"w": 24,
"h": 6
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"refId": "A",
"expr": "max by (persistentvolumeclaim) (kubelet_volume_stats_used_bytes{namespace=~\"$namespace\"} / kubelet_volume_stats_capacity_bytes{namespace=~\"$namespace\"})",
"legendFormat": "{{persistentvolumeclaim}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"min": 0,
"max": 1,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 0.8
},
{
"color": "red",
"value": 0.9
}
]
}
},
"overrides": []
},
"options": {
"orientation": "horizontal",
"displayMode": "gradient",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
}
}
}
]
}
@@ -0,0 +1,772 @@
{
"uid": "knaster-uebersicht",
"title": "knaster – Übersicht",
"tags": [
"knaster"
],
"timezone": "browser",
"schemaVersion": 39,
"version": 1,
"editable": false,
"refresh": "30s",
"time": {
"from": "now-6h",
"to": "now"
},
"panels": [
{
"id": 1,
"type": "stat",
"title": "Proxmox-Knoten online",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 0,
"w": 4,
"h": 4
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"refId": "A",
"expr": "count(max by (id) (pve_up{id=~\"node/.*\"}) == 1) or vector(0)",
"legendFormat": ""
}
],
"fieldConfig": {
"defaults": {
"unit": "none",
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
}
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "background",
"graphMode": "none",
"textMode": "auto"
}
},
{
"id": 2,
"type": "stat",
"title": "Gäste laufend",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 4,
"y": 0,
"w": 4,
"h": 4
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"refId": "A",
"expr": "count(max by (id) (pve_up{id=~\"(qemu|lxc)/.*\"}) == 1) or vector(0)",
"legendFormat": ""
}
],
"fieldConfig": {
"defaults": {
"unit": "none",
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
}
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "background",
"graphMode": "none",
"textMode": "auto"
}
},
{
"id": 3,
"type": "stat",
"title": "GPUs gemeldet",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 8,
"y": 0,
"w": 4,
"h": 4
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"refId": "A",
"expr": "count(count by (instance, uuid) (nvidia_smi_index)) or vector(0)",
"legendFormat": ""
}
],
"fieldConfig": {
"defaults": {
"unit": "none",
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
}
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "background",
"graphMode": "none",
"textMode": "auto"
}
},
{
"id": 4,
"type": "stat",
"title": "Ziele nicht erreichbar",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 12,
"y": 0,
"w": 4,
"h": 4
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"refId": "A",
"expr": "count(up{job!~\"vllm|llamacpp\"} == 0) or vector(0)",
"legendFormat": ""
}
],
"fieldConfig": {
"defaults": {
"unit": "none",
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 1
}
]
}
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "background",
"graphMode": "none",
"textMode": "auto"
}
},
{
"id": 5,
"type": "stat",
"title": "Aktive Alerts",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 16,
"y": 0,
"w": 4,
"h": 4
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"refId": "A",
"expr": "count(ALERTS{alertstate=\"firing\"}) or vector(0)",
"legendFormat": ""
}
],
"fieldConfig": {
"defaults": {
"unit": "none",
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 1
}
]
}
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "background",
"graphMode": "none",
"textMode": "auto"
}
},
{
"id": 6,
"type": "stat",
"title": "Slots verfügbar",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 20,
"y": 0,
"w": 4,
"h": 4
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"refId": "A",
"expr": "sum(knaster_slot_available) or vector(0)",
"legendFormat": ""
}
],
"fieldConfig": {
"defaults": {
"unit": "none",
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
}
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "background",
"graphMode": "none",
"textMode": "auto"
}
},
{
"id": 7,
"type": "stat",
"title": "Knoten",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 4,
"w": 12,
"h": 4
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"refId": "A",
"expr": "max by (id) (pve_up{id=~\"node/.*\"})",
"legendFormat": "{{id}}"
}
],
"fieldConfig": {
"defaults": {
"mappings": [
{
"type": "value",
"options": {
"0": {
"text": "aus",
"color": "red"
},
"1": {
"text": "an",
"color": "green"
}
}
}
],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
},
{
"color": "green",
"value": 1
}
]
}
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "background",
"graphMode": "none",
"textMode": "auto"
}
},
{
"id": 8,
"type": "stat",
"title": "GPU-VMs und infra (node_exporter)",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 12,
"y": 4,
"w": 12,
"h": 4
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"refId": "A",
"expr": "up{job=~\"node|infra-node\"}",
"legendFormat": "{{instance}}"
}
],
"fieldConfig": {
"defaults": {
"mappings": [
{
"type": "value",
"options": {
"0": {
"text": "aus",
"color": "red"
},
"1": {
"text": "an",
"color": "green"
}
}
}
],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
},
{
"color": "green",
"value": 1
}
]
}
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "background",
"graphMode": "none",
"textMode": "auto"
}
},
{
"id": 9,
"type": "timeseries",
"title": "CPU je Knoten",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 8,
"w": 12,
"h": 7
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"refId": "A",
"expr": "max by (id) (pve_cpu_usage_ratio{id=~\"node/.*\"})",
"legendFormat": "{{id}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"min": 0,
"max": 1
},
"overrides": []
}
},
{
"id": 10,
"type": "timeseries",
"title": "RAM je Knoten",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 12,
"y": 8,
"w": 12,
"h": 7
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"refId": "A",
"expr": "max by (id) (pve_memory_usage_bytes{id=~\"node/.*\"}) / max by (id) (pve_memory_size_bytes{id=~\"node/.*\"})",
"legendFormat": "{{id}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"min": 0,
"max": 1
},
"overrides": []
}
},
{
"id": 11,
"type": "timeseries",
"title": "GPU-Temperatur",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 15,
"w": 8,
"h": 8
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"refId": "A",
"expr": "max by (instance, uuid) (nvidia_smi_temperature_gpu) * on (instance, uuid) group_left(name) max by (instance, uuid, name) (nvidia_smi_gpu_info)",
"legendFormat": "{{instance}} {{name}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "celsius"
},
"overrides": []
}
},
{
"id": 12,
"type": "timeseries",
"title": "GPU-Auslastung",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 8,
"y": 15,
"w": 8,
"h": 8
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"refId": "A",
"expr": "max by (instance, uuid) (nvidia_smi_utilization_gpu_ratio) * on (instance, uuid) group_left(name) max by (instance, uuid, name) (nvidia_smi_gpu_info)",
"legendFormat": "{{instance}} {{name}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"min": 0,
"max": 1
},
"overrides": []
}
},
{
"id": 13,
"type": "timeseries",
"title": "GPU-Speicher belegt",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 16,
"y": 15,
"w": 8,
"h": 8
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"refId": "A",
"expr": "max by (instance, uuid) (nvidia_smi_memory_used_bytes / nvidia_smi_memory_total_bytes) * on (instance, uuid) group_left(name) max by (instance, uuid, name) (nvidia_smi_gpu_info)",
"legendFormat": "{{instance}} {{name}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"min": 0,
"max": 1
},
"overrides": []
}
},
{
"id": 14,
"type": "stat",
"title": "Slots (Router)",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 23,
"w": 12,
"h": 5
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"refId": "A",
"expr": "knaster_slot_available",
"legendFormat": "{{slot}}"
}
],
"fieldConfig": {
"defaults": {
"mappings": [
{
"type": "value",
"options": {
"0": {
"text": "aus",
"color": "red"
},
"1": {
"text": "an",
"color": "green"
}
}
}
],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
},
{
"color": "green",
"value": 1
}
]
}
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "background",
"graphMode": "none",
"textMode": "auto"
}
},
{
"id": 15,
"type": "table",
"title": "Aktive Alerts",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 12,
"y": 23,
"w": 12,
"h": 5
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"refId": "A",
"expr": "ALERTS{alertstate=\"firing\"}",
"legendFormat": "",
"instant": true,
"format": "table"
}
],
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": {
"Time": true,
"Value": true,
"__name__": true,
"alertstate": true
}
}
}
]
}
],
"templating": {
"list": []
},
"annotations": {
"list": []
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,17 @@
# Dashboards aus deploy/grafana/dashboards (im Container /var/lib/grafana/dashboards).
# Herkunft der Community-Dashboards (grafana.com, geladen am 12.10.2026; ${DS_PROMETHEUS} → uid prometheus, __inputs entfernt):
# proxmox.json ID 10347 „Proxmox via Prometheus“ Revision 5 (für prometheus-pve-exporter)
# node-exporter-full.json ID 1860 „Node Exporter Full“ Revision 45
# nvidia-gpu.json ID 14574 „Nvidia GPU Metrics“ Revision 15 (für utkuozdemir/nvidia_gpu_exporter)
# knaster-uebersicht.json eigenes Dashboard (Startseite, für den Kiosk in Task 5.3)
# k3s-pods.json eigenes Dashboard (Task 6.5): Pods je Namespace (Vorgabe nex), kube-state-metrics + cAdvisor
apiVersion: 1
providers:
- name: knaster
folder: knaster
type: file
disableDeletion: true
allowUiUpdates: false
updateIntervalSeconds: 60
options:
path: /var/lib/grafana/dashboards
@@ -0,0 +1,10 @@
# Feste uid "prometheus": Die Dashboards in deploy/grafana/dashboards verweisen darauf.
apiVersion: 1
datasources:
- name: Prometheus
uid: prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false
+18
View File
@@ -0,0 +1,18 @@
# Kubernetes-Manifeste (k3s, D6)
Ausgerollt von ops mit `kubectl` (Rolle `k3s-apps`, Playbook `playbooks/k3s.yml`):
| Datei | Zweck |
|---|---|
| `runtimeclass-nvidia.yaml` | RuntimeClass `nvidia` (Runtime legt k3s in den GPU-VMs selbst an) |
| `nvidia-device-plugin.yaml` | NVIDIA Device Plugin v0.20.1, nur auf Knoten mit Label `knaster.gpu=true`; liefert `nvidia.com/gpu`, seit 19.10. nur für Index 0 (M4000) |
| `portainer-agent.yaml` | Portainer Agent 2.45.2 (NodePort 30778), Quelle downloads.portainer.io/ce2-45 |
| `nex-speech/` | NEX-Sprachdienste auf gpu-pve52 (Playbook `playbooks/nex-speech-k3s.yml`) |
| `nex-searxng/` | NEX-Websuche SearXNG, NodePort 30880 (Playbook `playbooks/nex-searxng-k3s.yml`), Einstellungen aus dem nex-Repo |
| `portainer/` | Portainer CE 2.45.1 auf der Dienst-VM k3s, Datenbank von appsrv08 übernommen (Playbook `playbooks/portainer-k3s.yml` mit Rollback BE→CE), NodePort 30779 |
| `nex/` | Namespace nex für Agenten im Cluster: Brücke zu den NEX-Diensten auf appsrv08 (Services ohne Selector), Zugang für nex-admin (ServiceAccount, Role), Pull-Secret Gitea (Playbook `playbooks/nex-k3s.yml`); nex-Spec 2026-10-19 |
| `llamacpp/` | llama.cpp-Server je GPU-VM (Namespace `knaster`), Vorlage `llamacpp.yaml.j2` mit der UUID der P2000 je Knoten (Playbook `playbooks/gpu-llamacpp-k3s.yml`, Task 6.1) |
| `monitoring/` | kube-state-metrics v2.19.1 (NodePort 30808) und ServiceAccount `prometheus-infra` (nur `nodes/metrics`) für Prometheus auf infra (Playbook `playbooks/monitoring-k3s.yml`, Task 6.5) |
| `rustdesk/` | RustDesk-Server hbbs/hbbr, Service LoadBalancer 21115–21119 auf jeder Knoten-Adresse (Playbook `playbooks/rustdesk-k3s.yml`), Schlüssel im Secret `rustdesk-keys` |
Portainer: Umgebung „Kubernetes → Agent“, Adresse `<IP eines Knotens>:30778`.
+62
View File
@@ -0,0 +1,62 @@
# llama.cpp-Server je GPU-VM in k3s (Task 6.1, D6), gerendert von der Rolle gpu-llamacpp (tasks/k3s.yml) je Knoten.
# Ein Pod je Knoten; im Profil embed laufen Embedding und Rerank als zwei Container auf derselben P2000.
# Kartenwahl: kein nvidia.com/gpu (das Device Plugin verwaltet nur die M4000, Index 0), sondern die UUID der P2000
# per NVIDIA_VISIBLE_DEVICES, ermittelt und geprüft über den Kartennamen (ADR-0006: M4000 bleibt frei).
# Host-Netz mit den Ports wie zuvor im Compose (8080/8081/8082): Prometheus-Ziele (file_sd) und ufw bleiben gültig.
# Image aus dem Compose-Build, in das containerd von k3s importiert (imagePullPolicy Never).
# Datenschutz wie im Compose: kein --verbose, /slots aus, keine Web-Oberfläche.
apiVersion: apps/v1
kind: Deployment
metadata:
name: llamacpp-{{ inventory_hostname }}
namespace: knaster
labels: {app: llamacpp, knaster.node: {{ inventory_hostname }}}
spec:
replicas: 1
strategy: {type: Recreate} # Host-Ports und VRAM der P2000 können nur einmal belegt sein
selector:
matchLabels: {app: llamacpp, knaster.node: {{ inventory_hostname }}}
template:
metadata:
labels: {app: llamacpp, knaster.node: {{ inventory_hostname }}}
spec:
runtimeClassName: nvidia
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
nodeSelector:
kubernetes.io/hostname: {{ inventory_hostname }}
containers:
{% for svc in llamacpp_k3s_containers %}
- name: {{ svc.name }}
image: {{ llamacpp_image }}
imagePullPolicy: Never
args: {{ svc.args | to_json }}
env:
- {name: NVIDIA_VISIBLE_DEVICES, value: "{{ llamacpp_gpu_env[svc.gpu_var] }}"}
- {name: NVIDIA_DRIVER_CAPABILITIES, value: "compute,utility"}
resources:
requests: {cpu: 500m, memory: 1Gi}
limits: {memory: {{ svc.memory_limit }}}
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
volumeMounts:
- {name: models, mountPath: /models, readOnly: true}
- {name: tmp, mountPath: /tmp}
startupProbe:
httpGet: {path: /health, port: {{ svc.port }}, host: 127.0.0.1}
periodSeconds: 5
failureThreshold: 60
readinessProbe:
httpGet: {path: /health, port: {{ svc.port }}, host: 127.0.0.1}
periodSeconds: 30
livenessProbe:
httpGet: {path: /health, port: {{ svc.port }}, host: 127.0.0.1}
periodSeconds: 30
failureThreshold: 3
{% endfor %}
volumes:
- name: models
hostPath: {path: {{ llamacpp_models_dir }}, type: Directory}
- name: tmp
emptyDir: {sizeLimit: 64Mi}
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v1
kind: Namespace
metadata:
name: knaster
labels:
knaster.app: llamacpp
@@ -0,0 +1,88 @@
# kube-state-metrics v2.19.1 (Task 6.5): Zustand der Objekte (Pods, Deployments, Knoten, PVCs) für Prometheus auf infra.
# NodePort 30808 auf jedem Knoten; Prometheus fragt genau ein Ziel ab (file_sd, monitoring-targets.py).
# Nur die Ressourcen, die Dashboard und Alerts brauchen (infra hat 4 GB RAM).
apiVersion: v1
kind: ServiceAccount
metadata:
name: kube-state-metrics
namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: kube-state-metrics
rules:
- apiGroups: [""]
resources: [nodes, pods, namespaces, persistentvolumeclaims]
verbs: [list, watch]
- apiGroups: [apps]
resources: [deployments, daemonsets, statefulsets, replicasets]
verbs: [list, watch]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: kube-state-metrics
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: kube-state-metrics
subjects:
- kind: ServiceAccount
name: kube-state-metrics
namespace: kube-system
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: kube-state-metrics
namespace: kube-system
labels: {app: kube-state-metrics}
spec:
replicas: 1
selector:
matchLabels: {app: kube-state-metrics}
template:
metadata:
labels: {app: kube-state-metrics}
spec:
serviceAccountName: kube-state-metrics
automountServiceAccountToken: true
securityContext:
runAsNonRoot: true
runAsUser: 65534
seccompProfile: {type: RuntimeDefault}
containers:
- name: kube-state-metrics
image: registry.k8s.io/kube-state-metrics/kube-state-metrics:v2.19.1
args:
- --resources=nodes,pods,namespaces,persistentvolumeclaims,deployments,daemonsets,statefulsets,replicasets
- --port=8080
- --telemetry-port=8081
ports:
- {name: metrics, containerPort: 8080}
- {name: telemetry, containerPort: 8081}
readinessProbe:
httpGet: {path: /readyz, port: 8081}
initialDelaySeconds: 5
livenessProbe:
httpGet: {path: /livez, port: 8080}
initialDelaySeconds: 5
resources:
requests: {cpu: 10m, memory: 64Mi}
limits: {memory: 192Mi}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: {drop: [ALL]}
---
apiVersion: v1
kind: Service
metadata:
name: kube-state-metrics
namespace: kube-system
spec:
type: NodePort
selector: {app: kube-state-metrics}
ports:
- {name: metrics, port: 8080, targetPort: metrics, nodePort: 30808}
@@ -0,0 +1,40 @@
# Lesezugang für Prometheus auf infra (Task 6.5): kubelet /metrics und /metrics/cadvisor auf Port 10250.
# Der kubelet prüft das Token per SubjectAccessReview gegen nodes/metrics; mehr darf der ServiceAccount nicht.
# Das Token (Secret prometheus-infra-token) legt playbooks/monitoring-k3s.yml auf ops ab, die Rolle
# infra-stack kopiert es nach /opt/knaster/infra/secrets/k3s/.
apiVersion: v1
kind: ServiceAccount
metadata:
name: prometheus-infra
namespace: kube-system
---
apiVersion: v1
kind: Secret
metadata:
name: prometheus-infra-token
namespace: kube-system
annotations:
kubernetes.io/service-account.name: prometheus-infra
type: kubernetes.io/service-account-token
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: prometheus-infra
rules:
- apiGroups: [""]
resources: [nodes/metrics]
verbs: [get]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: prometheus-infra
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: prometheus-infra
subjects:
- kind: ServiceAccount
name: prometheus-infra
namespace: kube-system
+68
View File
@@ -0,0 +1,68 @@
# NEX-Websuche (SearXNG), umgezogen von appsrv08 (Portainer-Stack nex, Dienst searxng) nach k3s (D6-Test, 15.10.2026).
# Quelle der Einstellungen: nex-Repo deploy/nex/searxng/settings.yml (Kopie hier, als ConfigMap eingehängt).
# SEARXNG_SECRET liegt im Secret nex-searxng (legt playbooks/nex-searxng-k3s.yml an, nicht im Repo).
# Zustandslos, kein Volume. Erreichbar im LAN über NodePort 30880 auf jeder Knoten-Adresse; die Hermes-Agenten
# auf appsrv08 brauchen dann SEARXNG_URL=http://<Knoten-IP>:30880 statt http://nex-searxng:8080.
apiVersion: apps/v1
kind: Deployment
metadata:
name: nex-searxng
namespace: nex
labels: {app: nex-searxng}
spec:
replicas: 1
selector:
matchLabels: {app: nex-searxng}
template:
metadata:
labels: {app: nex-searxng}
spec:
# /etc/searxng muss beschreibbar sein (Image legt dort Dateien an): emptyDir, settings.yml aus der ConfigMap kopiert
initContainers:
- name: settings
image: searxng/searxng:2026.10.4-d48c4b555
command: ["sh", "-c", "cp /config/settings.yml /etc/searxng/settings.yml"]
volumeMounts:
- {name: config, mountPath: /config, readOnly: true}
- {name: etc, mountPath: /etc/searxng}
containers:
- name: searxng
image: searxng/searxng:2026.10.4-d48c4b555
ports:
- {name: http, containerPort: 8080}
env:
- name: SEARXNG_SECRET
valueFrom:
secretKeyRef: {name: nex-searxng, key: SEARXNG_SECRET}
- {name: GRANIAN_HOST, value: "0.0.0.0"} # IPv4: sonst erscheint die Absender-Adresse nur als ::ffff
- {name: GRANIAN_LOG_ACCESS_ENABLED, value: "true"}
- {name: GRANIAN_LOG_ACCESS_FMT, value: '[%(time)s] %(addr)s "%(method)s %(path)s" %(status)d %(dt_ms).0fms'} # ohne Query-String: keine Suchbegriffe im Log
resources:
requests: {cpu: 100m, memory: 128Mi}
limits: {memory: 256Mi}
volumeMounts:
- {name: etc, mountPath: /etc/searxng}
readinessProbe:
httpGet: {path: /healthz, port: http}
periodSeconds: 10
livenessProbe:
httpGet: {path: /healthz, port: http}
periodSeconds: 30
failureThreshold: 3
volumes:
- name: config
configMap: {name: nex-searxng-settings}
- name: etc
emptyDir: {sizeLimit: 16Mi}
---
apiVersion: v1
kind: Service
metadata:
name: nex-searxng
namespace: nex
labels: {app: nex-searxng}
spec:
type: NodePort
selector: {app: nex-searxng}
ports:
- {name: http, port: 8080, targetPort: http, nodePort: 30880}
+35
View File
@@ -0,0 +1,35 @@
# SearXNG für die NEX-Agenten (Plan docs/plans/2026-10-06-nex-searxng.md). Nur intern im Netz llm-internal erreichbar.
# Quelle für den Dienst searxng in deploy/nex/docker-compose.appsrv08.yml: dort als base64 im Command eingebettet
# (keine Bind-Mounts im Portainer-Stack). Nach jeder Änderung neu einbetten: base64 -w0 deploy/nex/searxng/settings.yml
# secret_key kommt aus der Env SEARXNG_SECRET (Stack-Variable NEX_SEARXNG_SECRET), nicht aus dieser Datei.
# Engines am 2026-10-06 von appsrv08 aus getestet: duckduckgo und bing liefern, wikipedia ergänzt Lexikon-Treffer;
# google („access denied“), brave („too many requests“), qwant (CAPTCHA) blockiert, startpage/mojeek im Image inaktiv.
use_default_settings:
engines:
keep_only: [duckduckgo, bing, wikipedia]
general:
instance_name: NEX-Suche
enable_metrics: false
server:
limiter: false # nur interne Clients (Agenten-Container), kein Bot-Schutz nötig
image_proxy: false
public_instance: false
search:
safe_search: 1
default_lang: de
autocomplete: ""
formats: [html, json] # Hermes fragt GET /search?q=…&format=json&pageno=1 (plugins/web/searxng/provider.py)
outgoing:
request_timeout: 3.0
max_request_timeout: 6.0
engines:
- name: bing
disabled: false # im Image standardmäßig aus
# Wikipedia liefert sonst nur eine Infobox; Hermes liest ausschließlich "results"
- name: wikipedia
display_type: ["infobox", "list"]
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v1
kind: Namespace
metadata:
name: nex
labels:
knaster.app: nex-speech
+56
View File
@@ -0,0 +1,56 @@
# NEX-Spracherkennung (whisper.cpp, M4000) auf gpu-pve52, umgezogen von Docker Compose (Task 6.2, D6).
# Host-Netz wie zuvor: Port 8178 auf der VM-Adresse, die ufw-Regel der VM (nur appsrv08) gilt weiter.
# Image stammt aus dem Compose-Build und wurde in das containerd von k3s importiert (Rolle nex-speech-k3s).
apiVersion: apps/v1
kind: Deployment
metadata:
name: nex-stt
namespace: nex
labels: {app: nex-stt}
spec:
replicas: 1
strategy: {type: Recreate} # Host-Port 8178 kann nur einmal belegt sein
selector:
matchLabels: {app: nex-stt}
template:
metadata:
labels: {app: nex-stt}
spec:
runtimeClassName: nvidia
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
nodeSelector:
kubernetes.io/hostname: gpu-pve52
containers:
- name: whisper
image: knaster/nex-whisper:v1.9.4-cu12.6
imagePullPolicy: Never
env:
- {name: WHISPER_PORT, value: "8178"}
- {name: WHISPER_MODEL, value: /models/whisper/ggml-large-v3-turbo.bin}
- {name: WHISPER_LANGUAGE, value: de}
- {name: WHISPER_BEAM_SIZE, value: "5"}
- {name: WHISPER_EXTRA_ARGS, value: ""}
resources:
limits: {nvidia.com/gpu: 1}
securityContext:
readOnlyRootFilesystem: true
volumeMounts:
- {name: models, mountPath: /models/whisper, readOnly: true}
- {name: run, mountPath: /run/nex-whisper}
startupProbe:
httpGet: {path: /health, port: 8178, host: 127.0.0.1}
periodSeconds: 5
failureThreshold: 60
readinessProbe:
httpGet: {path: /health, port: 8178, host: 127.0.0.1}
periodSeconds: 30
livenessProbe:
httpGet: {path: /health, port: 8178, host: 127.0.0.1}
periodSeconds: 30
failureThreshold: 3
volumes:
- name: models
hostPath: {path: /srv/models/whisper, type: Directory}
- name: run
emptyDir: {medium: Memory, sizeLimit: 512Mi}
+56
View File
@@ -0,0 +1,56 @@
# NEX-Sprachausgabe (Piper, CPU) auf gpu-pve52, Host-Netz Port 8179 (ufw: nur appsrv08).
apiVersion: apps/v1
kind: Deployment
metadata:
name: nex-tts
namespace: nex
labels: {app: nex-tts}
spec:
replicas: 1
strategy: {type: Recreate}
selector:
matchLabels: {app: nex-tts}
template:
metadata:
labels: {app: nex-tts}
spec:
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
nodeSelector:
kubernetes.io/hostname: gpu-pve52
containers:
- name: piper
image: knaster/nex-tts-piper:1.8.0
imagePullPolicy: Never
env:
- {name: TTS_PORT, value: "8179"}
- {name: PIPER_VOICES_DIR, value: /voices}
- {name: PIPER_VOICES, value: "de_DE-thorsten-high,de_DE-kerstin-low"}
- {name: PIPER_DEFAULT_VOICE, value: de_DE-thorsten-high}
- {name: PCM_SAMPLE_RATE, value: "24000"}
- {name: TTS_MAX_CHARS, value: "5000"}
- {name: TTS_PARALLEL, value: "2"}
resources:
limits: {cpu: "3", memory: 2Gi} # ein Kern bleibt für whisper-server und das System
requests: {cpu: "500m", memory: 512Mi}
securityContext:
readOnlyRootFilesystem: true
volumeMounts:
- {name: voices, mountPath: /voices, readOnly: true}
- {name: tmp, mountPath: /tmp}
startupProbe:
httpGet: {path: /health, port: 8179, host: 127.0.0.1}
periodSeconds: 5
failureThreshold: 24
readinessProbe:
httpGet: {path: /health, port: 8179, host: 127.0.0.1}
periodSeconds: 30
livenessProbe:
httpGet: {path: /health, port: 8179, host: 127.0.0.1}
periodSeconds: 30
failureThreshold: 3
volumes:
- name: voices
hostPath: {path: /srv/models/piper, type: Directory}
- name: tmp
emptyDir: {sizeLimit: 64Mi}
+57
View File
@@ -0,0 +1,57 @@
# Zugang von nex-admin (läuft auf appsrv08) zum Namespace nex: Agenten als Deployment/PVC/Secret/Service anlegen,
# exec, Logs, Skalieren (nex-Spec 2026-10-19 Cluster-Agenten, D265). Kein cluster-admin, nur dieser Namespace.
# Token: Secret nex-admin-token (Typ service-account-token, dauerhaft); playbooks/nex-k3s.yml legt es als Datei auf
# ops ab (/root/.config/knaster/nex-admin-k8s.token) – von dort in die Stack-Variable NEX_ADMIN_K8S_TOKEN.
apiVersion: v1
kind: ServiceAccount
metadata:
name: nex-admin
namespace: nex
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: nex-admin
namespace: nex
rules:
- apiGroups: [""]
resources: [pods, pods/log]
verbs: [get, list, watch, create, delete]
- apiGroups: [""]
resources: [pods/exec]
verbs: [create, get]
- apiGroups: [""]
resources: [services, persistentvolumeclaims, secrets]
verbs: [get, list, create, update, patch, delete]
- apiGroups: [""]
resources: [endpoints, events]
verbs: [get, list]
- apiGroups: [apps]
resources: [deployments, deployments/scale]
verbs: [get, list, watch, create, update, patch, delete]
- apiGroups: [metrics.k8s.io]
resources: [pods]
verbs: [get, list]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: nex-admin
namespace: nex
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: nex-admin
subjects:
- kind: ServiceAccount
name: nex-admin
namespace: nex
---
apiVersion: v1
kind: Secret
metadata:
name: nex-admin-token
namespace: nex
annotations:
kubernetes.io/service-account.name: nex-admin
type: kubernetes.io/service-account-token
+95
View File
@@ -0,0 +1,95 @@
# Brücke zu den NEX-Diensten auf appsrv08 (nex-Spec 2026-10-19 Cluster-Agenten, D259).
# Die Agenten-Pods im Namespace nex sprechen dieselben Namen wie die Container im Docker-Netz llm-internal auf
# appsrv08 (nex-router, nex-know, nex-access, grp-mcp-intern); hier zeigen Services ohne Selector mit festen
# EndpointSlices (v1 Endpoints ist seit 1.33 veraltet) auf die Host-Ports von appsrv08 (nur auf 192.168.100.29 veröffentlicht, Stacks nex und grp-mcp-intern).
# Zieht ein Dienst in den Cluster (Weg 3), ersetzt sein echter Service den Eintrag – der Name bleibt.
# LiteLLM bleibt ohne Brücke: Agenten sprechen nur den Router, der Router LiteLLM (nex AGENTS.md: kein Pfad an
# LiteLLM vorbei, und kein zusätzlicher Host-Port dafür).
apiVersion: v1
kind: Service
metadata:
name: nex-router
namespace: nex
labels: {knaster.app: nex-bridge}
spec:
ports:
- {name: http, port: 4100, targetPort: 4100}
---
apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
name: nex-router
namespace: nex
labels: {knaster.app: nex-bridge, kubernetes.io/service-name: nex-router}
addressType: IPv4
ports:
- {name: http, port: 4100, protocol: TCP}
endpoints:
- addresses: ["192.168.100.29"]
---
apiVersion: v1
kind: Service
metadata:
name: nex-know
namespace: nex
labels: {knaster.app: nex-bridge}
spec:
ports:
- {name: http, port: 4200, targetPort: 4200}
---
apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
name: nex-know
namespace: nex
labels: {knaster.app: nex-bridge, kubernetes.io/service-name: nex-know}
addressType: IPv4
ports:
- {name: http, port: 14200, protocol: TCP} # Host-Port 14200 auf appsrv08 (4200 dort schon belegt); Service-Port bleibt 4200
endpoints:
- addresses: ["192.168.100.29"]
---
apiVersion: v1
kind: Service
metadata:
name: nex-access
namespace: nex
labels: {knaster.app: nex-bridge}
spec:
ports:
- {name: http, port: 4300, targetPort: 4300}
---
apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
name: nex-access
namespace: nex
labels: {knaster.app: nex-bridge, kubernetes.io/service-name: nex-access}
addressType: IPv4
ports:
- {name: http, port: 4300, protocol: TCP}
endpoints:
- addresses: ["192.168.100.29"]
---
# GRP-MCP (Rolle projektleitung): Stack grp-mcp-intern auf appsrv08, Dienst gateway, Container-Port 8790
apiVersion: v1
kind: Service
metadata:
name: grp-mcp-intern
namespace: nex
labels: {knaster.app: nex-bridge}
spec:
ports:
- {name: http, port: 8790, targetPort: 8790}
---
apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
name: grp-mcp-intern
namespace: nex
labels: {knaster.app: nex-bridge, kubernetes.io/service-name: grp-mcp-intern}
addressType: IPv4
ports:
- {name: http, port: 8790, protocol: TCP}
endpoints:
- addresses: ["192.168.100.29"]
+48
View File
@@ -0,0 +1,48 @@
# NVIDIA Device Plugin v0.20.1 (Quelle: deployments/static/nvidia-device-plugin.yml des Projekts),
# ergänzt um runtimeClassName nvidia und nodeSelector knaster.gpu=true (nur die GPU-VMs).
# Meldet je Knoten die Ressource nvidia.com/gpu, die Pods anfordern können.
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: nvidia-device-plugin-daemonset
namespace: kube-system
spec:
selector:
matchLabels:
name: nvidia-device-plugin-ds
updateStrategy:
type: RollingUpdate
template:
metadata:
labels:
name: nvidia-device-plugin-ds
spec:
runtimeClassName: nvidia
nodeSelector:
knaster.gpu: "true"
tolerations:
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
priorityClassName: system-node-critical
containers:
- image: nvcr.io/nvidia/k8s-device-plugin:v0.20.1
name: nvidia-device-plugin-ctr
env:
- name: FAIL_ON_INIT_ERROR
value: "false"
# Nur Index 0 (Quadro M4000 in allen GPU-VMs, geprüft in playbooks/gpu-llamacpp-k3s.yml) wird als
# nvidia.com/gpu vergeben. Die P2000 gehört fest den llama.cpp-Pods (per UUID, Task 6.1).
- name: NVIDIA_VISIBLE_DEVICES
value: "0"
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumeMounts:
- name: device-plugin
mountPath: /var/lib/kubelet/device-plugins
volumes:
- name: device-plugin
hostPath:
path: /var/lib/kubelet/device-plugins
+81
View File
@@ -0,0 +1,81 @@
apiVersion: v1
kind: Namespace
metadata:
name: portainer
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: portainer-sa-clusteradmin
namespace: portainer
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: portainer-crb-clusteradmin
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: cluster-admin
subjects:
- kind: ServiceAccount
name: portainer-sa-clusteradmin
namespace: portainer
---
apiVersion: v1
kind: Service
metadata:
name: portainer-agent
namespace: portainer
spec:
type: NodePort
selector:
app: portainer-agent
ports:
- name: http
protocol: TCP
port: 9001
targetPort: 9001
nodePort: 30778
---
apiVersion: v1
kind: Service
metadata:
name: portainer-agent-headless
namespace: portainer
spec:
clusterIP: None
selector:
app: portainer-agent
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: portainer-agent
namespace: portainer
spec:
selector:
matchLabels:
app: portainer-agent
template:
metadata:
labels:
app: portainer-agent
spec:
serviceAccountName: portainer-sa-clusteradmin
containers:
- name: portainer-agent
image: portainer/agent:2.45.2
imagePullPolicy: Always
env:
- name: LOG_LEVEL
value: DEBUG
- name: AGENT_CLUSTER_ADDR
value: "portainer-agent-headless"
- name: KUBERNETES_POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
ports:
- containerPort: 9001
protocol: TCP
+95
View File
@@ -0,0 +1,95 @@
# Portainer CE im Cluster, Umzug von appsrv08 (Portainer EE 2.45.1) mit übernommener Datenbank (Benutzer,
# API-Schlüssel, Umgebungen, Stacks). Läuft auf der Dienst-VM k3s (tank, HA), Daten auf local-path dieser VM.
# Erst-Befüllung und Rollback BE→CE macht playbooks/portainer-k3s.yml aus /root/.config/knaster/portainer_data.tgz.
# Erreichbar: https://<Knoten-IP>:30779 (NodePort), von außen über den nginx auf appsrv08 (portainer.mygodel.de).
# Dieselbe Version wie auf appsrv08 (2.45.1): die Datenbank wird beim Start nicht migriert.
apiVersion: v1
kind: Namespace
metadata:
name: portainer-ce
labels:
knaster.app: portainer
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: portainer-data
namespace: portainer-ce
spec:
accessModes: [ReadWriteOnce]
storageClassName: local-path
resources:
requests:
storage: 2Gi
---
# Portainer verwaltet den eigenen Cluster über ein ServiceAccount (Umgebung „local“ wird beim ersten Start angelegt).
apiVersion: v1
kind: ServiceAccount
metadata:
name: portainer-sa-clusteradmin
namespace: portainer-ce
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: portainer-ce-crb
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: cluster-admin
subjects:
- kind: ServiceAccount
name: portainer-sa-clusteradmin
namespace: portainer-ce
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: portainer
namespace: portainer-ce
labels: {app: portainer-ce}
spec:
replicas: 1
strategy: {type: Recreate}
selector:
matchLabels: {app: portainer-ce}
template:
metadata:
labels: {app: portainer-ce}
spec:
serviceAccountName: portainer-sa-clusteradmin
nodeSelector:
knaster.role: control # Dienst-VM k3s: tank, HA, Replikation
containers:
- name: portainer
image: portainer/portainer-ce:2.45.1
ports:
- {name: https, containerPort: 9443}
- {name: edge, containerPort: 8000}
resources:
requests: {cpu: 50m, memory: 128Mi}
limits: {memory: 512Mi}
volumeMounts:
- {name: data, mountPath: /data}
readinessProbe:
httpGet: {path: /api/system/status, port: https, scheme: HTTPS}
periodSeconds: 10
livenessProbe:
httpGet: {path: /api/system/status, port: https, scheme: HTTPS}
periodSeconds: 30
failureThreshold: 3
volumes:
- name: data
persistentVolumeClaim: {claimName: portainer-data}
---
apiVersion: v1
kind: Service
metadata:
name: portainer
namespace: portainer-ce
labels: {app: portainer-ce}
spec:
type: NodePort
selector: {app: portainer-ce}
ports:
- {name: https, port: 9443, targetPort: https, nodePort: 30779}
+7
View File
@@ -0,0 +1,7 @@
# Runtime "nvidia" legt k3s in seinem containerd an, sobald nvidia-container-runtime in der VM liegt.
# Pods mit runtimeClassName: nvidia bekommen die GPU-Bibliotheken eingehängt.
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
name: nvidia
handler: nvidia
+113
View File
@@ -0,0 +1,113 @@
# RustDesk-Server (ID-Server hbbs + Relay hbbr) in k3s, Umzug vom Portainer-Stack „rustdesk“ auf appsrv08
# (Phase 6.0, 15.10.2026). Schlüsselpaar id_ed25519 kommt aus dem Secret rustdesk-keys (legt
# playbooks/rustdesk-k3s.yml aus /root/.config/knaster/rustdesk/ auf ops an; nicht im Repo). Mit demselben
# Schlüssel bleiben die in den Clients hinterlegten Public Keys gültig.
#
# Erreichbar über Service-Typ LoadBalancer (k3s ServiceLB): Ports 21115–21119/tcp und 21116/udp auf jeder
# Knoten-Adresse. Die Clients tragen als ID-Server die Adresse ein, die das Playbook als Relay-Adresse setzt
# (rustdesk_public_host, Standard: die k3s-VM). Erst mit fester Adresse/DNS-Namen für die Clients umstellen.
apiVersion: v1
kind: Namespace
metadata:
name: rustdesk
labels:
knaster.app: rustdesk
---
# Datenbank (ID-Registrierungen) auf local-path: bindet den Pod an den Knoten, auf dem er zuerst lief.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: rustdesk-data
namespace: rustdesk
spec:
accessModes: [ReadWriteOnce]
storageClassName: local-path
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: rustdesk
namespace: rustdesk
labels: {app: rustdesk}
spec:
replicas: 1
strategy: {type: Recreate} # ein Datenverzeichnis, ein Pod
selector:
matchLabels: {app: rustdesk}
template:
metadata:
labels: {app: rustdesk}
spec:
nodeSelector:
knaster.gpu: "true" # nicht auf die kleine k3s-VM (2 Kerne, 3 GB)
containers:
- name: hbbs
image: rustdesk/rustdesk-server:1.1.14
# Relay-Adresse, die hbbs den Clients nennt; setzt das Playbook (RELAY_HOST) – muss von den Clients erreichbar sein.
# Image ohne Shell (scratch), deshalb $(VAR)-Ersetzung durch Kubernetes statt sh -c.
command: ["hbbs", "-r", "$(RELAY_HOST):21117", "-k", "_"]
env:
- {name: RELAY_HOST, value: "__RELAY_HOST__"}
workingDir: /root
ports:
- {name: nat-test, containerPort: 21115, protocol: TCP}
- {name: id-tcp, containerPort: 21116, protocol: TCP}
- {name: id-udp, containerPort: 21116, protocol: UDP}
- {name: web, containerPort: 21118, protocol: TCP}
resources:
requests: {cpu: 50m, memory: 64Mi}
limits: {memory: 256Mi}
volumeMounts:
- {name: data, mountPath: /root}
- {name: keys, mountPath: /root/id_ed25519, subPath: id_ed25519, readOnly: true}
- {name: keys, mountPath: /root/id_ed25519.pub, subPath: id_ed25519.pub, readOnly: true}
readinessProbe:
tcpSocket: {port: id-tcp}
periodSeconds: 10
livenessProbe:
tcpSocket: {port: id-tcp}
periodSeconds: 30
failureThreshold: 3
- name: hbbr
image: rustdesk/rustdesk-server:1.1.14
command: ["hbbr", "-k", "_"]
workingDir: /root
ports:
- {name: relay, containerPort: 21117, protocol: TCP}
- {name: relay-web, containerPort: 21119, protocol: TCP}
resources:
requests: {cpu: 50m, memory: 64Mi}
limits: {memory: 256Mi}
volumeMounts:
- {name: keys, mountPath: /root/id_ed25519, subPath: id_ed25519, readOnly: true}
- {name: keys, mountPath: /root/id_ed25519.pub, subPath: id_ed25519.pub, readOnly: true}
readinessProbe:
tcpSocket: {port: relay}
periodSeconds: 10
volumes:
- name: data
persistentVolumeClaim: {claimName: rustdesk-data}
- name: keys
secret:
secretName: rustdesk-keys
defaultMode: 0400
---
apiVersion: v1
kind: Service
metadata:
name: rustdesk
namespace: rustdesk
labels: {app: rustdesk}
spec:
type: LoadBalancer
selector: {app: rustdesk}
ports:
- {name: nat-test, port: 21115, targetPort: 21115, protocol: TCP}
- {name: id-tcp, port: 21116, targetPort: 21116, protocol: TCP}
- {name: id-udp, port: 21116, targetPort: 21116, protocol: UDP}
- {name: relay, port: 21117, targetPort: 21117, protocol: TCP}
- {name: web, port: 21118, targetPort: 21118, protocol: TCP}
- {name: relay-web, port: 21119, targetPort: 21119, protocol: TCP}
+31
View File
@@ -47,3 +47,34 @@ groups:
for: 15m
labels: {severity: warning}
annotations: {summary: "{{ $labels.instance }} {{ $labels.mountpoint }} < 10 % frei"}
# k3s (Task 6.5): nur melden, der Healer greift nicht ein (config/healer-rules.yaml, Aktion notify)
- name: knaster-k3s
rules:
- alert: K3sNodeNotReady
expr: kube_node_status_condition{condition="Ready", status="true"} == 0
for: 5m
labels: {severity: high}
annotations: {summary: "k3s-Knoten {{ $labels.node }} nicht bereit"}
- alert: K3sPodCrashLooping
# max_over_time: zwischen zwei Neustarts steht der Container kurz nicht in CrashLoopBackOff (Test 19.10.)
expr: max by (namespace, pod, container) (max_over_time(kube_pod_container_status_waiting_reason{reason="CrashLoopBackOff"}[5m])) >= 1
for: 10m
labels: {severity: warning}
annotations: {summary: "{{ $labels.namespace }}/{{ $labels.pod }} ({{ $labels.container }}) startet immer wieder neu"}
- alert: K3sDeploymentUnavailable
expr: max by (namespace, deployment) (kube_deployment_spec_replicas) > max by (namespace, deployment) (kube_deployment_status_replicas_available)
for: 15m
labels: {severity: warning}
annotations: {summary: "Deployment {{ $labels.namespace }}/{{ $labels.deployment }} hat zu wenige bereite Pods"}
- alert: K3sContainerMemoryNearLimit
expr: >
max by (namespace, pod, container) (container_memory_working_set_bytes{job="cadvisor"})
/ on (namespace, pod, container)
max by (namespace, pod, container) (kube_pod_container_resource_limits{resource="memory"}) > 0.9
for: 10m
labels: {severity: warning}
annotations: {summary: "{{ $labels.namespace }}/{{ $labels.pod }} ({{ $labels.container }}) über 90 % des Speicherlimits"}
+70 -33
View File
@@ -1,5 +1,8 @@
# Scrape-Ziele. Die Labels an den GPU-Zielen (slot, docker_host, container, pve_node, vmid) landen in den
# Alerts und damit beim Healer. Hostnamen = DNS-Namen der VMs (ANNAHME bis Deployment).
# Alerts und damit beim Healer.
# GPU-VMs (job node, gpu, llamacpp): file_sd, weil die DHCP-Adressen wechseln (D5). Die Dateien erzeugt
# platform/proxmox/scripts/monitoring-targets.py auf ops aus `tofu output -json gpu_vms` und der Registry;
# die Ansible-Rolle infra-stack kopiert sie nach /opt/knaster/infra/file_sd (im Container /etc/knaster-sd).
global:
scrape_interval: 30s
evaluation_interval: 30s
@@ -11,10 +14,17 @@ alerting:
- static_configs: [{targets: ["alertmanager:9093"]}]
scrape_configs:
- job_name: prometheus
static_configs: [{targets: ["localhost:9090"]}]
- job_name: alertmanager
static_configs: [{targets: ["alertmanager:9093"]}]
- job_name: knaster-router
metrics_path: /metrics
static_configs: [{targets: ["router:8080"]}]
# Ada-Rechner (Windows, D3): Anbindung über die Worker-Registry v2 (Task 6.4), bis dahin nicht erreichbar
- job_name: vllm
static_configs:
- targets: ["gpu-main:8000"]
@@ -22,46 +32,73 @@ scrape_configs:
- targets: ["gpu-flex:8000"]
labels: {slot: tier1-flex, docker_host: gpu-flex, container: vllm, pve_node: pve-ada, vmid: "202"}
# llama-server (--metrics) in den GPU-VMs (Task 6.1): Embedding 8081 und Rerank 8082 in gpu-pve44/46,
# Chat 8080 in gpu-pve48. Ziele aus monitoring-targets.py (Profile laut Inventar-Gruppe gpu_llamacpp).
- job_name: llamacpp
static_configs:
- targets: ["gpu-embed-1:8081"]
labels: {slot: embed-default, docker_host: gpu-embed-1, container: llamacpp-embed, pve_node: pve3, vmid: "221"}
- targets: ["gpu-embed-1:8082"]
labels: {slot: rerank-default, docker_host: gpu-embed-1, container: llamacpp-rerank, pve_node: pve3, vmid: "221"}
- targets: ["gpu-small:8080"]
labels: {slot: verifier-small, docker_host: gpu-small, container: llamacpp-chat, pve_node: pve3, vmid: "231"}
file_sd_configs: [{files: [/etc/knaster-sd/llamacpp.json]}]
# nvidia_gpu_exporter (nvidia-smi, auch für Maxwell/Pascal; DCGM kann Maxwell nicht) in jeder GPU-VM
- job_name: gpu
static_configs:
- targets: ["gpu-main:9835"]
labels: {slot: tier1-general, pve_node: pve-ada, vmid: "201"}
- targets: ["gpu-flex:9835"]
labels: {slot: tier1-flex, pve_node: pve-ada, vmid: "202"}
- targets: ["gpu-embed-1:9835"]
labels: {slot: embed-default, pve_node: pve3, vmid: "221"}
- targets: ["gpu-embed-2:9835"]
labels: {slot: embed-default, pve_node: pve4, vmid: "222"}
- targets: ["gpu-small:9835"]
labels: {slot: verifier-small, pve_node: pve3, vmid: "231"}
file_sd_configs: [{files: [/etc/knaster-sd/gpu.json]}]
- job_name: node # Labels werden für GpuXidError → Healer (VM-Neustart) gebraucht
static_configs:
- targets: ["gpu-main:9100"]
labels: {slot: tier1-general, pve_node: pve-ada, vmid: "201"}
- targets: ["gpu-flex:9100"]
labels: {slot: tier1-flex, pve_node: pve-ada, vmid: "202"}
- targets: ["gpu-embed-1:9100"]
labels: {slot: embed-default, pve_node: pve3, vmid: "221"}
- targets: ["gpu-embed-2:9100"]
labels: {slot: embed-default, pve_node: pve4, vmid: "222"}
- targets: ["gpu-small:9100"]
labels: {slot: verifier-small, pve_node: pve3, vmid: "231"}
# node_exporter in jeder GPU-VM; Labels werden für GpuXidError → Healer (VM-Neustart) gebraucht
- job_name: node
file_sd_configs: [{files: [/etc/knaster-sd/node.json]}]
# node_exporter im LXC infra selbst (Host-Netz des LXC)
- job_name: infra-node
static_configs:
- targets: ["host.docker.internal:9100"]
labels: {instance: infra}
# prometheus-pve-exporter: jeder Knoten liefert die Cluster-Sicht (cluster=1) und seine eigenen Werte (node=1).
# Alle fünf als Ziel, damit die Cluster-Sicht auch bei einem ausgefallenen Knoten bleibt (Dashboard wählt eine instance).
- job_name: proxmox
metrics_path: /pve
params: {module: [default]}
static_configs: [{targets: ["pve1", "pve2", "pve3", "pve4", "pve-ada"]}]
params: {module: [default], cluster: ["1"], node: ["1"]}
static_configs:
- {targets: ["192.168.100.210"], labels: {pve: pve44}}
- {targets: ["192.168.100.209"], labels: {pve: pve46}}
- {targets: ["192.168.100.212"], labels: {pve: pve48}}
- {targets: ["192.168.100.207"], labels: {pve: pve52}}
- {targets: ["192.168.100.214"], labels: {pve: pve61}}
relabel_configs:
- {source_labels: [__address__], target_label: __param_target}
- {source_labels: [__param_target], target_label: instance}
- {target_label: __address__, replacement: "pve-exporter:9221"}
# k3s (Task 6.5): kubelet direkt auf jedem Knoten (10250), TLS gegen die k3s-CA, Token des ServiceAccounts
# prometheus-infra (nur nodes/metrics). Ziele per file_sd aus `kubectl get nodes` (monitoring-targets.py auf ops).
# infra hat 4 GB RAM: von kubelet und cAdvisor nur die Reihen, die Dashboard „k3s – Pods“ und Alerts brauchen.
- job_name: kubelet
scheme: https
authorization: {credentials_file: /etc/knaster-k3s/token}
tls_config: {ca_file: /etc/knaster-k3s/ca.crt}
file_sd_configs: [{files: [/etc/knaster-sd/kubelet.json]}]
metric_relabel_configs:
- source_labels: [__name__]
regex: kubelet_running_pods|kubelet_running_containers|kubelet_volume_stats_(used|capacity)_bytes
action: keep
- job_name: cadvisor
scheme: https
metrics_path: /metrics/cadvisor
authorization: {credentials_file: /etc/knaster-k3s/token}
tls_config: {ca_file: /etc/knaster-k3s/ca.crt}
file_sd_configs: [{files: [/etc/knaster-sd/kubelet.json]}]
metric_relabel_configs:
- source_labels: [__name__]
regex: container_cpu_usage_seconds_total|container_memory_working_set_bytes|container_memory_rss|container_oom_events_total|container_network_(receive|transmit)_bytes_total
action: keep
# nur Pods (keine systemd-Slices, keine Docker-Container der Compose-Stacks)
- source_labels: [pod]
regex: ""
action: drop
# CPU/Speicher je Container: Pod-Summe und Pause-Container verwerfen (Netz gibt es nur je Pod)
- source_labels: [__name__, container]
regex: "container_(cpu|memory|oom)[^;]*;(POD)?"
action: drop
- job_name: kube-state-metrics
honor_labels: true # namespace/pod/node kommen aus den Metriken selbst
file_sd_configs: [{files: [/etc/knaster-sd/kube-state-metrics.json]}]
+39
View File
@@ -0,0 +1,39 @@
# Anmeldung am Proxmox-Cluster „knaster“ (Vertretung)
Stand 13.10.2026. Zugangsdaten liegen in Password Depot, nicht in dieser Datei und nicht im Repo.
## 1. Einträge in Password Depot (seit 13.10.2026 vorhanden)
Eintrag **„Proxmox knaster – r.rapp@pve“**:
- Benutzer: `r.rapp`
- Passwort (Kopie auch in `it/proxmox/.env`, Zeile `PVE_ADMIN_R_RAPP_PASS`)
- Einmalpasswort (TOTP) im Feld „Einmalpasswort / 2FA“: Password Depot zeigt den 6-stelligen Code an.
- Notizen: Recovery-Keys (je einmal gültig). Die Datei `tfa-r.rapp.txt` ist gelöscht, Password Depot ist die einzige Quelle.
Zweiter Eintrag **„Proxmox knaster – root (Notfall)“**: Passwörter `PVE44_PASS` … `PVE61_PASS` aus `.env`.
Muss die Verwaltung im Notfall ganz an eine andere Person gehen: [NOTFALL-ADMIN-UEBERGABE.md](NOTFALL-ADMIN-UEBERGABE.md).
## 2. Anmelden (Weboberfläche)
1. Nur vom **Admin-Laptop** (WLAN 192.168.100.151 oder Dock 192.168.100.181) oder über den Steuerrechner `ops`. Von anderen Rechnern sperrt die Firewall die Weboberfläche. Soll ein anderer PC dauerhaft Zugriff bekommen: seine IP in `cluster-knaster/sites/knaster/proxmox.yaml` unter `firewall.management` eintragen und auf ops `ansible-playbook playbooks/cluster.yml` laufen lassen.
2. Browser: `https://192.168.100.210:8006` (pve44). Jeder Knoten geht, die Oberfläche zeigt den ganzen Cluster. Zertifikatswarnung verschwindet nach Import der CA (`certutil -user -addstore Root C:\Users\r.rapp\it\proxmox\pve-root-ca.pem`).
3. Benutzername `r.rapp`, Passwort aus Password Depot, Realm **„Proxmox VE authentication server“** (nicht „Linux PAM“).
4. Zweiter Schritt: 6-stelligen Code aus Password Depot (oder der Authenticator-App) eingeben.
## 3. Wenn der Code nicht geht
- **TOTP-Dialog eingefroren, rotes Symbol:** Eine Passwortmanager-Erweiterung (Bitwarden, Chrome-ID `nngceckbapebfimnlniiiahkandclblb`) legt ihre „Speichern?“-Leiste über den Dialog. Abhilfe: Inkognito-Fenster oder Edge, dauerhaft die Knoten-IPs in Bitwarden unter „Ausgeschlossene Domains“ eintragen. Die Anmeldung selbst ist in Ordnung (13.10. per API geprüft).
- Reiter „Recovery Key“ wählen und einen Recovery-Key aus Password Depot eingeben. Danach einen neuen TOTP anlegen: Oben rechts Benutzer → „TFA“, oder per Skript `cluster-knaster/platform/proxmox/scripts/pve-admin-user.sh r.rapp` nach Löschen der alten Einträge (siehe `docs/BETRIEB.md` Abschnitt 8).
- Letzter Ausweg: `root`, Realm „Linux PAM standard authentication“, Passwort des jeweiligen Knotens. root hat keine 2FA und ist nur für Notfälle gedacht.
## 4. Nur Kommandozeile
`ssh pve44` (Alias aus `~/.ssh/config`, Schlüssel `it/proxmox/keys/id_ed25519_pve`, ohne Passphrase). Funktioniert ebenfalls nur vom Admin-Laptop oder von `ops`.
## 5. Was im Vertretungsfall reicht
- Zustand prüfen: `cluster-knaster/platform/proxmox/scripts/healthcheck.sh` (Git-Bash auf dem Laptop). Meldet OK/FEHLER je Punkt.
- Updates und Neustarts: `docs/BETRIEB.md` Abschnitt 6, immer nur ein Knoten nach dem anderen.
- Benachrichtigungen des Clusters kommen per Mail an `ki@mygodel.de`.
+214 -1
View File
@@ -1,4 +1,4 @@
# Betrieb: Skill-Lebenszyklus, Hermes, Pflege, Freischaltung
# Betrieb: Skill-Lebenszyklus, Hermes, Pflege, Freischaltung, Proxmox-Cluster
## 1. Lebenszyklus eines Skills
@@ -71,3 +71,216 @@ Freitext, der keinem Befehl entspricht, bildet ein kleines Modell auf einen Befe
| Skill-Stufe | Prompt `stufe <skill> zurück` bzw. Git-Revert der SKILL.md |
| Treiber/Engine | Proxmox-Snapshot der GPU-VM (vor jedem Update anlegen) |
| Konfiguration | Git-Revert und Neustart des Router-Containers |
## 6. Proxmox: Updates und Neustarts (rollierend, Knoten für Knoten)
Stand 12.10.2026, getestet mit dem Wartungsmodus auf pve44. Gilt für pve44, pve46, pve48, pve52 und pve61.
**Wichtig vorab**
- Auf den GPU-Knoten gehört die M4000 (Boot-Grafik) der VM. Nach dem frühen Start bleibt der lokale Bildschirm schwarz. Zugang nur per SSH oder Weboberfläche; das GRUB-Menü ist beim Start noch sichtbar (Notzugang: Eintrag bearbeiten, `initcall_blacklist=sysfb_init` entfernen).
- pve61 hängt am Netz nur über die USB-NIC im Dock. Neustart nur, wenn jemand vor Ort ist. Kommt er nach etwa 2 Minuten nicht zurück: Dock aus- und wieder einstecken.
- Nie zwei Knoten gleichzeitig. Mit 5 Stimmen bleibt das Quorum bei einem fehlenden Knoten erhalten.
**Ablauf je Knoten**
1. Vorher: `pvecm status` (Quorate: Yes), `ha-manager status`, `pvesr status` (alle Jobs OK).
2. Wartungsmodus: `ha-manager crm-command node-maintenance enable <knoten>`. HA-Gäste ziehen um (`infra`: Test 36 s nach pve48).
3. GPU-VM des Knotens sauber stoppen: `qm shutdown <vmid>` (244/246/248/252). Sie hat kein HA und kann nicht wandern (Passthrough).
4. Update: `platform/proxmox/scripts/bootstrap-node.sh <knoten>` vom Laptop (idempotent, meldet `Neustart nötig`), dann `reboot`.
5. Nachher prüfen: Kernel, `systemctl --failed`, `ip -br a show vmbr0`, NTP; auf GPU-Knoten `lspci -nnk -d 10de:` → überall `vfio-pci`.
6. GPU-VM startet von selbst (`onboot`). Danach auf ops: `cd /root/cluster-knaster/platform/proxmox/tofu && . /root/.config/knaster/tofu.env && bash ../scripts/refresh-vm-access.sh` (DHCP-Adressen können wechseln, Hostschlüssel kommen über den Gast-Agenten) und `ansible gpu_vms -m shell -a "nvidia-smi -L"`.
7. Wartungsmodus aus: `ha-manager crm-command node-maintenance disable <knoten>`. HA holt `infra` auf seinen Heimatknoten pve44 zurück (Test 41 s).
**GPU-VMs aktualisieren**
- Pakete per `apt` in der VM, die NVIDIA-Pakete sind auf `hold` (R580). Vor Treiberwechseln einen Snapshot anlegen: `qm snapshot <vmid> vor-treiber`.
- Nach einem VM-Neustart immer `refresh-vm-access.sh`, solange es keine DHCP-Reservierungen gibt (D5).
## 7. Infra-Stack und Monitoring (LXC `infra`)
Stand 12.10.2026 (Task 5.2). Der LXC `infra` (vmid 101, HA, Heimat pve44, Replikation nach pve48/pve52) führt einen Compose-Stack `knaster-infra`: Router (Dry-Run), TTS (Piper), Postgres, Prometheus, Alertmanager, Grafana, pve-exporter, node-exporter, autoheal. Kein eigener LiteLLM (D4).
**Adressen** (DHCP, die IP kann sich nach einem Umzug ändern; aktuelle IP: `ansible infra -m debug -a var=ansible_host` auf ops oder `refresh-vm-access.sh`)
| Dienst | URL | Anmeldung |
|---|---|---|
| Grafana | `http://<infra-IP>:3000` | `admin` bzw. `kiosk` (Viewer) |
| Prometheus | `http://<infra-IP>:9090` | ohne (nur LAN) |
| Alertmanager | `http://<infra-IP>:9093` | ohne (nur LAN) |
| Router | `http://<infra-IP>:8080` | Bearer-Token |
**Wo liegt was**
- Konfiguration im Repo: `deploy/compose/infra`, `deploy/prometheus`, `deploy/alertmanager`, `deploy/grafana` (Datasource, Dashboards). Im LXC als Kopie unter `/opt/knaster/repo` (wird bei jedem Ansible-Lauf abgeglichen, dort nichts ändern).
- Geheimnisse nur im LXC, nie im Repo: `/opt/knaster/infra/.env` (0600) mit `POSTGRES_PASSWORD`, `KNASTER_API_TOKEN`, `GRAFANA_ADMIN_PASSWORD`, `GRAFANA_KIOSK_PASSWORD` (beim ersten Lauf mit `openssl rand` erzeugt, danach nie überschrieben). Lesen: `ssh root@<infra-IP> grep ^GRAFANA_KIOSK_PASSWORD= /opt/knaster/infra/.env` (für den Kiosk, Task 5.3).
- `/opt/knaster/infra/secrets/router_token` (Kopie von `KNASTER_API_TOKEN` für den Alertmanager) und `secrets/pve-exporter.env` (Kopie von ops `/root/.config/knaster/pve-exporter.env`, Token `monitoring@pve!prom`, PVEAuditor).
- Einstellungen ohne Geheimnis (Dry-Run, `LITELLM_BASE_URL` …) stehen in `platform/proxmox/ansible/roles/infra-stack/defaults/main.yml` und werden bei jedem Lauf in die `.env` geschrieben.
**Ausrollen** (auf ops, idempotent): `cd /root/cluster-knaster && git pull`, Adressen auffrischen (Abschnitt 6, Schritt 6), dann in `platform/proxmox/ansible`:
- `ansible-playbook playbooks/monitoring.yml` – Exporter in den GPU-VMs (ohne Treiber-Rolle, kein Neustart) und Stack im LXC.
- Ein Image-Update heißt: Tag im Compose-File ändern, committen, Playbook laufen lassen.
**Ziele der GPU-VMs (file_sd) auffrischen**
Die GPU-VMs haben DHCP-Adressen (D5). `platform/proxmox/scripts/monitoring-targets.py` schreibt aus `tofu output -json gpu_vms` und der Registry `node.json` und `gpu.json` (Labels `instance` = VM-Name, `pve_node`, `vmid`, `slot`, `slots`). Nach jedem VM-Neustart:
```bash
cd /root/cluster-knaster/platform/proxmox/tofu && . /root/.config/knaster/tofu.env && bash ../scripts/refresh-vm-access.sh
cd ../ansible && ansible-playbook playbooks/monitoring-targets.yml
```
Prometheus liest die Dateien in `/opt/knaster/infra/file_sd` selbst neu, ein Neustart ist nicht nötig.
**Exporter in den GPU-VMs:** `prometheus-node-exporter` (Port 9100, Ubuntu-Paket, liest `/var/lib/node_exporter/textfile` mit den Xid-Zählern) und `nvidia_gpu_exporter` (Port 9835, nvidia-smi; DCGM unterstützt Maxwell nicht). ufw-Regeln mit Kommentar `knaster-monitoring` erlauben beide Ports aus `192.168.100.0/24`; ufw selbst wird von der Rolle nicht ein- oder ausgeschaltet.
**Alerts:** Alertmanager meldet an den Router (`/v1/healer/alertmanager`, Healer im Dry-Run). Der Receiver für n8n `knaster-notify` ist in `deploy/alertmanager/alertmanager.yml` nur vorbereitet (auskommentiert). Test: `docker exec knaster-infra-alertmanager-1 amtool --alertmanager.url=http://localhost:9093 alert add test severity=warning` und `… alert query`.
**Dashboards** (Ordner `knaster`, Startseite „knaster – Übersicht“): Proxmox via Prometheus (grafana.com 10347), Node Exporter Full (1860), Nvidia GPU Metrics (14574), knaster – Übersicht (eigenes), k3s – Pods (eigenes, Task 6.5).
**k3s-Pods (Task 6.5, seit 19.10.):** Prometheus fragt drei Jobs ab. `kubelet` und `cadvisor` gehen direkt an Port 10250 jedes k3s-Knotens (TLS gegen die k3s-CA, Token des ServiceAccounts `prometheus-infra`, darf nur `nodes/metrics` lesen). `kube-state-metrics` geht an NodePort 30808 (bevorzugt auf der Dienst-VM k3s). Die Ziele erzeugt `monitoring-targets.py` aus `kubectl get nodes` auf ops (`kubelet.json`, `kube-state-metrics.json`), sie werden mit `playbooks/monitoring-targets.yml` aufgefrischt wie die der GPU-VMs. Token und CA: auf ops `/root/.config/knaster/k3s-prometheus.token` und `k3s-ca.crt`, im LXC `/opt/knaster/infra/secrets/k3s/` (nur dieser Ordner ist in Prometheus eingehängt). Von cAdvisor bleiben nur CPU, Speicher, OOM und Netz je Pod (`metric_relabel_configs`), weil infra nur 4 GB hat. Alerts `K3s*` (Knoten nicht bereit, CrashLoop, Deployment ohne bereite Pods, Speicher > 90 % des Limits) melden nur, der Healer greift nicht ein. Ausrollen: `ansible-playbook playbooks/monitoring-k3s.yml` (Cluster-Teil, dann Infra-Stack).
**Achtung HA-Umzug und ZFS-Seiten-Cache (gefunden 12.10.2026 bei der HA-Probe)**
Nach `zfs receive` (Replikation) liefert ein Knoten für Dateien, die dort früher per mmap geöffnet waren, weiter den alten Inhalt aus seinem Seiten-Cache (ZFS 2.4.4, Kernel 7.0.14; Beleg auf pve44: `zfs diff <snap>` leer, `written` 0, md5 der Datei trotzdem ungleich der im Snapshot). Folge beim ersten Umzug nach pve48: dockerd las veraltete bbolt-Datenbanken, brach mit `panic: invalid freelist page` ab, danach fehlten Netzwerk und Volume-Labels. Betroffen ist jeder replizierte LXC mit mmap-Nutzern (Docker, Prometheus-TSDB, später `grp-test`), sobald er auf einen Knoten zurückkehrt, auf dem er schon lief.
- Schutz im Container: Dienst `knaster-drop-stale-cache.service` (Rolle `infra-stack`) verwirft vor containerd/docker per `posix_fadvise(DONTNEED)` den Cache aller Dateien des Containers. Protokoll: `journalctl -u knaster-drop-stale-cache -b` (zeigt geänderte Datenbank-Prüfsummen).
- Eine Lösung auf den Knoten (OpenZFS-Fehler melden bzw. Update, Hookscript) entscheidet der Nutzer.
**Wiederherstellung, falls Docker nach einem Umzug nicht startet** (im LXC, nichts löschen):
1. `systemctl stop docker containerd`; `journalctl -u docker -b | grep -A12 panic` zeigt die betroffene Datenbank (z. B. `buildkit/cache.db`).
2. Die defekte Datenbank bzw. das Verzeichnis beiseiteschieben, z. B. `mv /var/lib/docker/buildkit /var/lib/docker/buildkit.defekt-<datum>`.
3. `systemctl start docker`, dann `docker compose -p knaster-infra --env-file /opt/knaster/infra/.env -f /opt/knaster/repo/deploy/compose/infra/docker-compose.yml up -d --force-recreate` (Volumes bleiben, kein `down -v`).
4. Danach auf ops `ansible-playbook playbooks/monitoring.yml --limit infra`.
## 8. Zugang: Benutzer, Rollen, 2FA, Token (Phase 4.1/4.2)
Stand 13.10.2026, Quelle `sites/knaster/proxmox.yaml` (`access`), ausgerollt mit `playbooks/cluster.yml` (Rolle `pve-cluster`).
| Konto | Zweck | Rechte | Geheimnis liegt |
|---|---|---|---|
| `r.rapp@pve` | persönlicher Admin (Weboberfläche) | Gruppe `admins` = Administrator auf `/` | Passwort `PVE_ADMIN_R_RAPP_PASS` in `it/proxmox/.env`; Passwort, TOTP und Recovery-Keys in Password Depot (Datei `tfa-r.rapp.txt` am 13.10. gelöscht) |
| `root@pam` | Notfallzugang, SSH-Schlüssel, Ansible | alles | Passwörter `PVExx_PASS` in `.env`, Schlüssel `keys/id_ed25519_pve` |
| `tofu@pve!ops` | OpenTofu auf ops | VM-, Storage-, Mapping-, Pool-Rechte | ops `/root/.config/knaster/tofu.env` |
| `monitoring@pve!prom` | pve-exporter | PVEAuditor | ops `/root/.config/knaster/pve-exporter.env`, Kopie in infra |
| `healer@pve!knaster` | Healer (SPEC-healer) | Rolle `KnasterHealer` (`VM.PowerMgmt`, `VM.Audit`) nur auf `/pool/gpu` | ops `/root/.config/knaster/healer.env` |
- **Anmeldung Weboberfläche:** Benutzer `r.rapp`, Realm „Proxmox VE authentication server“, danach TOTP-Code. Die Recovery-Keys gelten je einmal.
- **TOTP:** liegt in Password Depot (Feld „Einmalpasswort / 2FA“). Für neue Konten schreibt `pve-admin-user.sh` eine Datei `tfa-<name>.txt`; Secret und Recovery-Keys daraus in Password Depot übernehmen und die Datei löschen.
- **Anmeldung und Vertretung:** `docs/ANMELDUNG.md` (auch Browser-Falle: Bitwarden-Leiste blockiert den TOTP-Dialog, Inkognito oder Domain ausschließen).
- **Neues Admin-Konto:** Name in `access.admin_users` eintragen, `playbooks/cluster.yml` auf ops, dann vom Laptop `platform/proxmox/scripts/pve-admin-user.sh <name>` (setzt Passwort, TOTP, Recovery-Keys; idempotent).
- **2FA für root@pam** ist bewusst nicht gesetzt: root bleibt Notfallzugang und läuft über SSH-Schlüssel. Wenn gewünscht, gleiches Vorgehen über `pvesh create /access/tfa/root@pam` – vorher den Code in einer App haben, sonst sperrt man sich aus der Weboberfläche aus (SSH bleibt).
- **Token rotieren:** `pveum user token remove <user> <token>`, Datei auf ops löschen, Playbook erneut (Healer) bzw. Token neu anlegen und Datei schreiben (tofu, monitoring).
- Verloren gegangenes TOTP: `pvesh get /access/tfa/<user>` listet die Einträge, `pvesh delete /access/tfa/<user>/<id>` entfernt sie, danach das Skript erneut.
## 9. Benachrichtigungen (Phase 4.3)
Ziel `knaster-mail` (Sendmail an `ki@mygodel.de`, aus `access.mail_to`), Matcher `knaster-alles` ohne Filter (Backup, Fencing, Replikation, Paket-Updates), `default-matcher` abgeschaltet (sonst doppelt). Postfix auf den Knoten liefert direkt an die MX von mygodel.de (13.10. getestet: `250 … Queued mail for delivery`). Kein SMTP-Relay, keine Zugangsdaten.
- Test: `pvesh create /cluster/notifications/targets/knaster-mail/test` auf einem Knoten, Zustellung im Journal: `journalctl -u postfix --since -10min | grep status=`.
- Landet die Mail im Spam: Absender `root@pveXX.buero-godel.local` hat kein SPF. Dann in Exchange Online eine Transportregel oder einen Connector für die Knoten-IPs anlegen.
- Empfänger ändern: `access.mail_to` in der Site-Datei, dann `pvesh set /cluster/notifications/endpoints/sendmail/knaster-mail --mailto <adresse>` (das Playbook legt nur an, es ändert bestehende Ziele nicht).
## 10. Firewall (Phase 4.4)
Datacenter-Firewall aktiv auf allen Knoten (`/etc/pve/firewall/cluster.fw`, Vorlage `roles/pve-cluster/templates/cluster.fw.j2`). Eingehend DROP, ausgehend ACCEPT.
- **IPSET `admin`:** die fünf Knoten, Admin-Laptop `.151` (WLAN) und `.181` (USB-LAN im Dock), appsrv08 `.29`, ops `.173`, infra `.188`. Nur diese erreichen 22, 8006, 3128, 5900–5999 und 60000–60050. Alle anderen Adressen im LAN bekommen auf diesen Ports DROP, Ping aus dem LAN geht. Corosync zwischen den Knoten erlaubt Proxmox selbst.
- **Warum eigene Regeln statt IPSET `management`:** Proxmox ergänzt `management` immer um das lokale Netz (`local_network`, hier das ganze /24). Eine Einschränkung auf einzelne Adressen wirkt deshalb nur mit ACCEPT für `+admin` und anschließendem DROP für das /24 (13.10. geprüft: aus einer GPU-VM sind 22 und 8006 zu, vom Laptop und aus infra offen).
- **DHCP-Risiko (D5):** Seit 14.10. haben ops (.173) und infra (.188) feste Adressen (infra über `containers.infra.ip` in der Site-Datei und tofu, ops per `pct set 100 -net0 …ip=…` auf pve46; DNS .120, Suchdomäne buero-godel.local). Am DC sind sie noch nicht reserviert, der Support soll die Reservierungen nachziehen (Task 0.2). Seit 19.10. hat der Laptop Reservierungen (WLAN .151, Dock .181), ebenso pve44 und pve52. Für infra, pve46 und pve48 ist laut DC derzeit keine Reservierung möglich; sie sind statisch eingestellt, das Restrisiko ist eine doppelt vergebene Adresse. 14.10. passiert: Laptop kam per Dock mit .181 und war ausgesperrt, Rückweg über ops hat funktioniert. Wechselt eine Adresse, ist diese Quelle ausgesperrt. Rückweg: von einer noch zugelassenen Adresse (z. B. `ssh ops`, dann `ssh pve44`) die Adresse in `sites/knaster/proxmox.yaml` ändern und das Playbook laufen lassen, oder direkt `cluster.fw` bearbeiten. Letzter Rückweg: Konsole am Knoten, dort `pve-firewall stop` bzw. `enable: 0` in `cluster.fw`.
- **Totmannschalter:** Das Playbook stellt bei jeder Änderung einen Timer (`knaster-fw-totmann`, 600 s), der `enable: 0` schreibt, und hebt ihn erst nach erfolgreicher Prüfung (SSH und 8006 zu allen Knoten von ops aus) wieder auf. Bricht der Lauf ab, geht die Firewall von selbst wieder aus.
- Neue Verwaltungsquelle (z. B. zweiter Laptop): Eintrag unter `firewall.management`, Playbook laufen lassen.
- Gast-Firewalls (VM/LXC) sind nicht aktiviert; die GPU-VMs schützen ihre Ports mit ufw (Abschnitt 7).
## 11. Zertifikat der Weboberfläche (Phase 4.6)
Die Knotenzertifikate stammen von der Cluster-CA (`/etc/pve/pve-root-ca.pem`, gültig bis 2036) und enthalten die IP-Adresse als SAN. Es reicht, die CA einmal auf dem Laptop als vertrauenswürdig einzutragen, dann sind `https://192.168.100.2xx:8006` für alle fünf Knoten ohne Warnung erreichbar.
- Kopie der CA: `it/proxmox/pve-root-ca.pem` (SHA-256 `A2:71:C0:AB:…:77:3E`, zum Abgleich `openssl x509 -in … -noout -fingerprint -sha256`).
- Import ohne Adminrechte (Benutzerspeicher, Windows fragt einmal nach): `certutil -user -addstore Root C:\Users\r.rapp\it\proxmox\pve-root-ca.pem`. Danach Browser neu starten.
- Kein ACME/Let's Encrypt: die Knoten haben keine öffentlichen Namen (IP-Regel, kein DNS).
## 12. Netzwerk-Prüfung (Stand 13.10.2026)
| Knoten | NIC | Treiber | Link | Bridge |
|---|---|---|---|---|
| pve44, pve46, pve48, pve52 | Intel onboard `nic0` | `e1000e` | 1 GBit/s | `vmbr0` → `nic0`, kein VLAN-aware |
| pve61 | USB-NIC im Dock `enx3ce1a1c0839d` | `r8152` | 1 GBit/s | `vmbr0` → USB-NIC, ein Link-Reset seit Start |
- Keine `e1000e`-Hänger („Detected Hardware Unit Hang“) im Kernel-Log. Sollten sie auftreten: `ethtool -K nic0 tso off gso off` als bekannter Ausweg, dann in `/etc/network/interfaces` festhalten.
- **VLAN:** nicht aktiviert (Global Constraint „vorerst kein VLAN“). Wenn nötig: `bridge-vlan-aware yes` und `bridge-vids 2-4094` auf `vmbr0`, `ifreload -a`; auf pve61 nur vor Ort (USB-NIC).
- Eine zweite NIC für ein getrenntes Corosync- oder Migrationsnetz gibt es auf keinem Knoten. Migration läuft `secure` über das LAN.
- Feste Adressen: Knoten sind statisch, Gäste und Laptop per DHCP (D5 offen, Task 0.2). Erst mit Reservierungen ist die Firewall-Liste dauerhaft stabil.
## 13. Testvorgehen
`platform/proxmox/scripts/healthcheck.sh` (Laptop oder ops, nur lesend) prüft in einem Lauf: Quorum und Stimmen, HA-Dienste, Replikation, Firewall aktiv und Regel `+admin`, Matcher, Benutzer und 2FA; je Knoten Kernel, fehlgeschlagene Dienste, Knoten-Firewall, NTP, `vmbr0`, Platz auf `/`, `tank`, Zertifikatslaufzeit, GPU-Bindung an `vfio-pci`, NIC-Hänger, Weboberfläche; dazu den Negativtest aus einer GPU-VM. Ausgabe `OK`/`FEHLER` je Prüfung, Exit 1 bei Fehlern.
Wann laufen lassen:
1. Nach jedem Playbook-Lauf gegen die Knoten (`site.yml`, `cluster.yml`) und nach jedem Neustart (Abschnitt 6, Schritt 5).
2. Vor einer Änderung an Firewall, Benutzern oder Netzwerk als Vergleichsbasis.
3. Wöchentlich als Routine; Grafana (Abschnitt 7) deckt den Rest kontinuierlich ab.
Manuelle Ergänzungen, die das Skript nicht abdeckt: Anmeldung mit `r.rapp@pve` und TOTP in der Weboberfläche, Testmail (Abschnitt 9), Dock-Test an pve61 (Task 1.3, vor Ort), Wartungsmodus-Probe (Abschnitt 6).
## 14. Kubernetes (k3s) und Portainer (D6, seit 15.10.2026)
Die GPU-VMs bilden mit der Dienst-VM `k3s` einen k3s-Cluster (v1.36.5+k3s1). Steuerung auf `k3s` (VM 110 auf pve48, tank, HA mit Replikation nach pve52 und pve44, 2 Kerne, 3 GB), Agents `gpu-pve44/46/48/52`. Docker bleibt in den VMs für noch nicht umgezogene Compose-Stacks (llama.cpp auf pve48, Exporter).
| Was | Wo |
|---|---|
| Server-Adresse | per DHCP (heute 192.168.100.124, MAC `bc:24:11:a5:d5:52`); Agents nutzen den Namen `k3s.knaster` aus `/etc/hosts` (Rolle k3s-agent). Ändert sich die Adresse: `refresh-vm-access.sh`, dann `playbooks/k3s.yml` |
| Token und kubeconfig | nur auf ops: `/root/.config/knaster/k3s.env`, `/root/.kube/config`; `kubectl` liegt auf ops |
| Manifeste | `deploy/k8s/` (RuntimeClass `nvidia`, NVIDIA Device Plugin v0.20.1, Portainer Agent 2.45.2, `nex-speech/`, `nex-searxng/`, `rustdesk/`) |
| Playbooks | `playbooks/k3s.yml` (Server, Agents, Grundausstattung), `playbooks/nex-speech-k3s.yml`, `playbooks/nex-searxng-k3s.yml`, `playbooks/rustdesk-k3s.yml` (Umzüge), `playbooks/monitoring-k3s.yml` (Pod-Monitoring) |
| Monitoring | kube-state-metrics v2.19.1 in `kube-system` (NodePort 30808), Prometheus auf infra liest kubelet/cAdvisor; Grafana-Dashboard „k3s – Pods“ (Abschnitt 7). `kubectl top` zeigt weiter den metrics-server von k3s |
| GPU in Pods | `runtimeClassName: nvidia` + `resources.limits: {nvidia.com/gpu: 1}` vergibt seit 19.10. nur die M4000 (Device Plugin sieht nur Index 0, allocatable 1/1/1/1). Die P2000 gehört fest llama.cpp (`NVIDIA_VISIBLE_DEVICES=<UUID>`, kein `nvidia.com/gpu`). Knoten-Labels `knaster.gpu=true`, `knaster.gpu.m4000=1`, `knaster.gpu.p2000=1` |
| Portainer | Umgebung „Kubernetes → Agent“ mit Adresse `<IP eines Knotens>:30778`, z. B. `192.168.100.124:30778` (Agent-Pod läuft im Namespace `portainer`) |
- **Zuteilung (seit 15.10.):** jede GPU-VM hat alle 8 Threads und 20 GB ihres Knotens (`vms.*.cores/memory_mb` in der Site-Datei), der Cluster zeigt damit 34 vCPU und 83 GB. Der Rest je Knoten (32 GB): Proxmox ~3 GB, ZFS-ARC 2 GB (`cluster.zfs_arc_max_mb`, Rolle pve-host, sofort wirksam), dazu ops 2 GB (pve46), infra 4 GB (pve44), k3s 3 GB (pve48). RAM der GPU-VMs ist wegen PCI-Passthrough fest reserviert. Ändern: Site-Datei, auf ops `tofu apply` (startet nicht automatisch neu, `reboot_after_update = false`), dann je Knoten `qm reboot <vmid>` nacheinander und `kubectl get node` abwarten. Knapp wird es nur im HA-Fall, wenn infra und k3s gleichzeitig auf denselben Ersatzknoten ziehen.
- **Prüfen:** auf ops `kubectl get nodes` (fünf Knoten Ready), `kubectl get pods -A`, `kubectl -n nex get pods` (nex-stt, nex-tts auf gpu-pve52).
- **NEX-Sprachdienste** laufen seit 15.10. als Deployments im Namespace `nex` mit Host-Netz, Ports 8178/8179 auf der VM-Adresse wie zuvor; ufw der VM gilt weiter (nur appsrv08). Umzugstest 15.10.: TTS → STT Rundlauf liefert den Text exakt, M4000 belegt, Compose-Stack ist angehalten (Rückweg: `kubectl -n nex delete deploy nex-stt nex-tts`, dann in der VM `docker compose -f /opt/nex-speech/docker-compose.yml start`). `playbooks/nex-speech.yml` bricht deshalb ab, außer mit `-e nex_speech_compose=true`.
- **NEX-Websuche (SearXNG)** läuft seit 15.10. zusätzlich in k3s (Namespace `nex`, NodePort 30880 auf jeder Knoten-Adresse, `playbooks/nex-searxng-k3s.yml`, Einstellungen = Kopie aus dem nex-Repo `deploy/nex/searxng/settings.yml`). Der Dienst auf appsrv08 läuft parallel weiter, bis die Hermes-Agenten dort auf `SEARXNG_URL=http://<Knoten-IP>:30880` umgestellt sind (nex-Admin `onboarding`), danach den Dienst `searxng` im Portainer-Stack `nex` entfernen.
- **Portainer CE im Cluster** (seit 15.10., Namespace `portainer-ce`, Pod auf der Dienst-VM k3s, Daten auf deren local-path, `https://192.168.100.124:30779`): Datenbank aus dem Volume `portainer_data` von appsrv08 (Portainer EE 2.45.1) übernommen, mit dem offiziellen Werkzeug `portainer-ee --rollback-to-ce` auf Community Edition zurückgesetzt (laut Portainer-Doku nur nach CE→BE-Upgrade möglich, hat bei der frischen BE-Installation trotzdem funktioniert; CE verweigert eine BE-Datenbank sonst mit Hinweis). Benutzer, Passwörter, API-Schlüssel und Umgebungen sind erhalten, der Schlüssel aus `it/.hermes.env` funktioniert unverändert. Sicherung `/root/.config/knaster/portainer_data.tgz` auf ops (15.10., 2,5 MB). **Umgebungen:** Nr. 4 „godel-local“ war der Docker-Socket von appsrv08 und ist im Cluster tot; auf appsrv08 läuft dafür `portainer_agent` (2.45.1, nur `192.168.100.29:9001`), den ein Admin als neue Umgebung „appsrv08“ (Agent) anlegt und für `godel-deployer` freigibt. Die Kubernetes-Umgebung (Nr. 5, Agent `:30778`) bleibt. **Noch auf appsrv08:** Portainer EE (`:9443`) läuft parallel weiter, nginx leitet `portainer.mygodel.de` dorthin; Umstellung = `proxy_pass` in `/var/docker/nginx/etc/nginx/conf.d/portainer.conf` auf `https://192.168.100.124:30779`, `nginx -t`, Reload (Runbook `IT Verbesserungen/GRP_Linux/Portainer-DNS-Runbook.md`), dann EE stoppen. Für nex-Admin und `.hermes.env` (URL `:9443`, Endpoint 4) zusätzlich Weiterleitung `:9443` → Cluster oder Werte anpassen. Lizenz: CE hat keine Knotengrenze.
- **Agenten im Cluster (NEX)** (seit 19.10., nex-Spec `docs/specs/2026-10-19-nex-cluster-agenten-design.md` im nex-Repo): Neue Hermes-Agenten entstehen als Deployment im Namespace `nex`, angelegt von nex-admin auf appsrv08 über die Kubernetes-API (`playbooks/nex-k3s.yml`: ServiceAccount `nex-admin` mit Role nur für diesen Namespace, Token auf ops `/root/.config/knaster/nex-admin-k8s.token`, Pull-Secret `gitea-registry` aus `/root/.config/knaster/gitea-registry.env`). Die Pods erreichen die NEX-Dienste auf appsrv08 über Services ohne Selector (`deploy/k8s/nex/bridge.yaml`: nex-router 4100, nex-know 14200 (Host-Port, Service-Port 4200), nex-access 4300, grp-mcp-intern 8790 → `192.168.100.29`, dort als Host-Ports nur auf dieser Adresse veröffentlicht). Nutzeradressen bleiben `https://192.168.100.29:<port>/`: Traefik auf appsrv08 leitet an den NodePort `<port>+20000` weiter. Agenten-Volumes liegen auf `local-path` (an den ersten Knoten gebunden).
- **RustDesk-Server** (ID-Server hbbs + Relay hbbr) läuft seit 15.10. zusätzlich in k3s (Namespace `rustdesk`, ein Pod auf einem GPU-Knoten, Datenbank auf `local-path`, Service-Typ LoadBalancer: Ports 21115–21119/tcp und 21116/udp auf **jeder** Knoten-Adresse). Das Schlüsselpaar ist dasselbe wie auf appsrv08 (Kopie auf ops `/root/.config/knaster/rustdesk/`, im Cluster als Secret `rustdesk-keys`), der Public Key der Clients `BPhsgjAI8yicpNRA3YsnBzfx78JUuNglrYZSZANVCUY=` bleibt gültig. hbbs nennt den Clients als Relay die Adresse `rustdesk_public_host` (Standard: k3s-VM, Variable im Playbook). **Umstellung der Clients steht aus:** sie tragen heute `192.168.100.29` als ID-Server; nötig sind eine feste Adresse (DHCP-Reservierung der k3s-VM oder DNS-Name am DC) und danach die neue Adresse in den installierten Clients sowie im Installer-Namen des Portainer-Stacks `rustdesk-updater` (`SERVER_HOST`). Bis dahin läuft der Stack `rustdesk` auf appsrv08 weiter.
- **llama.cpp** (Task 6.1, seit 19.10.) läuft in k3s, Namespace `knaster`, ein Deployment `llamacpp-<vm>` je GPU-VM: Embedding 8081 + Rerank 8082 auf gpu-pve44/46 (zwei Container, eine P2000), Chat 8080 auf gpu-pve48. Host-Netz, Ports und Prometheus-Ziele wie zuvor im Compose. Ausrollen: `playbooks/gpu-llamacpp-k3s.yml` (prüft, dass Index 0 die M4000 ist, wendet das Device Plugin an, dann VM für VM). Rückweg je VM: `kubectl -n knaster delete deploy llamacpp-<vm>`, in der VM `docker compose --project-directory /opt/knaster/gpu-llamacpp start`; `playbooks/gpu-llamacpp.yml` nur mit `-e llamacpp_compose=true`. Embedding mit `-ub 4096`, weil `-ub 8192` 5 GB Rechenpuffer bräuchte.
- **Images** aus Compose-Builds landen per `docker save | k3s ctr images import -` im containerd von k3s (Rolle nex-speech-k3s); kein Registry nötig. Für neue Dienste ohne Build reicht `image:` aus Docker Hub oder nvcr.io.
- **ufw in den VMs:** die Rolle k3s-agent erlaubt 10250/tcp, 8472/udp, 30000–32767/tcp aus dem LAN und alles aus dem Pod-Netz 10.42.0.0/16.
- **Aktualisieren:** `k3s_version` in `roles/k3s-server/defaults` und `roles/k3s-agent/defaults` anheben, `playbooks/k3s.yml`; erst Server, dann Agents (so läuft das Playbook). Kubernetes-Versionen immer nur eine Minor-Stufe je Schritt.
- **Stromausfall:** VM 110 startet automatisch (onboot, HA). Agents verbinden sich neu, Pods starten mit den Knoten. Sollte `k3s` auf einen anderen Knoten umziehen, bleibt die Adresse (gleiche MAC), sobald die DHCP-Reservierung am DC steht.
## 15. Verwaltung übergeben oder teilen (seit 19.10.2026, „Weg 1“)
Die Verwaltung hängt an Personen, nicht an einem Gerät. Einstieg für jede Person ist **ops** (Bastion): eigener
SSH-Schlüssel dort, von ops aus laufen `kubectl`, Ansible und tofu und erreichen alle Knoten und Gäste. Ein Agent
(Claude Code o. Ä.) arbeitet auf dem Gerät der Person im Repo und nach [AGENTS.md](../AGENTS.md).
**Neue Person aufnehmen**
1. Die Person erzeugt auf ihrem Gerät einen Schlüssel (`ssh-keygen -t ed25519 -C <name>-<gerät>-<datum>`) und gibt
nur den öffentlichen Teil (`.pub`) weiter.
2. Eintrag in `sites/knaster/proxmox.yaml` unter `access.ops_ssh_keys` (`{name, key}`), committen, auf ops
`git pull` und `ansible-playbook playbooks/admins.yml` (zeigt danach die Fingerabdrücke).
3. Weboberfläche: Name in `access.admin_users`, `playbooks/cluster.yml`, dann `scripts/pve-admin-user.sh <name>`
(Passwort, TOTP, Recovery-Keys; Ergebnis in Password Depot). Geräteadresse(n) in `firewall.management`, erneut
`playbooks/cluster.yml` (Totmannschalter, Abschnitt 10).
4. Repo: Schreibzugang in Gitea (`gitea.mindboost.team/rorapp/cluster-knaster`) für die Person.
5. SSH-Konfiguration auf dem Gerät:
```
Host ops
HostName 192.168.100.173
User root
IdentityFile ~/.ssh/<schlüssel>
IdentitiesOnly yes
```
Alles Weitere läuft über ops (`ssh ops`, dann `ssh pve44`, `kubectl …`). Prüfen: `ssh ops kubectl get nodes`.
6. Zugänge, die nicht im Repo stehen, über Password Depot übergeben (Proxmox-root-Notfall, Grafana-Admin aus
`/opt/knaster/infra/.env`).
**Person oder Gerät entfernen**
1. Eintrag von `access.ops_ssh_keys` nach `access.ops_ssh_keys_revoked` verschieben, `playbooks/admins.yml`.
2. Name aus `access.admin_users` streichen und das Konto in Proxmox löschen (`pveum user delete <name>@pve`; das
Playbook legt nur an), Adresse aus `firewall.management`, `playbooks/cluster.yml`.
3. Gitea-Zugang entziehen. Hatte die Person Zugang zu Geheimnissen (Password Depot, ops), die betroffenen Token
rotieren (Abschnitt 8).
**Was ops nicht ersetzt:** Den Schlüssel des Admin-Laptops (`rrapp-laptop-2026-10-08`) gibt es zusätzlich direkt auf
den Knoten (`/etc/pve/priv/authorized_keys`), in infra und in den VMs. Neue Personen brauchen das nicht; bei einer
vollständigen Übergabe dort ebenfalls entfernen.
**Notfall (bisherige Person nicht erreichbar):** `docs/NOTFALL-ADMIN-UEBERGABE.md` (Unterlage für die Geschäftsführung, ohne Passwörter): Firewall-Freigabe vor Ort an pve61, Schlüssel über die Proxmox-Shell von pve46 (`pct enter 100`) auf ops eintragen, danach wie oben dauerhaft machen.
**Später („Weg 2“, Phase 9):** Verwaltung über nex-admin mit freigegebenen Aktionen auf ops statt persönlicher
Shell, siehe Aufbauplan.
+4 -2
View File
@@ -18,6 +18,7 @@ Vorgehen pro Baustein: **Spec → Implementierung → Tests → Commit**. Bis zu
| 10 | Plattform | ADR-0001/0002 | OpenTofu (bpg/proxmox), Ansible-Rollen (IOMMU/VFIO, ZFS-Replikation, Treiber-Pinning, Xid-Exporter) | ✅ geschrieben, **nicht** gegen echten Cluster/`tofu validate` geprüft |
| 11 | CI | – | Gitea Actions: Tests, Lint, Konfig-Validierung (Runner `gitrunner-knaster`) | ✅ |
| 12 | TTS + Legacy-GPUs | ADR-0006 | Engine-Grenzen im Katalog, deaktivierte GPUs, geteilte GPU-Rollen, TTS-Slots, Piper-Dienst, TTS-Canary, `knaster bench tts` | ✅ |
| 13 | Ontologie der Datenhaltung | SPEC-ontology | `ontology/know.yaml` als Quelle, Export (Turtle/SKOS, Graph, Ansicht), Abgleich mit n8n/KNOW (nur lesend), Vorschläge `ontology_change` in der Service-Routine, `knaster ontology apply` | ✅ gegen echten n8n-Export geprüft (24 Befunde, 4 Vokabel-Vorschläge) |
## Teststrategie
@@ -30,16 +31,17 @@ Vorgehen pro Baustein: **Spec → Implementierung → Tests → Commit**. Bis zu
| Was | Wie geprüft |
|---|---|
| Kernlogik (Router, Eskalation, Verifier, Pflege-Agent, Healer, Lifecycle, Placement) | 136 Unit- und Integrationstests (`make test`) |
| Kernlogik (Router, Eskalation, Verifier, Pflege-Agent, Healer, Lifecycle, Placement) | 152 Unit- und Integrationstests (`make test`) |
| Konfiguration (Registry ↔ Skills ↔ LiteLLM ↔ Healer ↔ Policy ↔ Katalog) | `knaster validate` |
| Compose-Dateien | `docker compose config` (`make compose-check`) |
| Prometheus/Alertmanager/n8n-JSON | Strukturtests (`core/tests/test_artifacts.py`) |
| n8n, Portainer | nur lesender Erreichbarkeitstest, nichts deployt |
| Ontologie ↔ n8n | `knaster ontology check` gegen einen lesend abgerufenen Export aller Workflows (13.10.); Tests mit synthetischen Workflows |
| OpenTofu, Ansible | nur YAML/Syntax. `tofu validate` und `ansible-lint` stehen noch aus |
## Offene Punkte vor dem Deployment
- Image-Tags pinnen: LiteLLM (`main-stable`) und pve-exporter (`latest`). vLLM-, llama.cpp- und Treiber-Versionen gegen den aktuellen Stand prüfen.
- ~~Image-Tags pinnen: LiteLLM (`main-stable`) und pve-exporter (`latest`).~~ 12.10.2026: Infra-Stack vollständig gepinnt, LiteLLM entfällt (D4). Offen: vLLM-, llama.cpp- und Treiber-Versionen gegen den aktuellen Stand prüfen.
- LiteLLM-Wildcard-Routen (`tier1-general/*`) gegen die eingesetzte Version testen.
- Den n8n Chat-Trigger nach dem Import prüfen (Parameter der Knotenversion).
- `sites/knaster/*`: Hosts, VM-IDs, IPs und PCI-Adressen sind Annahmen und werden durch den Inventarlauf ersetzt.
+152
View File
@@ -0,0 +1,152 @@
# Notfall: Verwaltung des Clusters „knaster“ an eine andere Person übergeben
Stand 19.10.2026. Für die Geschäftsführung als Notfallunterlage. **In diesem Dokument stehen keine Passwörter.**
Alle Geheimnisse liegen in Password Depot; hier steht nur, wo sie liegen und was man damit tut.
Technische Einzelheiten für die übernehmende Person: `docs/BETRIEB.md` Abschnitt 15, `AGENTS.md`, `project-map.md`
im Repository `cluster-knaster`.
---
## 1. Worum es geht
Der Cluster „knaster“ (fünf Proxmox-Server mit KI-Diensten) wird von einer Person verwaltet, heute **Robert Rapp**.
Fällt diese Person aus (Krankheit, Austritt, nicht erreichbar), muss eine andere Person die Verwaltung übernehmen
können. Dafür braucht sie vier Zugänge:
| Zugang | Wofür | Woher im Notfall |
|---|---|---|
| **Proxmox-Konto mit TOTP** (Einmalcode) | Weboberfläche der Server: Zustand, Neustart, Konsole | Notfall: `root`-Passwort aus Password Depot; danach eigenes Konto |
| **Freigabe der Geräteadresse in der Firewall** | Ohne Freigabe ist die Weboberfläche vom eigenen Rechner aus gesperrt | vor Ort am Server pve61 (Abschnitt 4, Schritt 2) |
| **SSH-Schlüssel auf dem Steuerrechner „ops“** | Alle Verwaltungsarbeiten (Kubernetes, Ansible, Updates) | über die Proxmox-Weboberfläche eintragen (Schritt 4) |
| **Gitea-Zugang** zum Repository `cluster-knaster` | Konfiguration ändern und dauerhaft festhalten | Gitea-Administrator (siehe unten) |
## 2. Was vorher bereitliegen muss (einmal prüfen, dann jährlich)
- [ ] Die Geschäftsführung (oder eine benannte Vertretung) hat Zugriff auf Password Depot mit den Einträgen
**„Proxmox knaster – root (Notfall)“** (Passwörter `PVE44_PASS` … `PVE61_PASS`) und
**„Proxmox knaster – r.rapp@pve“** (Passwort, Einmalcode, Recovery-Keys).
- [ ] Bekannt ist, wo der Server **pve61** steht (Microsoft Surface mit eigenem Bildschirm, im Dock am Netzwerk):
Standort: ______________________________
- [ ] Gitea-Administrator für `gitea.mindboost.team` (kann Personen Zugang zum Repository geben):
Name / Kontakt: ______________________________
- [ ] Benannte übernehmende Person(en) mit Grundkenntnissen in Linux und SSH:
______________________________
## 3. Geplante Übergabe (die bisherige Person ist noch erreichbar)
Dauer etwa 30–60 Minuten, beide gemeinsam. Die bisherige Person führt die neue nach `docs/BETRIEB.md` Abschnitt 15
durch: Schlüssel eintragen, Proxmox-Konto mit TOTP anlegen, Geräteadresse freigeben, Gitea-Zugang. Danach ist
Abschnitt 4 nicht nötig. Das ausgefüllte Übergabeblatt (Abschnitt 6) zur Notfallunterlage legen.
## 4. Notfall: die bisherige Person ist nicht erreichbar
Die übernehmende Person (im Folgenden **B**) braucht: einen eigenen Laptop im Büronetz, Zugriff auf Password Depot
(über die Geschäftsführung) und einmal Zugang zum Server pve61 vor Ort.
**Schritt 1 – Laptop vorbereiten (B, 5 Minuten).** Unter Windows in „Git Bash“ (oder Linux/macOS im Terminal):
```bash
ssh-keygen -t ed25519 -C "<name>-<geraet>-<datum>" # z. B. m.muster-laptop-2026-11-03, Enter für Standardpfad
cat ~/.ssh/id_ed25519.pub # öffentlicher Schlüssel, eine Zeile: ssh-ed25519 AAAA… <name>
ipconfig # eigene IPv4-Adresse im Netz 192.168.100.x notieren
```
Nur der **öffentliche** Schlüssel (`.pub`) wird weitergegeben, nie die Datei ohne Endung.
**Schritt 2 – Adresse von B in der Firewall freigeben (vor Ort an pve61, 5 Minuten).**
1. Am Surface anmelden: Benutzer `root`, Passwort `PVE61_PASS` aus Password Depot.
2. Datei öffnen: `nano /etc/pve/firewall/cluster.fw`
3. Im Block `[IPSET admin]` eine Zeile ergänzen, z. B. `192.168.100.57 # Notfall m.muster 2026-11-03`
4. Speichern (`Strg+O`, Enter, `Strg+X`). Die Änderung gilt nach wenigen Sekunden auf allen Servern.
Diese Freigabe ist vorläufig: Der nächste Lauf von `playbooks/cluster.yml` schreibt die Datei aus dem Repository neu.
Darum in Schritt 5 dauerhaft eintragen.
**Schritt 3 – Weboberfläche (B, am eigenen Laptop).** Browser: `https://192.168.100.210:8006`
(Zertifikatswarnung bestätigen). Benutzer `root`, Realm **„Linux PAM standard authentication“**, Passwort
`PVE44_PASS` aus Password Depot.
**Schritt 4 – Schlüssel von B auf dem Steuerrechner ops eintragen.**
1. Links im Baum den Server **pve46** wählen, oben **„Shell“**.
2. In der Shell:
```bash
pct enter 100
echo "ssh-ed25519 AAAA… <öffentlicher Schlüssel von B, ganze Zeile>" >> /root/.ssh/authorized_keys
exit
```
3. Am Laptop von B prüfen: `ssh root@192.168.100.173 kubectl get nodes` → fünf Zeilen mit `Ready`.
Ab hier kann B den Cluster verwalten. Alle weiteren Arbeiten laufen über ops (siehe `AGENTS.md`).
**Schritt 5 – Dauerhaft machen (B, mit Gitea-Zugang).**
1. Gitea-Administrator gibt B Schreibzugang zu `gitea.mindboost.team/rorapp/cluster-knaster`; Repository klonen.
2. In `sites/knaster/proxmox.yaml` eintragen (Beispiel in Abschnitt 6):
Schlüssel unter `access.ops_ssh_keys`, Name unter `access.admin_users`, Adresse unter `firewall.management`.
3. Committen und pushen, dann auf ops:
```bash
cd /root/cluster-knaster && git pull
cd platform/proxmox/ansible
ansible-playbook playbooks/admins.yml
ansible-playbook playbooks/cluster.yml
```
4. Eigenes Proxmox-Konto mit TOTP: vom Laptop `platform/proxmox/scripts/pve-admin-user.sh <name>`
(setzt Passwort, Einmalcode und Recovery-Keys; die erzeugte Datei `tfa-<name>.txt` in Password Depot übernehmen
und löschen). Ab dann nicht mehr als `root` anmelden.
**Schritt 6 – Bisherige Person austragen (nur bei Austritt).**
Nach `docs/BETRIEB.md` Abschnitt 15 „Person oder Gerät entfernen“: Schlüssel nach `ops_ssh_keys_revoked`, Konto
`r.rapp@pve` löschen, alte Laptop-Adressen aus `firewall.management`, Gitea-Zugang entziehen. Zusätzlich die
root-Passwörter aller Server ändern (Password Depot aktualisieren) und die Token in `/root/.config/knaster/` auf ops
erneuern (BETRIEB Abschnitt 8). Der Laptop-Schlüssel der bisherigen Person liegt auch direkt auf den Servern
(`/etc/pve/priv/authorized_keys`), in infra und in den VMs und muss dort ebenfalls entfernt werden.
## 5. Was man im Notfall nicht tun sollte
- Die Firewall **nicht** ganz abschalten, solange es nicht anders geht (`pve-firewall stop` nur als letzter Ausweg,
danach sofort wieder `pve-firewall start`).
- Keine Server neu installieren oder Festplatten anfassen; Daten und KI-Dienste laufen weiter, auch ohne Verwaltung.
- Keine Passwörter oder privaten Schlüssel per Mail oder Chat verschicken.
- Server nur einzeln und nacheinander neu starten (`docs/BETRIEB.md` Abschnitt 6).
## 6. Beispiel: ausgefülltes Übergabeblatt (fiktive Person)
| Punkt | Beispiel | Wo es festgehalten wird |
|---|---|---|
| Person | Max Muster, IT, max.muster@… | dieses Blatt |
| Proxmox-Konto | `m.muster@pve`, Gruppe `admins`, TOTP eingerichtet am 03.11.2026 | Passwort, Einmalcode, Recovery-Keys in Password Depot, Eintrag „Proxmox knaster – m.muster@pve“ |
| Geräte und Adressen | Laptop WLAN `192.168.100.57`, Dock `192.168.100.58` (beim DC reservieren lassen) | `sites/knaster/proxmox.yaml` → `firewall.management` |
| SSH-Schlüssel | `ssh-ed25519 AAAAC3Nz…Xy m.muster-laptop-2026-11-03` | `sites/knaster/proxmox.yaml` → `access.ops_ssh_keys` |
| Gitea | Benutzer `mmuster`, Schreibzugang zu `rorapp/cluster-knaster`, erteilt von ________ | Gitea |
| Prüfung | `ssh ops kubectl get nodes` → 5× Ready; Anmeldung Weboberfläche mit TOTP ok | Datum, Unterschrift: ________ |
Eintrag in `sites/knaster/proxmox.yaml` (Ausschnitt):
```yaml
access:
admin_users: [r.rapp, m.muster]
ops_ssh_keys:
- {name: r.rapp-laptop, key: "ssh-ed25519 AAAAC3Nz…QcoJ rrapp-laptop-2026-10-08"}
- {name: m.muster-laptop, key: "ssh-ed25519 AAAAC3Nz…Xy m.muster-laptop-2026-11-03"}
ops_ssh_keys_revoked: []
firewall:
management:
- {ip: 192.168.100.57, comment: Laptop m.muster WLAN}
- {ip: 192.168.100.58, comment: Laptop m.muster Dock}
```
SSH-Konfiguration auf dem Laptop von B (`~/.ssh/config`):
```
Host ops
HostName 192.168.100.173
User root
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
```
Danach genügt `ssh ops`. Von ops aus erreicht man alle Server und Gäste (`ssh pve44`, `kubectl …`, `ansible-playbook …`).
## 7. Prüfliste nach der Übernahme
- [ ] `ssh ops kubectl get nodes` zeigt fünf Knoten `Ready`.
- [ ] Weboberfläche mit eigenem Konto und Einmalcode, Übersicht ohne rote Server.
- [ ] Grafana `http://192.168.100.188:3000` öffnet (Passwort `admin` steht in `/opt/knaster/infra/.env` auf infra).
- [ ] Proxmox-Benachrichtigungen kommen an (Mail an `ki@mygodel.de`; Empfänger bei Bedarf in
`access.mail_to` ändern).
- [ ] Übergabeblatt ausgefüllt und zur Notfallunterlage gelegt; Password Depot aktualisiert.
+41 -16
View File
@@ -47,30 +47,45 @@ Ziel: Ein starkes LLM (OpenRouter) erarbeitet neue Aufgaben einmalig und bricht
### 1.3 Rollenverteilung (aktuelle Hardware)
| Rolle | Hardware | Engine | Aufgabe |
Stand 12.10.2026, per SSH geprüft. Proxmox-Cluster `knaster` mit fünf Knoten:
| Knoten | IP | Hardware (Kerne/Threads, RAM) | GPUs |
|---|---|---|---|
| `gpu-main` | RTX 4000 Ada #1 (20 GB) | vLLM, Multi-LoRA | Slot `tier1-general` plus Skill-Adapter |
| `gpu-flex` | RTX 4000 Ada #2 (20 GB) | vLLM / Unsloth | tagsüber zweiter Slot oder Reserve, nachts LoRA-Training |
| `gpu-embed` | 2× Quadro P2000 (5 GB) | llama.cpp (Embedding/Rerank) | `embed-default`, `rerank-default`, redundant auf zwei Hosts |
| `gpu-small` | Quadro P2000 (5 GB) | llama.cpp | `verifier-small`, `signature-extractor` (3–4B) |
| – | 4× Quadro M4000 (8 GB) | – | **vorerst außer Betrieb** (ADR-0006), nur nach Benchmark für llama.cpp-Rollen |
| `tts-hq` | teilt RTX 4000 Ada #2 | PyTorch | `tts-de-hq`: Qwen3-TTS 1.7B (ADR-0006) |
| `tts-cpu` | Infra-LXC (CPU) | Piper (ONNX) | `tts-de`: schnelle Sprachausgabe, Fallback |
| `infra` | i7 6./7. Gen, 32 GB | Docker | n8n, LiteLLM, Postgres+pgvector, Redis, Router, Healer, Langfuse, Prometheus/Grafana |
| `strong` | extern | OpenRouter | `tier0-strong`, Skill-Autor (Hermes) |
| pve44 | 192.168.100.210 | ThinkStation P310, i7-6700 (4/8), 32 GB | Quadro M4000 (8 GB) + Quadro P2000 (5 GB) |
| pve46 | 192.168.100.209 | ThinkStation P310, i7-6700 (4/8), 32 GB | M4000 + P2000 |
| pve48 | 192.168.100.212 | ThinkStation P310, i7-6700 (4/8), 32 GB | M4000 + P2000 |
| pve52 | 192.168.100.207 | ThinkStation P320, i7-7700 (4/8), 32 GB | nur M4000 |
| pve61 | 192.168.100.214 | Surface Book, i5-8350U (4/8), 8 GB | keine NVIDIA (Intel UHD 620, Kiosk) |
Jeder GPU-Knoten hat **eine** GPU-VM mit allen Karten (`gpu-pve44`, `gpu-pve46`, `gpu-pve48`, `gpu-pve52`, ADR-0001 Nachtrag). Die beiden RTX 4000 Ada (20 GB) stecken nicht im Cluster, sondern je eine in den Windows-Arbeitsplätzen GODE-PC-05 und GODE-PC-06 (64 GB RAM, ADR-0006 Nachtrag). Die Zuordnung unten ist die Ausgabe von `knaster placement` mit `sites/knaster/inventory.yaml`.
| Rolle | Hardware | Ort | Engine | Aufgabe |
|---|---|---|---|---|
| `gpu-main` | RTX 4000 Ada (20 GB) | gode-pc-05 (Windows) | vLLM, Multi-LoRA; unter Windows vorerst llama-server (D3, vorgeschlagen) | Slot `tier1-general` plus Skill-Adapter |
| `gpu-flex` | RTX 4000 Ada (20 GB) | gode-pc-06 (Windows) | vLLM / Unsloth; unter Windows vorerst llama-server (D3, vorgeschlagen) | tagsüber zweiter Slot oder Reserve, nachts LoRA-Training |
| `gpu-embed` | 2× Quadro P2000 (5 GB) | `gpu-pve44`, `gpu-pve46` | llama.cpp (Embedding/Rerank) | `embed-default`, `rerank-default`, redundant auf zwei Knoten |
| `gpu-small` | Quadro P2000 (5 GB) | `gpu-pve48` | llama.cpp | `verifier-small`, `signature-extractor` (3–4B) |
| – | 4× Quadro M4000 (8 GB) | je eine in den vier GPU-VMs | – | **vorerst außer Betrieb** (ADR-0006), durchgereicht, aber `disabled`; nur nach Benchmark für llama.cpp-Rollen |
| `tts-hq` | teilt die Ada von `gpu-flex` | gode-pc-06 | PyTorch | `tts-de-hq`: Qwen3-TTS 1.7B (ADR-0006), unter Windows erst nach der WSL2-Entscheidung |
| `tts-cpu` | Infra-LXC (CPU) | `infra` | Piper (ONNX) | `tts-de`: schnelle Sprachausgabe, Fallback |
| `infra` | LXC, 4 Kerne, 8 GB | pve44 in `tank`, HA nach pve52/pve48 | Docker | n8n, Postgres+pgvector, Redis, Router, Healer, Langfuse, Prometheus/Grafana; LiteLLM auf appsrv08 (D4, vorgeschlagen) |
| `strong` | extern | – | OpenRouter | `tier0-strong` über den LiteLLM mit PSE auf appsrv08, Skill-Autor (Hermes) |
Das Placement schlägt für `infra` pve52 vor, weil es CPU-Rollen auf Knoten ohne aktive GPU-Rolle legt. Festgelegt ist pve44 (`sites/knaster/proxmox.yaml`), pve52 ist Replikations- und HA-Ziel.
Wichtig: Auf Maxwell und Pascal läuft kein vLLM (das braucht Compute Capability ≥ 7.0), und CUDA 13 unterstützt sie nicht mehr. Daher auf diesen Nodes Treiber und Images pinnen. Auch PyTorch hat dort keine Zukunft mehr (2.14 ist die letzte Version mit CUDA-12-Wheels). Deshalb laufen auf alten Karten nur llama.cpp-Workloads (ADR-0006).
### 1.4 Netzwerk und Storage (entschieden, siehe ADR-0002)
Ausgangslage: **1 GbE** zwischen den Nodes, pro physischer Maschine **2–3 SSDs mit 128–256 GB**.
Ausgangslage: **1 GbE** zwischen den Knoten, flaches Netz `192.168.100.0/24` (Gateway `.1`, DNS `.120`, kein VLAN). Pro Knoten **eine oder zwei SSDs mit 256 GB**, pve48 zusätzlich eine leere 128-GB-SSD (Tabelle in 1.3).
- **Kein Ceph.** Ceph braucht für brauchbare Latenz und Rebuild-Zeiten mindestens 10 GbE und mehr Platz pro OSD. Mit 1 GbE und kleinen SSDs würde jeder Schreibvorgang über das Netz gebremst und ein Rebuild das Netz tagelang belegen.
- **ZFS lokal pro Node.** SSD 1 trägt das Proxmox-System. SSD 2 und 3 bilden, wo vorhanden, einen ZFS-Mirror `tank` für VM-Disks. Auf Nodes mit nur zwei SSDs ist `tank` eine Einzel-SSD, die über Replikation abgesichert wird.
- **ZFS-Replikation** (Proxmox-Job, alle 15 Min.) für die Infra-VMs auf einen zweiten Node. Zusammen mit Proxmox HA startet die VM nach einem Node-Ausfall dort mit maximal 15 Min. altem Stand. Für Postgres wird das zusätzlich durch PBS-Backups und WAL-Archivierung abgefedert.
- **Modelle liegen lokal** auf der GPU-VM, in einem eigenen Disk-Image (`models`, nicht repliziert, nicht gesichert). Quelle ist Hugging Face oder ein optionaler **Modell-Mirror** auf einem Infra-Node (HTTP/NFS, nur lesend). 10 GB über 1 GbE dauern etwa 90 s, das ist für Modellwechsel unkritisch.
- **System auf ext4/LVM-thin, ZFS auf der zweiten SSD** (D1 = Variante B, ADR-0002 Nachtrag). SSD 1 trägt das Proxmox-System mit `local` und `local-lvm`. Die zweite SSD ist auf pve44, pve48 und pve52 der ZFS-Pool `tank` (Einzel-SSD), abgesichert über Replikation. pve46 und pve61 haben nur eine Platte und bleiben ohne ZFS.
- **ZFS-Replikation** (Proxmox-Job, alle 15 Min.) nur zwischen pve44, pve48 und pve52: `infra` liegt auf pve44 in `tank` und wird nach pve52 und pve48 repliziert. Zusammen mit Proxmox HA startet der LXC nach einem Knotenausfall dort mit maximal 15 Min. altem Stand. Für Postgres wird das zusätzlich durch PBS-Backups und WAL-Archivierung abgefedert.
- **Modelle liegen lokal** auf der GPU-VM (auf `local-lvm`), in einem eigenen Disk-Image (`models`, nicht repliziert, nicht gesichert). Quelle ist Hugging Face oder ein optionaler **Modell-Mirror** auf einem Infra-Node (HTTP/NFS, nur lesend). 10 GB über 1 GbE dauern etwa 90 s, das ist für Modellwechsel unkritisch.
- **Proxmox Backup Server** als VM oder LXC auf dem Node mit dem meisten freien SSD-Platz, oder besser auf einer externen Platte bzw. einem NAS. Gesichert werden Postgres, Konfiguration und Datensätze, keine Modellgewichte.
- **Netztrennung per VLAN** (Proxmox SDN): `mgmt` (Proxmox, PBS), `svc` (Dienste untereinander), `ext` (Zugriff von außen auf n8n und Router). Ohne Bandbreitengewinn, aber Replikation und Backups lassen sich zeitlich steuern, damit sie den Inferenz-Traffic nicht stören.
- **IP-Adressen:** Die Knoten behalten .207–.214. Gäste bekommen vorerst DHCP-Adressen (D5 zurückgestellt); feste Gast-IPs müssen vor Phase 6 stehen, weil STT und die LiteLLM-Slots IP-Literale nutzen.
- **Netztrennung per VLAN** (Proxmox SDN) ist zurückgestellt, bis der Switch das zulässt: `mgmt` (Proxmox, PBS), `svc` (Dienste untereinander), `ext` (Zugriff von außen auf n8n und Router). Ohne Bandbreitengewinn, aber Replikation und Backups ließen sich zeitlich steuern, damit sie den Inferenz-Traffic nicht stören.
**Speicherbudget pro Rolle (Richtwerte):**
@@ -80,7 +95,7 @@ Ausgangslage: **1 GbE** zwischen den Nodes, pro physischer Maschine **2–3 SSDs
| `gpu-embed` / `gpu-small` | 15–25 GB | Embedding-, Rerank- und kleine GGUF-Modelle (je 0,5–3 GB), Images |
| `infra` | 40–80 GB | Postgres (Fälle, Traces), Prometheus (15 Tage Retention), Langfuse |
Die genaue SSD-Verteilung wird beim Inventarlauf erfasst (`sites/knaster/inventory.yaml`).
`gpu-main` und `gpu-flex` liegen auf den Windows-Rechnern. Eine GPU-VM hat 40 GB Systemplatte und 60 GB Modellplatte auf `local-lvm` (141 GB je Knoten), `infra` 60 GB in `tank` (238 GB). Die SSD-Verteilung steht in `sites/knaster/inventory.yaml`, die Gäste in `sites/knaster/proxmox.yaml`.
---
@@ -303,6 +318,16 @@ Die Portabilität beruht also auf den **Daten und der Pipeline**, nicht auf Gewi
- Infrastruktur heilt deterministisch, ohne LLM-Entscheidungen (ADR-0005)
- Alte GPUs nur llama.cpp, M4000 vorerst aus, TTS mit Piper (CPU) und Qwen3-TTS (Ada) (ADR-0006)
### Entscheidungen 2026-10
Aus dem [Aufbauplan](superpowers/plans/2026-10-12-aufbau-cluster-knaster.md), Abschnitt „Entscheidungen“:
- **D1 Speicher – umgesetzt (12.10.):** Variante B ohne Neuinstallation. System-SSD ext4/LVM-thin mit `local-lvm`, zweite SSD als ZFS-Pool `tank` auf pve44, pve48 und pve52. Replikation und HA nur zwischen diesen drei, pve46 und pve61 ohne ZFS (ADR-0002, Nachtrag).
- **D2 GPU als VM – entschieden (12.10.):** eine Ubuntu-24.04-VM pro GPU-Knoten mit allen Karten des Knotens (`gpu-pve44` 244, `gpu-pve46` 246, `gpu-pve48` 248, `gpu-pve52` 252), darin ein Docker-Container pro Rolle (ADR-0001, Nachtrag).
- **D3 Ada-Rechner – vorgeschlagen, Bestätigung offen:** GODE-PC-05/06 bleiben Windows und stellen ihre Leistung über die Worker-Registry v2 bereit (ADR-0006, Nachtrag; Umsetzung in Task 6.4).
- **D4 LiteLLM – vorgeschlagen, Bestätigung offen:** kein eigener LiteLLM im knaster-Infra. Der Router nutzt den LiteLLM mit PSE auf appsrv08, die lokalen Slots werden dort als Modelle eingetragen.
- **D5 IP-Plan – zurückgestellt (12.10.):** Knoten behalten .207–.214, Gäste vorerst per DHCP. Feste Gast-IPs (Vorschlag `.220–.239`) über Reservierungen bzw. einen Ausschluss im DHCP-Server, spätestens vor Phase 6.
### Offen
1. Erstes Basismodell für `tier1-general` (8–14B, gut quantisierbar, LoRA-freundlich, gutes Deutsch). Entscheidung per Kalibrierungslauf.
2. Kommen die M4000 zurück? `llama-bench` plus Strommessung am Deployment-Tag (bis dahin außer Betrieb, ADR-0006)
+9
View File
@@ -15,3 +15,12 @@ Im Cluster stecken GPUs aus drei Generationen: Maxwell (M4000), Pascal (P2000) u
- Der VM-RAM ist bei Passthrough vollständig reserviert. Er wird deshalb knapp bemessen: llama.cpp-VMs 6–8 GB, vLLM-VMs 24–32 GB.
- Keine Live-Migration für GPU-VMs. Bei einem Ausfall springt der Router auf einen anderen Slot oder auf Stufe 0.
- Treiber-Updates laufen pro VM, abgesichert durch einen Snapshot.
## Nachtrag 2026-10-12: eine GPU-VM pro Knoten (D2)
- Statt einer VM pro Rolle bekommt jeder GPU-Knoten **eine** Ubuntu-24.04-VM, an die alle Karten des Knotens durchgereicht werden: `gpu-pve44` (vmid 244), `gpu-pve46` (246), `gpu-pve48` (248), `gpu-pve52` (252). Darin läuft pro Rolle ein Docker-Container.
- Gründe:
- **RAM:** Passthrough pinnt den gesamten VM-RAM. Eine VM mit 12 GB pro Knoten lässt auf 32 GB mehr für Container und Failover übrig als mehrere kleine VMs.
- **Nur Maxwell/Pascal im Cluster:** Die Ada-Karten bleiben in den Windows-Rechnern (ADR-0006, Nachtrag). M4000 und P2000 laufen mit demselben Legacy-Treiber (R580, CUDA 12.x) und passen deshalb in eine VM. Getrennte Treiber je Generation sind nicht mehr nötig.
- **Host-Kernel ohne NVIDIA:** Der Treiber (R580) kommt als Ubuntu-Paket in der VM und ist per Snapshot rückrollbar. Proxmox-Kernel-Updates berühren ihn nicht.
- Mappings heißen `gpu-<karte>-<knoten>` (z. B. `gpu-m4000-pve44`), Quelle ist `sites/knaster/proxmox.yaml`. Die M4000 werden mit durchgereicht, bleiben im Inventar aber `disabled` (ADR-0006).
- Folgen: Ein Neustart der GPU-VM trifft alle Rollen des Knotens. Der Router weicht dann auf die zweite `gpu-embed`-Instanz bzw. auf Stufe 0 aus. Die RAM-Richtwerte oben (6–8 GB, 24–32 GB) gelten nicht mehr.
+7
View File
@@ -16,3 +16,10 @@ Die Nodes sind mit **1 GbE** verbunden. Jede physische Maschine hat 2–3 SSDs m
- Bei einem Node-Ausfall gehen höchstens 15 Min. Daten der Infra-VMs verloren. Postgres sichert sich zusätzlich über WAL-Archivierung bzw. PBS.
- Modellwechsel verursachen Downloads (10 GB ≈ 90 s über 1 GbE). Das ist akzeptabel.
- Die Speicherbudgets pro Rolle stehen in PLAN.md, Abschnitt 1.4.
## Nachtrag 2026-10-12: Umsetzung als Variante B (D1)
- Keine Neuinstallation. Die System-SSD bleibt bei ext4 mit LVM-thin: `local` für System, ISOs und Vorlagen, `local-lvm` für die Platten der Gäste.
- Die zweite SSD ist der ZFS-Pool `tank` (Einzel-SSD, kein Mirror) auf **pve44, pve48 und pve52**. pve46 und pve61 haben nur eine Platte und bleiben ohne ZFS. Die leere 128-GB-SSD `sdc` in pve48 ist noch nicht verplant.
- Replikation und HA laufen nur zwischen pve44, pve48 und pve52. `infra` (LXC, vmid 101) liegt auf pve44 in `tank` und wird nach pve52 und pve48 repliziert.
- Die GPU-VMs liegen auf `local-lvm` des eigenen Knotens. Sie werden nicht repliziert, Modelle lassen sich neu laden.
- Folgen: Fällt eine System-SSD aus, wird der Knoten neu installiert. Gesichert sind dann nur die replizierten Gäste in `tank` und die PBS-Backups. Variante A (Neuinstallation mit ZFS-Mirror, vor Ort) lässt sich später je Knoten nachholen.
@@ -20,3 +20,58 @@
## Folgen
- Bei `gpu-flex` muss sich der Lifecycle die Karte mit TTS teilen. Während des Trainings gehen sowohl `tier1-flex` als auch `tts-de-hq` auf `unavailable`, und LiteLLM fällt auf `tts-de` zurück.
- Die tatsächlichen RTF-Werte misst `knaster bench tts` beim Deployment. Sie ersetzen die Schätzungen im Katalog.
## Nachtrag 2026-10-12: Ada-Rechner bleiben Windows (D3, vorgeschlagen, Bestätigung offen)
- Die beiden RTX 4000 Ada (20 GB) stecken nicht in einem Proxmox-Knoten, sondern je eine in den Windows-Arbeitsplätzen GODE-PC-05 und GODE-PC-06 (64 GB RAM). Sie bleiben Windows, Mitarbeitende haben tagsüber Vorrang.
- Vorschlag: `LocalLLMWorker.ps1` mit llama-server (CUDA) meldet sich über die Worker-Registry v2. `tier1-general` (gode-pc-05) und `tier1-flex` (gode-pc-06) lösen auf einen Worker auf, der online ist, sonst auf Stufe 0 über den LiteLLM mit PSE.
- In `sites/knaster/registry.yaml` behalten die Ada-Slots vorerst ihre Engine, nur `pve_node` und `vmid` entfallen. Die Umstellung folgt in Task 6.4.
- Folgen: vLLM und PyTorch laufen unter Windows nicht nativ. `tts-de-hq` (Qwen3-TTS), Multi-LoRA und QLoRA auf `gpu-flex` warten auf eine spätere WSL2-Entscheidung (Phase 9). Bis dahin trägt `tts-de` (Piper) die Sprachausgabe allein.
## Nachtrag 2026-10-12: Benchmark (Task 3.3, ohne Strommessung)
Gemessen in `gpu-pve48` (pve48, VM 248: 4 vCPU i7-6700, 12 GB RAM, Treiber 580.178.04), beide Karten nacheinander, nie gleichzeitig.
- **Builds:** llama.cpp `b6000` (Commit `4762ad7`, Compose-Pin aus `deploy/compose/gpu-llamacpp`), `.devops/cuda.Dockerfile` Stufe `build`, `CUDA_VERSION=12.4.0`, `CUDA_DOCKER_ARCH=52;61`. whisper.cpp `v1.9.5` (Commit `d1be6fd`) auf `nvidia/cuda:12.4.0-devel-ubuntu22.04`, `CMAKE_CUDA_ARCHITECTURES=52;61` (braucht `-Wl,--allow-shlib-undefined`, sonst fehlt `libcuda` beim Linken im Container).
- **Modelle** (liegen in `/srv/models` von `gpu-pve48`, dort auch `SOURCES.txt`):
| Datei | Quelle (Hugging Face, Revision) | SHA-256 |
|---|---|---|
| `Qwen3-Embedding-0.6B-Q8_0.gguf` | `Qwen/Qwen3-Embedding-0.6B-GGUF@370f27d` | `06507c7b42688469c4e7298b0a1e16deff06caf291cf0a5b278c308249c3e439` |
| `Qwen3-4B-Q4_K_M.gguf` | `Qwen/Qwen3-4B-GGUF@bc64014` | `7485fe6f11af29433bc51cab58009521f205840f5b4ae3a32fa7f92e8534fdf5` |
| `Qwen3-8B-Q4_K_M.gguf` | `Qwen/Qwen3-8B-GGUF@7c41481` | `d98cdcbd03e17ce47681435b5150e34c1417f50b5c0019dd560e4882c5745785` |
| `ggml-large-v3-turbo.bin` (nach der Messung gelöscht, nicht im Katalog) | `ggerganov/whisper.cpp@5359861` | `1fc70f774d38eb169993ac391eea357ef47c88757ef72ee5943879b7e8e2bc69` |
**llama.cpp** (`llama-bench`, `-ngl 99`, Werte t/s ohne/mit Flash-Attention; VRAM = belegter Speicher mit `llama-server` beim Katalog-Kontext). Als „7B Q4“ wurde das Katalogmodell `qwen3-8b-q4km` (8B) gemessen.
| Karte | Modell | pp512 | tg128 | VRAM (Kontext) |
|---|---|---|---|---|
| M4000 (CC 5.2) | Qwen3-Embedding 0.6B Q8 | 929 | – | 2,8 GiB (8192, `-ub 8192`) |
| P2000 (CC 6.1) | Qwen3-Embedding 0.6B Q8 | 1009 | – | 2,8 GiB (8192, `-ub 8192`) |
| M4000 | Qwen3-4B Q4_K_M | 197 / 204 | 15,1 / 15,3 | 4,1 GiB (8192) |
| P2000 | Qwen3-4B Q4_K_M | 271 / 275 | 14,5 / 14,8 | 4,1 GiB (8192) |
| M4000 | Qwen3-8B Q4_K_M | 115 / 118 | 10,0 / 10,1 | 7,7 GiB (16384) |
| P2000 | Qwen3-8B Q4_K_M | 152 / 135 | 9,0 / 8,2 | **passt nicht** bei 16384 (KV-Cache scheitert), nur mit 512 Kontext (4,9 GiB) |
**whisper.cpp** `large-v3-turbo`, `-l de`, Standard-Dekodierung (Beam 5), Testaudio selbst mit Piper erzeugt (Hausverwaltungs-Brief, keine Kundendaten, danach gelöscht). Rechenzeit ohne Modell-Laden (Laden: P2000 1,1 s, M4000 0,7 s; ein Server lädt nur einmal), Median aus 3 Läufen nach einem Aufwärmlauf:
| Karte | 10-s-Clip | 33-s-Clip, umgerechnet je 10 s | VRAM | Flash-Attention aus |
|---|---|---|---|---|
| P2000 | 3,5 s | 2,5 s | 2,0–2,2 GiB | 3,9 s bzw. 2,9 s |
| M4000 | 2,7 s | 2,1 s | 2,0–2,2 GiB | 3,2 s bzw. 2,3 s |
Erkennung fehlerfrei bis auf „Milltonnen“ (statt „Mülltonnen“); synthetische Sprache ist leichter als echtes Diktat. Zum Vergleich der Dienst `nex-stt` auf der M4000 in `gpu-pve52` (v1.9.4, Task 6.2): 10 s → 2,94 s.
**Piper** (`knaster bench tts` gegen das Image aus `deploy/docker/tts-piper.Dockerfile`, `de_DE-thorsten-medium`, 4 vCPU): RTF 0,17 bei 241 Zeichen (≈ 17 s Audio, 2,9 s Rechenzeit), RTF 0,11 bei 596 Zeichen (≈ 33 s Audio, 3,6 s).
**Leistungsaufnahme laut GPU-Sensor** (`nvidia-smi`, nur die Karte, keine Steckdosenmessung): M4000 Leerlauf 13,5 W (P8), unter llama.cpp 94–102 W, unter whisper 92–99 W. P2000 Leerlauf 6,5 W, unter Last 46–52 W (Grenze 75 W). Ohne Persistence-Modus zeigt ein einzelner `nvidia-smi`-Aufruf kurz P0 mit 46 W bzw. 20 W an; erst nach einigen Sekunden fällt die Karte auf P8.
Weitere Befunde:
- **CC 5.2 rechnet:** llama.cpp b6000 und whisper.cpp 1.9.5 laufen mit CUDA 12.4 auf der M4000, Flash-Attention eingeschlossen.
- Die Token-Erzeugung (tg) ist auf beiden Karten etwa gleich schnell und liegt deutlich unter der Speicherbandbreite. Die Karte ist dabei voll ausgelastet (P2000 99 %), es liegt also nicht an der CPU der VM. b6000 ist über ein Jahr alt (aktuell `b11574`); vor einer Anhebung des Compose-Pins nachmessen.
- Die P2000 hängt in pve48 nur mit PCIe 3.0 x4 an (M4000 x16). Das bremst nur das Laden.
- **Kartenwahl in Compose:** `deploy/compose/gpu-llamacpp` reserviert `count: 1`. Docker gibt dann die erste Karte heraus, und das ist die M4000 (geprüft: `docker run --gpus 1 … nvidia-smi -L` → Quadro M4000). Für die P2000 in Task 6.1 `device_ids` (Index 1 oder UUID) statt `count` setzen.
### Empfehlung: M4000 wieder aufnehmen – **ja, aber nur gezielt**
- **pve52 (nur eine M4000): ja**, für STT (whisper.cpp, 2,1 s je 10 s, schneller als die P2000). Ohne sie hat der Knoten keine GPU-Rolle, und die M4000 läuft dort für `nex-stt` ohnehin.
- **pve44, pve46, pve48: vorerst nein** für die heutigen Rollen. Embedding und Verifier laufen auf der P2000 gleich schnell oder schneller (Prefill +37 %) bei etwa halber Leistungsaufnahme. Bedingung: Embedding und Rerank passen mit `-ub 8192` gemeinsam auf die P2000 (Embedding allein 2,8 GiB, Rerank ungemessen). Passt das nicht, kommt Rerank auf die M4000 in derselben VM (dann „ja“ für pve44/46), oder `-ub` wird gesenkt. Die M4000 lohnt sich dort nur für Rollen, die mehr als 5 GB brauchen: `qwen3-8b-q4km` mit 16k Kontext (7,7 GiB), eine zweite STT-Instanz oder ein zweiter Verifier zur Lastverteilung.
- Nur für llama.cpp und whisper.cpp; PyTorch, vLLM und TTS bleiben ausgeschlossen (Entscheidung 1 und 3 unverändert).
- Eine gesperrte, aber eingebaute M4000 verbraucht im Leerlauf weiter etwa 13 W, solange der Treiber in der VM geladen ist. Bleibt sie dauerhaft ungenutzt, ausbauen.
- Vorbehalt: Die Steckdosenmessung je Knoten (Leerlauf und Volllast) steht aus. Das Flag `disabled` im Inventar ist nicht geändert; die Freigabe für pve52 setzt der Nutzer nach dieser Messung.
@@ -0,0 +1,324 @@
# Auftrag: Sprachmodelle (Spracherkennung + Sprachausgabe) für NEX auf dem knaster-Cluster
Eingang 12.10.2026 (vom NEX-Team, ohne Schlüssel). Ausführung **nach** Task 3.2 (GPU-VMs mit Treiber), gern durch einen Subagenten. Im Plan als Task 6.5 geführt.
## Abgleich mit dem echten Cluster (Stand 12.10.2026, vor Ausführung lesen)
Der Auftrag geht von einem älteren Bild aus. Es gilt:
| Auftrag sagt | Wirklichkeit |
|---|---|
| 2 Knoten + QDevice | 5 Knoten (pve44, pve46, pve48, pve52, pve61), 5 Stimmen, kein QDevice |
| Knoten A 2× P2000, Knoten B 2× M4000, GPU per LXC oder VM | Eine Ubuntu-24.04-VM pro GPU-Knoten mit allen Karten (D2): `gpu-pve44`, `gpu-pve46`, `gpu-pve48` je M4000 + P2000, `gpu-pve52` nur M4000. Treiber R580 in der VM |
| Feste IP per DHCP-Reservierung | D5 ist zurückgestellt. Gäste laufen per DHCP, der Nutzer hat keinen Zugriff auf den DHCP-Server. **Vor der Abnahme klären:** Reservierung durch die DHCP-Verwaltung oder statische IP außerhalb des DHCP-Bereichs |
Daraus folgt für die Umsetzung:
- **Erkennung:** whisper.cpp (CUDA 12, `CMAKE_CUDA_ARCHITECTURES="52;61"`) auf einer **M4000**. Naheliegend ist `gpu-pve52`: Dort steckt nur die M4000, und Placement verteilt keine andere Rolle dorthin. Die P2000 bleiben für embed/rerank (pve44, pve46) und `gpu-small` (pve48). Die M4000 sind im Inventar `disabled` bis zum Benchmark-Tag (Task 3.3); die Messwerte aus 4. Abnahme liefern genau diesen Benchmark für whisper und gehören in Task 3.3.
- **Ausgabe Stufe 1 (Piper, CPU):** laut Registry `tts-de` im Infra-LXC (`infra`, Task 5.2). Solange `infra` nicht steht, als Container in derselben GPU-VM wie die Erkennung (CPU reicht).
- **Firewall „nur von 192.168.100.29“:** Proxmox-Firewall an der Netzkarte der VM (`firewall=1`, Regeln für 8178/8179) plus `ufw` in der VM.
- Globale Vorgabe aus dem Plan passt: whisper-server auf Port 8178, nur von `.29`, `stt.openai.base_url` nur als IP-Literal.
## Auftrag (Originaltext)
NEX sind Hermes-Agenten auf appsrv08 (192.168.100.29). Sprache läuft dort: Hermes → nex-router → LiteLLM → Sprach-Backend. Das Backend ist austauschbar (Eintrag in LiteLLM). Der Dienst auf dem Cluster ist das **Rückfall-Modell**. Solange kein Cloud-Anbieter Guthaben hat, ist er der einzige aktive Weg. Die NEX-Seite trägt danach nur IP:Port und Modellnamen in LiteLLM ein. Gebraucht werden zwei **OpenAI-kompatible** HTTP-Dienste im internen Netz. Ein Dienst für beides ist auch in Ordnung.
Formate laut Hermes-Code: mp3 als Standard, Opus für Sprachnachrichten, rohes PCM mit Sample-Rate-Header für das Streaming.
### Hardware und Grenzen
- Treiber **R580**, die letzte Serie für Maxwell/Pascal.
- **faster-whisper/CTranslate2 laufen nicht auf CC 5.2.** Auf der M4000 nur whisper.cpp, und zwar als **CUDA-12-Build**, nicht CUDA 13 (`-DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES="52;61"`).
- PyTorch-Unterstützung für sm_50/sm_61 in aktuellen Wheels **vorher prüfen** (`python -c "import torch; print(torch.cuda.get_arch_list())"`), nicht annehmen. Keine Tensor-Cores: bei faster-whisper `int8` oder `int8_float32`.
### 1. Spracherkennung (Pflicht)
- Modell **Whisper large-v3-turbo**, Sprache fest `de`.
- Empfehlung M4000: whisper.cpp `whisper-server` mit `-m ggml-large-v3-turbo.bin -l de -bs 5 --convert --inference-path /v1/audio/transcriptions`, **ohne** `--print-realtime`, `--tmp-dir` auf tmpfs (z. B. `/run/nex-whisper`). Nach dem Start einmal mit 1 s Stille aufwärmen.
- Alternative P2000: speaches (faster-whisper) mit `deepdml/faster-whisper-large-v3-turbo-ct2`, `int8_float32`.
- Optional mitmessen: `primeline/whisper-large-v3-turbo-german`.
- Schnittstelle: `POST /v1/audio/transcriptions`, multipart
- Felder: `file`, `model`, `language=de`, `response_format`.
- `file` muss wav, mp3, ogg/opus, webm und m4a annehmen (Umwandlung mit ffmpeg).
- `response_format`: `json` → `{"text": "…"}`, `text`, `verbose_json` (wenn möglich mit Wort-Wahrscheinlichkeiten).
- Unbekannte Felder ignorieren, nicht mit Fehler antworten.
### 2. Sprachausgabe (Pflicht Stufe 1, optional Stufe 2)
- **Stufe 1:** Piper Deutsch, CPU reicht. Stimmen: `de_DE-thorsten-high` und eine weibliche deutsche Stimme. Genauen Namen nennen. Server OpenAI-kompatibel, z. B. speaches (kann Piper) oder ein schlanker Wrapper.
- Schnittstelle: `POST /v1/audio/speech`
- JSON-Body `{model, voice, input, response_format, speed}`.
- `response_format` muss können: `mp3` (Standard), `opus` (im Ogg-Container), `wav`, `flac`, `pcm` (rohes int16, little-endian, mono; Sample-Rate im Header `X-Audio-Sample-Rate` oder in `Content-Type: audio/pcm; rate=<Hz>`).
- Antwort möglichst gestreamt (chunked), damit die Wiedergabe früh beginnt.
- **Stufe 2 (optional, GPU, nach PyTorch-Prüfung):** eine natürlichere deutsche Stimme.
- **Lizenz für kommerzielle Nutzung prüfen.** Nach Stand des NEX-Teams sind XTTS-v2 (Coqui Public Model License) und die Gewichte von Fish Speech/OpenAudio **nicht kommerziell**.
- Chatterbox Multilingual (MIT) ist ein Kandidat.
- Kokoro hat nach diesem Stand keine deutsche Stimme. Prüfen, nicht voraussetzen.
### 3. Für beide Dienste
- `GET /health` → 200.
- Feste IP in `192.168.100.0/24` (siehe Abgleich oben). Ports zum Beispiel 8178 (Erkennung) und 8179 (Ausgabe).
- Firewall: Zugriff **nur von 192.168.100.29**. LiteLLM läuft dort in einem Bridge-Netz und erscheint mit der Host-IP.
- Kein API-Key nötig. Erzwingt der Server einen, einen festen Dummy-Wert nennen (kein Geheimnis).
- Autostart nach Neustart (systemd oder Docker `restart: unless-stopped`), Energiesparen aus, Sicherheitsupdates.
- **Datenschutz:** keine Ablage von Audio oder Text; Temp-Dateien nur im RAM, alte regelmäßig löschen; **keine Transkripte oder vorgelesenen Texte in Logs.**
### 4. Abnahme
Von appsrv08 aus prüfen, Testaudio ohne Kundendaten, danach löschen.
- **Erkennung:**
- `curl -s -w ' %{time_total}s\n' -F file=@t10.wav -F model=whisper -F language=de -F response_format=json http://IP:8178/v1/audio/transcriptions`
- Text muss deutsch und plausibel sein.
- Median aus 3 Läufen: 10 s Audio **≤ 4 s** (Ziel ≤ 3 s), 60 s Audio **≤ 12 s**. Den ersten Aufruf getrennt notieren.
- **Ausgabe:** `curl -s -o t.mp3 -w ' TTFB %{time_starttransfer}s gesamt %{time_total}s\n' -H 'Content-Type: application/json' -d '{"model":"piper","voice":"de_DE-thorsten-high","input":"Guten Tag, hier ist Ihr NEX-Assistent. Die Baubesprechung in Haslach ist am vierten November.","response_format":"mp3"}' http://IP:8179/v1/audio/speech`
- Ergebnis abspielbar, Zeit bis zum ersten Byte möglichst ≤ 1 s.
- Dasselbe mit `pcm` (Sample-Rate-Header prüfen) und `opus`.
- **Last:** 2 Erkennungen gleichzeitig. Mit `nvidia-smi` prüfen, dass GPU-Last und VRAM steigen.
### 5. Rückmeldung (genau so)
Je Dienst: Basis-URL (IP:Port) · Knoten und GPU · Software mit Version oder Image-Digest · Modellname(n), wie im Feld `model` erwartet · Stimmen-IDs · unterstützte `response_format` · PCM-Sample-Rate · Messwerte (Median, erster Aufruf, VRAM, RAM) · Autostart nach Neustart getestet (ja/nein) · Auffälligkeiten.
### Gegenseite (NEX, zur Information)
Die Agenten fragen nur noch neutrale Namen an: `nex-stt` für die Erkennung, `nex-tts` für die Ausgabe. Welches Backend dahinter antwortet und in welcher Reihenfolge, legt allein LiteLLM fest: heute das Cluster, später ein Cloud-Anbieter davor und das Cluster als Rückfall. nex-router 0.2.3 wird um die Sprachausgabe (gestreamt) erweitert. Nach der Rückmeldung trägt das NEX-Team die Endpunkte in LiteLLM ein und testet die Kette. Live erst mit dem neuen Router-Image.
## Rückmeldung (Stand 12.10.2026, Task 6.2)
Umgesetzt in `gpu-pve52` (VM 252 auf pve52). Getestet in der VM (localhost) und von ops mit einer temporären ufw-Regel, die danach wieder entfernt wurde. Von appsrv08 noch **nicht** geprüft, das bleibt für das NEX-Team. Testaudio kam aus Piper (zwei Sätze zur Baubesprechung, keine Kundendaten) und wurde danach gelöscht.
**Feste IP fehlt (D5):**
- Die VM hat ihre Adresse per DHCP: heute `192.168.100.174`, MAC `bc:24:11:92:4b:eb`.
- Die Adresse hat den Neustart überstanden, ist aber nicht garantiert.
- Vor dem Eintrag in LiteLLM: Reservierung auf diese MAC einrichten oder eine statische Adresse vergeben.
**Energiesparen und Updates:**
- Die VM hat kein Suspend und kein Energiesparen (Ubuntu-Server-Cloud-Image).
- Die GPU läuft im Leerlauf in P5 mit 17 W. Der Kontext bleibt geladen, weil whisper-server läuft.
- Die Energieeinstellungen der Proxmox-Knoten habe ich laut Vorgabe nicht angefasst.
- Sicherheitsupdates laufen per `unattended-upgrades`.
**API-Key:** Keiner nötig, kein Dummy-Wert. Beide Dienste ignorieren das Feld `model`, jeder Wert geht.
### Erkennung (`nex-stt`)
**Basis-URL:** `http://192.168.100.174:8178`
- Pfad: `POST /v1/audio/transcriptions`
- Zustand: `GET /health` → `{"status":"ok"}`, solange das Modell lädt: 503
**Knoten und GPU:** pve52, VM `gpu-pve52`, Quadro M4000 (8 GB, CC 5.2), Treiber 580.178.04
**Software:**
- whisper.cpp `whisper-server` **v1.9.4** (Commit `927cfce34f31707e17f2bff35c349632fb9e2c3a`)
- Build: CUDA 12.6.3, `-DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES="52;61"`, statisch gelinkt
- Image `knaster/nex-whisper:v1.9.4-cu12.6`, ID `sha256:be4987ff63df…`
- Basis-Images per Digest gepinnt: `nvidia/cuda:12.6.3-{devel,runtime}-ubuntu24.04`
- ffmpeg 6.1.1 für `--convert`
**Modell:** `ggml-large-v3-turbo.bin` (f16)
- Quelle: HF `ggerganov/whisper.cpp`, Revision `5359861c…`
- sha256 `1fc70f77…e2bc69`, gegen Hugging Face geprüft
- Feld `model`: beliebig, z. B. `whisper` oder `nex-stt`
**Parameter:**
- `-l de -bs 5 --convert --inference-path /v1/audio/transcriptions --tmp-dir /run/nex-whisper`
- `--tmp-dir` liegt auf tmpfs (512 MB)
- ohne `--print-realtime`, Flash-Attention an
- Nach jedem Start wird mit 1 s Stille aufgewärmt.
**Eingangsformate:** wav, mp3, ogg/opus, webm/opus und m4a/aac, alle über ffmpeg getestet.
**`response_format`:**
- `json` → `{"text": "…"}`
- `text`
- `verbose_json`: Segmente mit `words[].probability`, `avg_logprob`, `no_speech_prob`, dazu `language_probabilities`
- außerdem `srt` und `vtt`
**Unbekannte Felder werden ignoriert.** Geprüft mit `model=gpt-4o-transcribe`, `timestamp_granularities[]=word`, `foo=bar` und `stream=false`, Antwort jeweils 200.
**PCM-Sample-Rate:** entfällt; ffmpeg wandelt intern auf 16 kHz mono.
**Messwerte (M4000, `-bs 5`):**
| Messung | Ergebnis |
|---|---|
| 10 s Audio, erster Aufruf nach Start und Aufwärmen | 3,00 s (nach `docker restart`), 2,94 s (erster Aufruf der Abnahmeserie) |
| 10 s Audio, Median aus 3 Läufen | **2,94 s** (Grenze 4 s, Ziel ≤ 3 s erreicht) |
| 60 s Audio, erster Aufruf | 8,63 s |
| 60 s Audio, Median aus 3 Läufen | **8,63 s** (Grenze 12 s) |
| 5 s Audio, von ops über das Netz | 2,43 s |
| Start bis bereit (Modell im Cache, inkl. Aufwärmen) | 4,1 s |
| VRAM | 1965 MiB nach dem Laden und bei 10-s-Aufrufen, bis 2179 MiB nach 60-s-Audio, im Paralleltest kein weiterer Anstieg |
| RAM des Containers | 286 MiB |
| Leerlauf der Karte | P5, 17 W |
**Last mit 2 Erkennungen gleichzeitig (je 60 s):**
- Antworten nach 8,8 s und 17,5 s. whisper-server arbeitet sie nacheinander ab (globaler Mutex), die zweite wartet.
- GPU-Last stieg auf 100 %, Mittel 78 %. Der Leerlauf (0 %) wurde separat gemessen; der erste Wert im Log (85 %) war noch Nachlauf.
- **Der VRAM steigt nicht**, er bleibt bei 2179 MiB, weil der Kontext einmal geladen wird.
**Autostart nach Neustart getestet: ja.** Die VM wurde über Proxmox neu gestartet (`qm reboot 252`). 34 s nach dem Boot lieferten beide `/health` 200, die erste Erkennung danach war normal.
**Auffälligkeiten:**
1. **Gleichzeitige Anfragen laufen nacheinander** (siehe Last).
2. **Mindestdauer etwa 2,3 s pro Aufruf**, auch bei kurzen Clips. Der Encoder rechnet immer ein 30-s-Fenster.
3. **Format der `json`-Antwort:** Der Text beginnt mit einem Leerzeichen und hat Zeilenumbrüche zwischen den Segmenten. Für Hermes ggf. `strip()` bzw. Umbrüche durch Leerzeichen ersetzen.
4. **Content-Type bei `text`:** `text/html; charset=utf-8`.
5. **Qualität am synthetischen Testaudio:**
- Sauber: „Baubesprechung in Haslach ist am 4. November um 10 Uhr“.
- „NEX“ wurde meist als „NX“ erkannt.
- Im Teil mit der leisen Kerstin-Stimme gab es einzelne Fehler, z. B. „Baugüro“ und „Baulätung“.
- Echte Aufnahmen und die Dialekt-Testsätze stehen in Task 3.3 an.
6. **Log:** Es enthält nur den Upload-Dateinamen und die Audiolänge (z. B. `Received request: t10.wav`), **keinen Text**. Gegen den Testtext gegrepped: 0 Treffer.
- ffmpeg läuft mit `-loglevel error`.
- Log-Rotation: Treiber `local`, 2 × 5 MB.
7. **Temp-Dateien:**
- Nur im tmpfs, nach jeder Anfrage gelöscht; nach den Tests 0 Dateien.
- Bei ffmpeg-Fehlern bleibt eine Upload-Datei liegen. Ein Aufräumer löscht Dateien älter als 10 min.
8. **Endpunkt `POST /load`:** Er existiert (Modellwechsel). Erreichbar ist er nur von `.29`, und im Container liegt nur dieses eine Modell.
9. **Proxmox-Firewall:** Abweichend vom Abgleich oben ist sie **nicht** aktiviert (Vorgabe). Es gilt nur **ufw in der VM**:
- 8178/8179 nur von `192.168.100.29`
- SSH aus `192.168.100.0/24`
- sonst `deny incoming`
- Beide Dienste laufen im Host-Netz, weil veröffentlichte Docker-Ports ufw umgehen würden.
- Negativtest von ops (.173): ohne Regel Zeitüberschreitung auf beiden Ports.
### Ausgabe (`nex-tts`)
**Basis-URL:** `http://192.168.100.174:8179`
- Pfad: `POST /v1/audio/speech`
- Zustand: `GET /health` → `{"status":"ok","voices":[…],"default":"de_DE-thorsten-high"}`
- außerdem `GET /v1/models` und `GET /v1/audio/voices`
**Knoten und Hardware:** pve52, VM `gpu-pve52`, nur CPU. Die VM hat 4 Kerne (i7-7700); der Container ist auf 3 Kerne und 2 GB begrenzt.
**Software:**
- eigener schlanker Wrapper: FastAPI 0.141.1, uvicorn 0.53.0, piper-tts **1.8.0** (GPL-3.0, eigener Dienst), onnxruntime 1.30.0, ffmpeg 5.1.9
- Image `knaster/nex-tts-piper:1.8.0`, ID `sha256:d9e65ee77836…`
- Basis `python:3.12-slim-bookworm`, per Digest gepinnt
**Warum nicht speaches:** Der Wrapper ist klein und gut zu prüfen. Er streamt satzweise, setzt die PCM-Header nach Vorgabe und schreibt weder Text noch Audio auf die Platte. Der bisherige knaster-`tts_server` konnte nur wav.
**Modellname (`model`):** beliebig, z. B. `piper` oder `nex-tts`.
**Stimmen-IDs:**
- `de_DE-thorsten-high`: männlich, 22050 Hz, CC0, Standard
- `de_DE-kerstin-low`: weiblich, 16000 Hz, Datensatz CC0
- Dateien aus HF `rhasspy/piper-voices`, Revision `c10ece1a…`, sha256 geprüft.
- Kurzformen `thorsten` und `kerstin` gehen ebenfalls.
- Unbekannte Stimmen (z. B. `alloy`) fallen ohne Fehler auf die Standardstimme. Der Header `X-Voice` nennt die verwendete Stimme.
**Weitere weibliche Stimmen**, nicht genommen:
- `de_DE-ramona-low` und `de_DE-eva_k-x_low`: M-AILABS, Lizenz „See URL“, kommerzielle Nutzung nicht belegt
- `de_DE-mls-medium`: mehrere Sprecher
**`response_format`:**
- Standard ist `mp3` (`audio/mpeg`).
- `opus` (Ogg, 48 kHz, `audio/ogg`), `aac`, `flac` und `pcm` werden **gestreamt** (chunked). Die Header kommen erst mit dem ersten Audioblock, deshalb misst TTFB echtes Audio.
- `wav` kommt vollständig mit korrektem Header; das passt zu `knaster bench tts` und zum Canary.
- `speed` (0,25–4) wird auf `length_scale` abgebildet.
- Unbekannte Felder werden ignoriert.
- `input` ist auf 5000 Zeichen begrenzt.
**PCM-Sample-Rate:** **24000 Hz** für alle Stimmen, wie OpenAI.
- Format: int16 LE, mono, umgerechnet per ffmpeg.
- Header: `X-Audio-Sample-Rate: 24000` und `Content-Type: audio/pcm; rate=24000`.
- Mit `PCM_SAMPLE_RATE=0` käme die native Rate der Stimme.
**Messwerte (Auftragssatz, thorsten-high, je 3 Läufe):**
| Format | TTFB | gesamt | Audio |
|---|---|---|---|
| mp3 | 0,67–0,91 s | 1,42–1,68 s | 5,25 s |
| pcm | 0,72–0,87 s | 1,48–1,62 s | 5,31 s |
| opus | 0,64–0,72 s | 1,54–1,61 s | 5,28 s |
- Von ops über das Netz: mp3 TTFB 0,72 s.
- Erste Synthese nach einem Neustart (wav, 10 s Audio): 2,7 s gesamt. Die Stimmen werden beim Start aufgewärmt.
- RAM: 616 MiB. Kein VRAM.
**Autostart nach Neustart getestet: ja**, wie bei der Erkennung.
**Auffälligkeiten:**
1. **Log:** kein Access-Log, Level `warning`.
- Gegen den Testtext gegrepped: 0 Treffer.
- Piper meldet gelegentlich `Missing phoneme from id map` (ohne Text).
- onnxruntime meldet beim Start eine harmlose Telemetrie-Warnung.
2. **Gleichzeitige Synthesen:** höchstens 2 (`TTS_PARALLEL`). Die CPU teilt sich der Dienst mit whisper-server, das die CPU aber kaum nutzt.
3. **Ablageort:** Laut Registry (`tts-de`) gehört Piper in den Infra-LXC. Vorerst läuft es hier.
### Stufe 2 (nur geprüft, nichts installiert)
**PyTorch auf der M4000:**
- In einem Wegwerf-Container geprüft: `torch 2.9.1+cu126`, `get_arch_list()` = `sm_50, sm_60, sm_70, sm_75, sm_80, sm_86, sm_90`.
- Die M4000 (5, 2) rechnet eine Matrixmultiplikation korrekt.
- **2.9.1 ist die letzte Version mit cu126-Wheels.** Laut den Build-Skripten haben cu128/cu129/cu130 kein sm_5x/sm_6x mehr, also ist das die Obergrenze für Maxwell/Pascal.
**Chatterbox Multilingual (ResembleAI):**
- Lizenz **MIT** (HF-Karte), Deutsch enthalten.
- Läuft grundsätzlich mit PyTorch ≤ 2.9.1 auf sm_52, aber ohne Tensor-Cores in fp32.
- Langsam erwartet. Nicht gemessen.
**Kokoro-82M:** `VOICES.md` listet **keine deutsche Stimme**, nur en, ja, zh, es, fr, hi, it und pt-br.
**XTTS-v2 und Fish Speech:** nicht geprüft, laut NEX-Team nicht kommerziell.
**Empfehlung:** Stufe 2 mit `tts-de-hq` (Qwen3-TTS) auf der Ada (Task 6.4) statt auf der M4000.
### Notiz für Task 3.3 (Benchmark M4000, whisper)
**Ergebnis:**
- CC 5.2 **rechnet** mit dem CUDA-12.6-Build. `system_info` zeigt `CUDA : ARCHS = 520,610`.
- `large-v3-turbo` f16 mit `-bs 5`: 10 s Audio in 2,94 s (RTF 0,29), 60 s in 8,63 s (RTF 0,14).
- Mit `-nfa` (Flash-Attention aus) sind es 3,21 s für 10 s, also bleibt Flash-Attention an.
- Speicher: VRAM 2,2 GB, RAM 0,3 GB.
**Noch offen:**
- P2000 nicht gemessen
- Dialekt-Testsätze
- `primeline/whisper-large-v3-turbo-german`: bräuchte eine ggml-Umwandlung
- Strom unter Last
**Offener Punkt Registry:** Den Slot `stt-de` habe ich **nicht** angelegt.
- `SlotSpec.kind` (core `registry.py`) kennt kein `stt`, Placement (`placement.py`) und Canary ebenfalls nicht.
- Ohne Core-Änderung schlägt `knaster validate` fehl, und der Canary würde den Slot als Chat proben.
- Gehört zu 3.3 bzw. 5.2, zusammen mit dem Umzug von `tts-de`.
### Betrieb
**Dateien im Repo:**
- `deploy/compose/nex-speech/` (Compose, `.env.example`, Dockerfiles, `piper/app.py`)
- Rolle `platform/proxmox/ansible/roles/nex-speech`
- Playbook `platform/proxmox/ansible/playbooks/nex-speech.yml`, nur für `gpu-pve52`
**Ausrollen von ops:**
```sh
cd /root/cluster-knaster && git pull
cd platform/proxmox/tofu && . /root/.config/knaster/tofu.env && bash ../scripts/refresh-vm-access.sh
cd ../ansible && ansible-playbook playbooks/nex-speech.yml
```
**Was die Rolle macht:**
- ufw-Regeln setzen und ufw einschalten
- `docker-buildx` installieren
- Modelle nach `/srv/models/{whisper,piper}` laden, mit sha256
- Compose nach `/opt/nex-speech` kopieren, `.env` aus den Rollen-Variablen schreiben
- bauen und starten, danach auf `/health` beider Dienste warten
- Ein zweiter Lauf ändert nichts (`changed=0`).
**Build:**
- Der erste whisper-Build dauert in der VM etwa 15 min.
- Das devel-Image (11,5 GB) bleibt als Cache liegen. Freier Platz auf `/` danach: 18 GB.
**Prüfen in der VM:**
- `curl -s localhost:8178/health; curl -s localhost:8179/health`
- `sudo docker compose -f /opt/nex-speech/docker-compose.yml ps`
- Logs mit `sudo docker logs nex-stt` bzw. `nex-tts`. Sie enthalten keine Texte.
**Firewall in der VM:**
- `sudo ufw status`
- Zum Testen von einem anderen Rechner eine Regel nur vorübergehend setzen: `sudo ufw insert 1 allow from <IP> to any port 8178:8179 proto tcp`, danach `sudo ufw delete allow from <IP> to any port 8178:8179 proto tcp`.
- Weitere Compose-Stacks in dieser VM mit veröffentlichten Ports (`ports:`) umgehen ufw. Für Sprachdienste deshalb im Host-Netz bleiben.
**Updates:**
- Ubuntu-Sicherheitsupdates laufen per `unattended-upgrades`. Der NVIDIA-Treiber ist gehalten.
- Images sind gepinnt. Ein Wechsel geht nur bewusst über die Rollenvariablen `nex_speech_env`:
- whisper: `WHISPER_TAG` und `WHISPER_COMMIT` gemeinsam
- Piper: `requirements.txt` und `PIPER_TTS_VERSION`
- Danach das Playbook erneut laufen lassen.
**Parameter ändern:** In `nex_speech_env`, z. B. `WHISPER_EXTRA_ARGS: "-nfa"` oder `PCM_SAMPLE_RATE: "0"`, dann das Playbook laufen lassen.
+338
View File
@@ -0,0 +1,338 @@
# KNOW: Befunde zur Datenhaltung (Analyse vom 13.10.2026)
Grundlage sind die Workflow-Definitionen auf n8n.mindboost.app, nur lesend über die n8n-API abgerufen. Ausgewertet wurde der Code von KNOW, KGW und allen aufrufenden Workflows. B11–B30 stammen aus der Vollanalyse in vier Ebenen:
1. Schema: Schlüssel, Indizes, Typen, nachträgliche Spalten.
2. SQL der 80 Operationen: Was wird geschrieben, was bei Konflikten nicht aktualisiert, was nie befüllt.
3. Abgleich mit den Payloads der Aufrufer.
4. Datenablagen außerhalb von KNOW: staticData, direkte Verbindungen, LLM-Aufrufe.
Jeder Befund wurde am Code nachgeprüft, Artefakte der automatischen Auswertung sind aussortiert. Inhalte der Datenbank wurden nicht gelesen. Für `CANCEL - Unsubscribe Link` und `KAN-Learn - Kanbau Pattern Learning` liefert die API keine gespeicherten Ausführungen, ein Nachweis aus der Laufzeit fehlt deshalb.
- Übersicht der Tabellen: [KNOW-schema.md](KNOW-schema.md)
- Interaktive Ansichten (privat):
- Datenfluss: https://claude.ai/artifact/GP8FfU8M6Wk9hPRmJuk96h
- Ontologie: https://claude.ai/artifact/R7zxBJsCKkkCWdCcztLFUT (Quelle: [ontology/know.yaml](../../ontology/know.yaml))
Status aller Befunde: **offen**. In n8n wurde nichts geändert.
| # | Befund | Schwere |
|---|---|---|
| B1 | CANCEL protokolliert über eine Operation, die es nicht gibt | mittel |
| B2 | KAN-Learn speichert über eine Operation, die es nicht gibt | mittel |
| B3 | IN-Sync ruft zwei nicht vorhandene Operationen auf (inaktiv) | niedrig |
| B4 | CAT schreibt per eigenem SQL an KNOW vorbei | niedrig |
| B5 | 12 Tabellen ohne aktiven Schreibweg, mit drei Ursachen | hoch (Datenbasis unklar) |
| B6 | KAF-Bestätigung führt bei jedem Aufruf DDL aus und setzt einen festen Bestätiger | mittel |
| B7 | Anhang-Extraktionen kommen nur von außen | niedrig |
| B8 | KGW erlaubt externen Aufrufern eine Lösch-Operation | niedrig |
| B9 | Keine Löschfristen für Klartext-Felder | mittel (Datenschutz) |
| B10 | Absender-Report zeigt eingefrorene Statistik | mittel |
| B11 | Kein einziger Fremdschlüssel; alle Verweise sind freier Text | hoch |
| B12 | Zwei Schlüsselschemata für Kontakte → Dubletten, Join findet nichts | hoch |
| B13 | Zwei Projekt-Kennungen, `projects` leer, Projekte kommen live aus KanBau | hoch |
| B14 | Zu enge Eindeutigkeit: Ereignisse und Prüfungen werden überschrieben bzw. verworfen | hoch |
| B15 | Fehlende Eindeutigkeit: jede Extraktion erzeugt eine neue Zeile | mittel |
| B16 | Drei Tabellen ohne Primärschlüssel, doppelte Indizes | niedrig |
| B17 | Upserts frieren Fachdaten ein (Kontakte, Ankaufsfälle, Falldokumente) | hoch |
| B18 | Gespeicherte abgeleitete Werte veralten (`deadline_is_overdue` u. a.) | mittel |
| B19 | Felder, die nie geschrieben werden (`confidence`, `confirmed_by`, `verified_at`) | mittel |
| B20 | Kategorien in mehreren Vokabularen; Safety-Floor-Kategorien über CAT unerreichbar | hoch |
| B21 | Postfach und Absender uneinheitlich normalisiert | mittel |
| B22 | Zwei Listen für Dokumenttypen | niedrig |
| B23 | Sechs Statusfelder rund um den Ankaufsfall | mittel |
| B24 | Schema-Drift: 45 nachgezogene Spalten, Laufzeit-DDL, keine Versionierung | mittel |
| B25 | Uneinheitliche Typen und Benennung (Geld als Text, Cent vs. Euro, deutsch/englisch) | niedrig |
| B26 | Fehlende Indizes auf Such- und Join-Spalten | niedrig |
| B27 | Ein Datenbankzugang für alles, geteilt mit dem Chat-Gedächtnis | mittel |
| B28 | Zustand in staticData von 9 Workflows, außerhalb von KNOW | mittel |
| B29 | Browser-Sessions (Cookies, Header) unverschlüsselt gespeichert | hoch (Sicherheit) |
| B30 | LLM-Aufrufe am PSE-LiteLLM vorbei, u. a. Kaufvertragstexte | hoch (Datenschutz) |
| B31 | Zwei Wörter für „bestätigt“: `verified` vs. `confirmed` | niedrig |
| B32 | KAN-Gateway ruft `get_kanbau_project_patterns` auf, die es nicht gibt | hoch |
| B33 | MPA speichert jedes Projektereignis als `info`/`normal` | hoch |
## B1 – CANCEL protokolliert ins Leere
- **Beleg:** `CANCEL - Unsubscribe Link` (aktiv) ruft in 4 Knoten (`KNOW: Log Erfolg`, `… Fehler`, `… Duplikat-Erfolg`, `… Dry Run`) `log_workflow_decision` auf. Diese Operation steht nicht in der Liste von KNOW, KNOW wirft `Nicht erlaubte KNOW operation`. Alle 4 Knoten haben „Continue on Fail“.
- **Folge:** Der Workflow läuft durch, aber kein Abmeldevorgang wird protokolliert, und niemand merkt es.
- **Vorschlag:** auf die vorhandene Operation `record_workflow_decision` umstellen. Sie schreibt in `workflow_decisions`, das heute leer bleibt (B5b). Vorher die Felder des Payloads abgleichen.
## B2 – KAN-Learn speichert nicht
- **Beleg:** `KAN-Learn - Kanbau Pattern Learning` (aktiv) ruft im Knoten `KNOW: Save Patterns` `upsert_kanbau_project_patterns` auf. Die Operation gibt es in KNOW nicht, der Knoten hat keine Fehlerbehandlung.
- **Folge:** Jede Ausführung bricht an dieser Stelle ab. KAN-Learn wird von `KAN - Kanbau API Gateway` aufgerufen.
- **Vorschlag:** In der Ausführungsliste von n8n prüfen, ob und wie oft der Workflow läuft. Dann entweder die Operation in KNOW ergänzen (Zieltabelle festlegen) oder den Aufruf entfernen.
## B3 – IN-Sync ruft Operationen auf, die fehlen
- **Beleg:** `IN - Sicherheitsregeln Source Sync` (inaktiv) ruft `upsert_input_validation_rule` und `upsert_input_validation_source_snapshot` auf. Beide fehlen in KNOW, ebenso eine passende Tabelle.
- **Vorschlag:** vor einer Aktivierung in KNOW ergänzen.
## B4 – CAT schreibt direkt in `workflow_hints`
- **Beleg:** Ein Postgres-Knoten in `CAT - Postfach Pipeline` führt `UPDATE knowledge.workflow_hints SET notified_at = now(), pending_notification = false WHERE hint_key = '{{ $json.hint_key }}'` aus.
- **Folge:**
- KNOW ist nicht mehr der einzige Schreibweg.
- Der Wert wird ohne Escaping direkt in das SQL eingesetzt. Ein Hochkomma in `hint_key` bricht die Abfrage.
- **Vorschlag:** eine KNOW-Operation dafür anlegen, z. B. `mark_hint_notified`.
## B5 – Tabellen ohne aktiven Schreibweg
Kein aktiver Workflow schreibt diese 12 Tabellen über KNOW. Die Ursachen sind verschieden:
**a) KNOW hat gar keine Schreib-Operation.** Gefüllt werden können diese Tabellen nur mit direktem Zugang zur Datenbank:
| Tabelle | Wer liest | Bemerkung |
|---|---|---|
| `projects` | `NEX - Teams Chat Agent`, extern über KGW (`find_project_candidates`, `get_sender_profile`) | Ist die Tabelle leer, findet die Projektsuche nur Treffer über `project_aliases`. **Zuerst prüfen**: `SELECT count(*), max(last_synced_at) FROM knowledge.projects;` |
| `contact_aliases` | extern über KGW (`find_contact_candidates`) | |
| `contact_project_relations` | niemand | |
| `agent_capabilities`, `agent_context_documents`, `agent_execution_log` | niemand | vermutlich Altlast oder außerhalb von n8n genutzt |
**b) Die Operation gibt es, aber kein Workflow ruft sie auf:**
| Tabelle | vorhandene Operationen | Wer liest |
|---|---|---|
| `property_development_cases` | `upsert_property_development_case`, `sync_property_development_folder_snapshot`, `store_kaf_confirmation_snapshot`, `claim_/complete_kaf_confirmation_create` | extern über KGW (6 Abfragen für Fristen, Angebote, fehlende Unterlagen) |
| `property_development_documents` | `upsert_property_development_document` | extern über KGW (`summarize_property_development_case`) |
| `project_folders` | `upsert_project_folder` | niemand |
| `place_enrichments` | `upsert_place_enrichment` | niemand (`MAP - Maps/Geo Gateway` nutzt KNOW nicht) |
| `workflow_decisions` | `record_workflow_decision` | niemand (siehe B1) |
Die Abfragen zu Ankaufsfällen über KGW (Hermes) liefern also nur Daten, die einmal anders hineingekommen sind. Über KGW selbst dürfen diese Tabellen nicht beschrieben werden.
**c) Gewollt:** `workflow_hint_config` wird von `setup_schema` mit 9 Kategorien befüllt (u. a. `mahnung`, `zahlungsaufforderung`, `newsletter_werbung`) und danach nur noch gelesen.
**Nächster Schritt:** einmal die Zeilenzahlen aller Tabellen und das jüngste `updated_at` abfragen. Erst dann ist klar, ob die Tabellen leer sind oder von außerhalb von n8n befüllt werden.
## B6 – KAF-Bestätigung: DDL im Anfragepfad, fester Bestätiger
- **Beleg:** Ruft jemand `upsert_property_development_case` mit `quality_status: confirmed` auf, führt KNOW einen Sonderweg aus (`Build KAF Case Confirmation SQL`). Dieser Sonderweg macht Folgendes:
- Er führt bei **jedem** Aufruf `ALTER TABLE … ADD COLUMN IF NOT EXISTS confirmed_by / confirmed_at` aus.
- Er setzt `confirmed_by` ohne Angabe auf `r.rapp@mygodel.de`.
- **Folge:**
- Die beiden Spalten fehlen in `setup_schema`.
- DDL im Anfragepfad braucht eine exklusive Tabellensperre.
- Bestätigungen ohne Absender werden einer bestimmten Person zugeschrieben.
- Heute ruft kein Workflow diesen Weg auf (B5b).
- **Vorschlag:** die Spalten in `setup_schema` aufnehmen, das `ALTER TABLE` entfernen und `confirmed_by` zur Pflichtangabe machen.
## B7 – `document_extractions` nur von außen
- **Beleg:** `store_document_extraction` ruft kein n8n-Workflow auf, auch `DOC - Dokumenten-Analyse` nicht. Geschrieben wird nur extern über KGW.
- **Vorschlag:** klären, ob DOC die Extraktionen speichern soll. Sie wären eine gute Quelle für Fälle bzw. Trainingsdaten in knaster.
## B8 – Lösch-Operation über KGW
- **Beleg:** Die Freigabeliste von KGW enthält `purge_spf_aliases`, ein `DELETE` auf `project_aliases`.
- **Vorschlag:** Prüfen, ob externe Aufrufer (Hermes, Skripte) das wirklich brauchen. Sonst streichen.
## B9 – Löschfristen
- **Beleg:** Folgende Felder speichern Klartext, eine Bereinigung ist nicht erkennbar:
- `llm_jobs.prompt` und `.result`
- `document_extractions.extracted_text`
- `property_development_cases.meeting_transcripts`
- `agent_context_documents.content`
- `mail_events` (Absender, Betreff)
`purge_*` gibt es nur für Absender-Beobachtungen und Aliase. `browser_sessions.session_data` ist unverschlüsselt, siehe B29.
- **Vorschlag:** Aufbewahrungsfristen festlegen, z. B. `llm_jobs` nach Abschluss nach 30 Tagen leeren. Zu `browser_sessions` siehe B29.
## B10 – Absender-Report zeigt eingefrorene Statistik
- **Beleg:**
- `ASP - Absender-Profil Backfill` schreibt die Statistik eines Absenders (`mail_count`, `dominant_category`, `avg_confidence` …) per `upsert_contact` in `contacts.raw_evidence_json`.
- `upsert_contact` aktualisiert bei einem vorhandenen Kontakt nur `display_name`, `normalized_name`, `last_seen_at` und `updated_at`, **nicht** `raw_evidence_json`.
- `ASP - Absender-Report` liest Kategorie und Mail-Anzahl per eigenem SQL aus genau diesem Feld.
- **Folge:** Der Report zeigt für jeden Absender die Werte vom ersten Backfill, auch wenn sich die Lage seitdem geändert hat. Die aktuellen Werte stehen in `sender_observations`, dort schreibt derselbe Backfill per `upsert_sender_observation` mit.
- **Ursache (ontologisch):** Das Absender-Profil gehört zur Klasse *Absender*, der Kontakt ist das fachliche Gegenüber. Beide sind über die E-Mail-Adresse verbunden. Liegt die Statistik zusätzlich im Kontakt, entsteht eine zweite Quelle, und die veraltet.
- **Vorschlag:** Den Report auf `sender_observations` umstellen und den Kontakt nur noch über `email` dazuholen. Die Statistik aus `upsert_contact` herausnehmen.
---
# Vollanalyse: B11–B30
## A. Integrität und Schlüssel
### B11 – Keine Fremdschlüssel
- **Beleg:** `setup_schema` enthält kein einziges `REFERENCES`.
- Alle Verweise (`project_key`, `contact_key`, `case_key`, `message_id`, `kuerzel`, `mac` …) sind freier Text ohne Prüfung.
- Auch Löschungen (`purge_*`, `reject_project_alias`) räumen abhängige Zeilen nicht auf.
- **Folge:** Verwaiste und ungültige Verweise sind jederzeit möglich, und es fällt nicht auf. Besonders betroffen ist `project_key`: Es wird in 7 Tabellen benutzt, aber `projects` wird nie befüllt (B13). Kein einziger Projektverweis lässt sich gegen eine Projekttabelle prüfen.
- **Vorschlag:**
- Für die Kernbezüge Fremdschlüssel einführen, zuerst `NOT VALID`, dann nach einer Bereinigung validieren: Falldokument → Ankaufsfall, Alias → Kontakt, Beteiligung → Kontakt und Projekt.
- Für `project_key` erst B13 lösen.
### B12 – Zwei Schlüsselschemata für Kontakte
- **Beleg:**
- `upsert_contact` bildet den Schlüssel standardmäßig als `normalisierter Name|E-Mail`. Kein Aufrufer übergibt einen eigenen Schlüssel.
- `update_sender_relevance` legt Kontakte mit dem Schlüssel `E-Mail` an, mit der E-Mail-Adresse als Anzeigename.
- `find_workflow_hints` verknüpft `workflow_hints.pattern_key` (Absenderadresse) mit `contacts.contact_key`.
- **Folge:**
- Ein Absender kann zweimal als Kontakt existieren.
- Der Relevanz-Score landet auf dem Schattenkontakt mit dem E-Mail-Schlüssel.
- Die vom Backfill angelegten Kontakte („Name|E-Mail“) findet der Join in `find_workflow_hints` nie.
- Ändert sich der Anzeigename, entsteht ein neuer Kontakt.
- **Vorschlag:** einen Schlüssel festlegen, z. B. die kleingeschriebene E-Mail für Personen und Absender, Namen nur noch als Attribut bzw. Alias. Bestehende Dubletten über `email` zusammenführen.
### B13 – Zwei Projekt-Kennungen, `projects` leer
- **Beleg:**
- Verweise nutzen `project_key`.
- Der Ankaufsfall speichert dagegen `selected_project_id`, eine `project_id` aus der KAF-Bestätigung.
- `projects` und `project_aliases` führen zusätzlich `kanbau_project_id`.
- Keine KNOW-Operation schreibt `projects` (B5a).
- `PRO - Project Assignment` fragt Projekte live bei `mygodel.kanbau.com` ab.
- **Folge:** Es gibt keine Stelle, an der `project_key` und die KanBau-ID verlässlich zusammengehören. Lesende Abfragen auf `projects` (NEX-Teams-Agent, `find_project_candidates`) finden vermutlich nur Treffer über Aliase.
- **Vorschlag:** einen Abgleich KanBau → `projects` anlegen (z. B. nachts, über das vorhandene KAN-Gateway) mit `kanbau_project_id` als festem Schlüssel. `project_key` daraus ableiten oder ersetzen.
### B14 – Zu enge Eindeutigkeit
- **`project_events`:** `UNIQUE(message_id)` mit `ON CONFLICT (message_id) DO UPDATE SET project_key, category, summary …`.
- Meldet eine Mail zwei Ereignisse oder betrifft sie zwei Projekte, überschreibt das zweite das erste.
- Vorschlag: Schlüssel `(message_id, project_key, category)`.
- **`kaf_review_snapshots`:** `UNIQUE(case_key)` mit Aktualisierung nur bei `review_status='pending'`.
- Nach einer Freigabe kann für denselben Fall kein neuer Vertragsstand gespeichert werden. Der Insert läuft ohne Fehler ins Leere.
- Vorschlag: eine Version pro Prüfung (`case_key, version`).
- **`mail_events`:** `UNIQUE(message_id)` ohne Postfach.
- Das ist unkritisch, solange die Graph-IDs pro Postfach verschieden sind. Bei Internet-Message-IDs würde eine Mail an zwei Postfächer zu einer Zeile.
- Prüfen, welche ID CAT und MPA übergeben.
### B15 – Fehlende Eindeutigkeit bei Extraktionen
- **Beleg:** `document_extractions` hat keinen eindeutigen Schlüssel, `store_document_extraction` macht ein reines `INSERT`.
- **Folge:** Jede erneute Verarbeitung desselben Anhangs erzeugt eine weitere Zeile, und es ist nicht festgelegt, welche gilt.
- **Vorschlag:** `UNIQUE(message_id, attachment_id)` mit Upsert.
### B16 – Tabellen ohne Primärschlüssel
- **Beleg:** `workflow_hints`, `workflow_hint_config` und `kanbau_method_examples` haben nur `UNIQUE`, keinen `PRIMARY KEY`. Auf `hint_key` und `method_key` liegt zusätzlich ein zweiter, überflüssiger Index.
- **Vorschlag:** den eindeutigen Schlüssel zum Primärschlüssel machen und die doppelten Indizes entfernen.
## B. Aktualität
### B17 – Upserts frieren Fachdaten ein
Bei einem vorhandenen Datensatz aktualisieren mehrere Upserts nur wenige Spalten. Alles andere behält den Wert vom ersten Insert:
- **`upsert_contact`:** aktualisiert nur Name, `last_seen_at` und `updated_at`. **E-Mail, Telefon, Website, Steuer-Nr., USt-IdNr., IBAN-Hash, Adresse, Kontakttyp, Qualitätsstatus und Belege** werden nie korrigiert. B10 ist ein Spezialfall davon.
- **`upsert_property_development_case`:** aktualisiert nur Name, die drei Statusfelder, `sharepoint_json` und `raw_evidence_json`. **Rund 50 Fachfelder bleiben eingefroren**, darunter Angebotsfrist, Kaufpreis, Fläche, `next_action`, `next_action_due_at`, Risiko, Unterlagen-Flags und Mitschriften.
- **`upsert_property_development_document`:** `is_current_version` und `supersedes_document_key` werden nie aktualisiert. Ein altes Dokument bleibt „aktuell“, die **Versionierung funktioniert nicht**. Ebenso bleiben Zusammenfassung, Absender und Dokumentdatum stehen.
- **Vorschlag:** In den Upserts alle fachlichen Felder mit `COALESCE(EXCLUDED.x, tabelle.x)` aktualisieren. Felder, die bewusst nur einmal gesetzt werden, im Code kennzeichnen.
### B18 – Gespeicherte abgeleitete Werte veralten
- **Beleg:**
- `deadline_is_overdue` ist eine gespeicherte Spalte. `find_cases_by_deadline` liest sie, sie lässt sich aber aus `offer_deadline` und dem heutigen Datum berechnen.
- `document_count`, `missing_documents` und `detected_documents` werden nur von `sync_property_development_folder_snapshot` gepflegt, und das ruft niemand auf (B5b).
- **Folge:** „Überfällig“ und „fehlende Unterlagen“ zeigen den Stand vom Einfügen.
- **Vorschlag:** `deadline_is_overdue` zur Laufzeit berechnen (View oder Ausdruck in der Abfrage). Die Zähler aus `property_development_documents` ableiten.
### B19 – Felder, die nie geschrieben werden
- **`workflow_hints.confidence`:** Keine Operation schreibt das Feld.
- CAT sendet die Konfidenz, aber innerhalb von `suggested_action`.
- `find_workflow_hints` sortiert nach der Spalte, die immer 0 ist. Die Reihenfolge der Hinweise ist damit zufällig.
- **`workflow_hints.confirmed_by`:** `promote_workflow_hint` setzt Status und Zeit, aber nicht die Person. Wer einen Hinweis über den Bestätigungslink freigegeben hat, wird nicht gespeichert.
- **`contacts.verified_at`, `project_aliases.verified_at`:** Keine Operation setzt sie. Das Bestätigungsmodell (`quality_status` + `verified_at`) ist nur halb umgesetzt.
- **Vorschlag:** `confidence` aus `suggested_action` in die Spalte übernehmen (auch rückwirkend). `confirmed_by` aus dem Bestätigungs-Webhook mitgeben.
## C. Vokabular und Normalisierung
### B20 – Kategorien in mehreren Vokabularen
- **Beleg:** Dieselbe Idee „Kategorie einer Mail“ existiert in mindestens sechs Listen:
- **CAT-Zuordnung:** 8 Werte, darunter `bauvorhaben` und `projekt`.
- **`workflow_hint_config.config_key`:** 9 Werte, darunter `mahnung`, `zahlungsaufforderung` und `default`.
- **`outlook_category`:** Anzeigenamen in Outlook.
- **`sender_observations.dominant_category`:** vom Absender-Backfill vergeben.
- **`project_events.category`:** 5 feste Werte.
- **`mail_events.category`:** frei. MPA schreibt hier den Workflow-Namen `"MPA"`.
- **Folge:**
- CAT erzeugt `bauvorhaben` und `projekt`, die Konfiguration kennt sie nicht, also greift `default`.
- Umgekehrt erzeugt CAT nie `mahnung` oder `zahlungsaufforderung`. **Die Regeln mit Safety-Floor („immer manuell prüfen“) greifen bei Hinweisen aus CAT nie.**
- Auswertungen nach Kategorie mischen fachliche Kategorien mit dem Workflow-Namen „MPA“.
- **Vorschlag:**
- Ein Vokabular als Tabelle bzw. SKOS-Konzeptschema in der Ontologie, mit Abbildung der Outlook-Namen.
- CAT um Mahnung und Zahlungsaufforderung erweitern.
- MPA schreibt seinen Status in `workflow_status`, nicht in `category`.
### B21 – Postfach und Absender uneinheitlich normalisiert
- **Postfach:** CAT setzt in `store_mail_event` fest `info@mygodel.de`, im CAT-Code kommt aber auch `r.rapp@mygodel.de` vor. MPA übergibt den `grant_key`. Der Absender-Backfill schreibt das Postfach kleingeschrieben.
- **Absender:** CAT schreibt ihn klein, MPA übergibt ihn unverändert. `sender_observations.email` ist immer klein.
- **Folge:** Die weichen Bezüge Mail ↔ Absender ↔ Kontakt (Ontologie) treffen nur teilweise. Steht ein falsches Postfach fest im Code, werden Mails dem falschen Postfach zugeordnet.
- **Vorschlag:** E-Mail-Adressen und Postfächer **in KNOW** normalisieren (kleinschreiben, Anzeigename abtrennen), nicht in jedem Aufrufer. In CAT das Postfach aus dem Trigger übernehmen.
### B22 – Zwei Listen für Dokumenttypen
- **Beleg:** `property_development_documents.document_type` hat 19 feste Werte. `document_extractions.document_type` ist frei, mit Standardwert `unknown`.
- **Vorschlag:** eine gemeinsame Liste (Ontologie-Klasse *Dokumenttyp*).
### B23 – Sechs Statusfelder rund um den Ankaufsfall
- **Beleg:** `status` (alt), `pipeline_status`, `outcome_status`, `review_status`, `confirmation_status` und `quality_status`. Dazu kommt die zweite Bestätigungslogik aus B6, die `quality_status='confirmed'` setzt, während der Token-Ablauf `confirmation_status` nutzt.
- **Folge:** Es ist nicht festgelegt, welches Feld „bestätigt“ bzw. „abgeschlossen“ bedeutet.
- **Vorschlag:** `status` (alt) aus `pipeline_status` und `outcome_status` ableiten oder entfernen. Eine Bestätigungslogik festlegen.
## D. Schema-Pflege
### B24 – Schema-Drift
- **Beleg:**
- 45 Spalten werden per `ALTER TABLE … ADD COLUMN IF NOT EXISTS` nachgezogen, davon 41 in `property_development_cases`. Bei `kanzlei_field_patterns` doppelt mit unterschiedlichem Standardwert (`'[]'` bzw. `'[]'::jsonb`).
- Dazu kommt Laufzeit-DDL in einer Anfrage (B6).
- Es gibt keine Schema-Version und keine Migrationshistorie.
- **Folge:** Welche Spalten eine Instanz hat, hängt davon ab, wann `setup_schema` zuletzt lief und ob der KAF-Sonderweg je ausgeführt wurde.
- **Vorschlag:** versionierte Migrationen (Tabelle `schema_migrations`), `setup_schema` nur noch als Migrationslauf.
### B25 – Uneinheitliche Typen und Benennung
- **Geld:** als Text (`commission_including_vat`, `godel_offer`), Beträge mal in Cent (`amount_cents BIGINT`), mal in Euro (`*_eur NUMERIC`).
- **Konfidenz:** mal `NUMERIC`, mal `DOUBLE PRECISION`.
- **Spaltennamen:** deutsch und englisch gemischt (`kuerzel`, `gesellschaft`, `quelle`, `bestaetigt`, `baustelle`, `absender_typ`, `mail_art`).
- **Zeitstempel:** `updated_at` fehlt u. a. in `project_aliases`, `contact_aliases`, `document_extractions` und `mail_events`.
- **Vorschlag:** bei der nächsten Migration vereinheitlichen. Niedrige Priorität, aber Voraussetzung für generierte Schemas (Ontologie → JSON-Schema).
### B26 – Fehlende Indizes
- **Beleg:** Ohne Index sind u. a.:
- `contacts.email` (Abgleich mit Absendern)
- `mail_events.sender`, `received_at`, `category`, `assigned_project_key` (`find_mail_events` filtert und sortiert danach)
- `document_extractions.message_id` und `project_key`
- `sender_observations.email` (`get_sender_profile`)
- `contact_project_relations`
- **Vorschlag:** Indizes auf diese Spalten. Unkritisch, solange die Tabellen klein sind.
## E. Ablage und Datenfluss außerhalb von KNOW
### B27 – Ein Datenbankzugang für alles
- **Beleg:** KNOW, die direkten SQL-Knoten in CAT und ASP, der Postgres-Healthcheck und der Credentials-Watchdog nutzen dasselbe Credential **„n8n Postgres memory account“**. Darüber läuft auch das Chat-Gedächtnis.
- **Folge:** Jeder Workflow mit diesem Credential kann jede Tabelle lesen, ändern und löschen. Die Freigabeliste in KNOW und KGW lässt sich so umgehen (B4 ist ein Beispiel).
- **Vorschlag:** eigene DB-Rolle für KNOW mit Rechten nur auf `knowledge`, getrennt vom Chat-Gedächtnis. Direkte SQL-Knoten auf eine Rolle nur mit Leserechten umstellen oder durch KNOW-Operationen ersetzen.
### B28 – Zustand in staticData
- **Beleg:** 9 Workflows speichern Zustand in n8n-staticData:
- CANCEL (`successful_unsubscribes`, wegen B1 die **einzige** Aufzeichnung der Abmeldungen)
- BEL (`bel_alerts`, `bel_setup_sent`)
- KJW (`nextLink`)
- TDO (`manual_enrichment`, `etag`)
- MCR (`last_reported_sha`)
- MPA (Token-Cache, verschlüsselt, in Ordnung)
- ASP-Scheduler, KBT und KBJ (inaktiv)
- **Folge:** Dieser Zustand ist in KNOW unsichtbar, hat keine Historie und geht bei einem Neuimport des Workflows verloren.
- **Vorschlag:** Fachlich relevante Zustände (CANCEL, BEL) nach KNOW verlagern. Technische Cursor (KJW, MCR) dürfen bleiben.
### B29 – Browser-Sessions unverschlüsselt
- **Beleg:** `BSB - Execute Login` speichert `session_data: { cookies, headers }` über `upsert_browser_session` als JSON im Klartext.
- **Folge:** Gültige Sitzungs-Cookies (u. a. für KanBau) liegen lesbar in der Datenbank. Jeder mit dem gemeinsamen Credential (B27) kann sie übernehmen. Zum Vergleich: Die Postfach-Tokens sind mit AES-256-GCM verschlüsselt, auch im Cache.
- **Vorschlag:** `session_data` genauso verschlüsseln wie `mailbox_grants`. Die Lebensdauer kurz halten (Standard 60 Min. ist gut).
### B30 – LLM-Aufrufe am PSE-LiteLLM vorbei
- **Beleg (direkte HTTP-Aufrufe, sicher am LiteLLM vorbei):**
- `KAF - Kaufvertrag Erkennung und Extraktion` (aktiv) schickt den **Kaufvertragstext** (`pdf_text`) direkt an `openrouter.ai`, ohne PSE-Aufruf.
- Ebenso direkt gehen `KAN - Kanbau API Gateway` (Intent-Erkennung), `BSB - Discover Session` (Login-Seiten an `api.anthropic.com`), `CDW` (Prüfaufruf) und `KSUM` (inaktiv).
- **Nicht entscheidbar:** Die OpenAI-Knoten in ASP, DOC, MAIL, CAT und OR nutzen das Credential `openAiApi`. Ob es auf den LiteLLM mit PSE zeigt, ist ohne Einsicht in das Credential nicht erkennbar. ASP ruft dabei kein PSE auf.
- **Folge:** Widerspricht der Vorgabe im Aufbauplan („Starke Modelle nur über den LiteLLM mit PSE“). Kaufverträge enthalten Namen, Anschriften und Preise.
- **Vorschlag:** KAF, KAN und BSB auf den LiteLLM mit PSE umstellen. Das Ziel des Credentials `openAiApi` prüfen.
## Geprüft, ohne Befund
- Postfach-Tokens (`mailbox_grants`) und der Token-Cache in MPA sind mit AES-256-GCM verschlüsselt.
- KNOW maskiert alle Werte über `sqlString`/`sqlJson`. Bei den Operationen gibt es keine offene String-Verkettung. Die Ausnahme ist der direkte SQL-Knoten in CAT (B4).
- KGW prüft HMAC und Zeitfenster und lässt nur eine feste Liste von Operationen zu.
- Die vermeintlich fehlende Spalte `workflow_decisions.decided_by` existiert (per `ALTER` ergänzt).
---
# Gefunden mit `knaster ontology check` (13.10.)
Diese Befunde hat der automatische Abgleich gefunden, nachdem die Ontologie im Repo als Quelle angelegt war (SPEC-ontology).
### B31 – Zwei Wörter für „bestätigt“
- **Beleg:** `record_workflow_decision` setzt `quality_status` auf `verified`. Alle anderen Tabellen und Operationen nutzen `confirmed`.
- **Folge:** Auswertungen über `quality_status = 'confirmed'` übersehen bestätigte Entscheidungen.
- **Stand in der Ontologie:** `verified` ist als alternative Schreibweise von `confirmed` im Vokabular *Qualitätsstatus* eingetragen. Vorschlag: in KNOW auf `confirmed` vereinheitlichen.
### B32 – KAN-Gateway ruft eine Operation auf, die es nicht gibt
- **Beleg:** `KAN - Kanbau API Gateway` (aktiv) ruft `get_kanbau_project_patterns` auf. Die Operation steht nicht in der Liste von KNOW. Das Gegenstück `upsert_kanbau_project_patterns` in KAN-Learn fehlt ebenso (B2).
- **Folge:** Gelernte Projektmuster für KanBau werden weder gespeichert noch gelesen. Je nach Fehlerbehandlung des Knotens bricht das Gateway an dieser Stelle ab.
- **Vorschlag:** die beiden Operationen samt Zieltabelle in KNOW anlegen (und dann in der Ontologie aufnehmen) oder die Aufrufe entfernen.
### B33 – Projektereignisse ohne Einordnung
- **Beleg:** `MPA - Projekt-Ablage` ruft `upsert_project_event` mit den festen Werten `category: "info"` und `urgency: "normal"` auf.
- **Folge:** Die Ereignisarten `verzoegerung`, `mangel`, `termin` und `kosten` (CHECK-Constraint der Tabelle) kommen nie vor, ebenso keine Dringlichkeit `high`. Eine Auswertung „Mängel pro Projekt“ ist mit diesen Daten nicht möglich.
- **Vorschlag:** Kategorie und Dringlichkeit aus der Mail-Analyse übernehmen (MAIL bzw. CAT kennen „Mangel“) oder ein kleines Modell bzw. einen knaster-Skill einordnen lassen.
+569
View File
@@ -0,0 +1,569 @@
# KNOW: Tabellen und Zugriffe (n8n, Schema `knowledge`)
Stand: 13.10.2026. Abgeleitet aus den Workflow-Definitionen auf n8n.mindboost.app, gelesen über die n8n-API. Inhalte der Datenbank wurden nicht gelesen.
Interaktive Ansichten (privat, Freigabe über das Share-Menü):
- Datenfluss: https://claude.ai/artifact/GP8FfU8M6Wk9hPRmJuk96h
- Ontologie: https://claude.ai/artifact/R7zxBJsCKkkCWdCcztLFUT (Quelle: [ontology/know.yaml](../../ontology/know.yaml), Turtle: [ontology/know.ttl](../../ontology/know.ttl))
Befunde mit Belegen, Folgen und Vorschlägen: [KNOW-befunde.md](KNOW-befunde.md) (B1–B33)
## Aufbau
- **KNOW - Knowledge Base Gateway** ist ein Sub-Workflow mit 80 fest benannten Operationen. Jede Operation ist auf eine SQL-Vorlage abgebildet, freies SQL gibt es nicht. Unbekannte Operationen lehnt KNOW mit `Nicht erlaubte KNOW operation` ab. Die Tabellen legt `setup_schema` an (Postgres, Erweiterung `pg_trgm`).
- **KGW - KNOW Query Gateway**: Eingang für externe Aufrufer (Hermes, Skripte) über `POST /webhook/know-query`.
- Authentifizierung: HMAC-SHA256 mit `X-KGW-Timestamp` und `X-KGW-Signature` (Schlüssel in `KGW_HMAC_KEY`), Zeitfenster 5 Min.
- Erlaubt sind 26 Operationen: alle Lese-Operationen plus Lern-Schreibzugriffe.
- Gesperrt sind `setup_schema` sowie die Operationen für BSB, KAF-Bestätigung, Kanzlei, Belege, Mailbox-Grants und LLM-Jobs.
- **Direktes SQL an KNOW vorbei**:
- `CAT - Postfach Pipeline` aktualisiert `workflow_hints` (`notified_at`, `pending_notification`).
- `ASP - Absender-Report` liest `contacts`.
## Auffälligkeiten (Kurzfassung, ausführlich in [KNOW-befunde.md](KNOW-befunde.md))
1. **`CANCEL - Unsubscribe Link`** (aktiv) ruft in 4 Knoten `log_workflow_decision` auf. KNOW kennt diese Operation nicht und lehnt ab. Weil „Continue on Fail“ gesetzt ist, läuft der Workflow weiter, aber es wird nichts protokolliert.
2. **`KAN-Learn - Kanbau Pattern Learning`** (aktiv) ruft `upsert_kanbau_project_patterns` auf. Auch diese Operation kennt KNOW nicht. Der Knoten hat keine Fehlerbehandlung, die Ausführung bricht dort ab.
3. **`IN - Sicherheitsregeln Source Sync`** (inaktiv) ruft `upsert_input_validation_rule` und `upsert_input_validation_source_snapshot` auf. Beide gibt es in KNOW nicht.
4. **Kein aktiver Workflow schreibt über KNOW** in: `contact_aliases`, `contact_project_relations`, `projects`, `project_folders`, `property_development_cases`, `property_development_documents`, `place_enrichments`, `workflow_decisions`, `workflow_hint_config`, `agent_capabilities`, `agent_context_documents`, `agent_execution_log`. Möglich sind Schreibzugriffe von außerhalb von n8n.
5. **`document_extractions`** wird nur von außen über KGW beschrieben.
6. **Löschfristen:** Für Klartext-Felder (`llm_jobs.prompt` und `.result`, `document_extractions.extracted_text`, `property_development_cases.meeting_transcripts`, `agent_context_documents.content`) ist keine Bereinigung erkennbar. Es gibt nur `purge_*`-Operationen für Absender-Beobachtungen und Aliase. Bei `browser_sessions.session_data` ist am Schema nicht erkennbar, ob es verschlüsselt ist.
## Bezug zu knaster
- **Task 6.4:** `find_active_llm_workers` ist über KGW von außen erlaubt. Die Operation liefert `mac, host, model, tier, tier_rank, status, endpoint, last_seen_at, age_seconds` und die Zahl wartender Jobs. Der Router kann aktive Worker damit ohne neuen Webhook lesen, braucht dafür aber den HMAC-Schlüssel.
- **Gleiche Idee wie die Skill-Graduierung:** `workflow_hint_config.min_observations_for_skip_ai` lässt die KI weg, sobald ein Muster oft genug beobachtet wurde. Aus `workflow_decisions` (Vorschlag vs. tatsächliche Entscheidung) ließen sich Präferenzpaare gewinnen.
## Tabellen
### Mail und Absender
#### `mail_events`
Eine Zeile pro verarbeiteter Mail: Kategorie, zugeordnetes Projekt, Status.
**Sensibel:** Absender und Betreff jeder verarbeiteten Mail.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | BCAT - Historische Mail-Analyse (inaktiv): `store_mail_event`<br>CAT - Postfach Pipeline: `store_mail_event`<br>MPA - Projekt-Ablage: `store_mail_event` |
| liest | KGW - KNOW Query Gateway: `find_mail_events, get_mail_events_summary, get_sender_profile`<br>MPA - Projekt-Ablage: `find_mail_events` |
<details><summary>11 Spalten</summary>
`id` BIGSERIAL, `message_id` TEXT, `mailbox` TEXT, `sender` TEXT, `subject` TEXT, `received_at` TIMESTAMPTZ, `category` TEXT, `assigned_project_key` TEXT, `workflow_status` TEXT, `raw_signals` JSONB, `last_processed_at` TIMESTAMPTZ
</details>
#### `sender_observations`
Absender-Profil je Postfach und Zeitfenster.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | ASP - Absender-Profil Backfill: `upsert_sender_observation` |
| liest | KGW - KNOW Query Gateway: `get_sender_profile, list_sender_domains` |
<details><summary>25 Spalten</summary>
`id` BIGSERIAL, `observation_key` TEXT, `mailbox` TEXT, `email` TEXT, `registered_domain` TEXT, `display_name` TEXT, `window_from` TIMESTAMPTZ, `window_to` TIMESTAMPTZ, `mail_count` INTEGER, `junk_count` INTEGER, `category_histogram` JSONB, `dominant_category` TEXT, `dominant_share` NUMERIC, `avg_confidence` NUMERIC, `absender_typ` TEXT, `mail_art` TEXT, `unsubscribe_url` TEXT, `unsubscribe_mailto` TEXT, `unsubscribe_one_click` BOOLEAN, `first_mail_at` TIMESTAMPTZ, `last_mail_at` TIMESTAMPTZ, `subjects_sample` JSONB, `source` TEXT, `created_at` TIMESTAMPTZ, `updated_at` TIMESTAMPTZ
</details>
#### `platform_domains`
Bekannte Plattform-Domains.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | ASP - Absender-Profil Backfill: `upsert_platform_domain` |
| liest | ASP - Absender-Profil Backfill: `find_platform_domain`<br>HMB - Postfach Backfill (inaktiv): `find_platform_domain`<br>KGW - KNOW Query Gateway: `get_sender_profile, list_sender_domains` |
<details><summary>9 Spalten</summary>
`id` BIGSERIAL, `registered_domain` TEXT, `display_name` TEXT, `status` TEXT, `source` TEXT, `evidence_json` JSONB, `first_seen_at` TIMESTAMPTZ, `last_seen_at` TIMESTAMPTZ, `updated_at` TIMESTAMPTZ
</details>
#### `mailbox_grants`
Zugriffsrechte auf Postfächer (delegierter Consent).
**Sensibel:** OAuth-Tokens, verschlüsselt (AES-256-GCM).
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | MPA - Delegated Consent: `upsert_mailbox_grant`<br>MPA - Get Mailbox Access: `upsert_mailbox_grant` |
| liest | MPA - Get Mailbox Access: `get_mailbox_grant`<br>MPA - Projekt-Ablage: `list_active_mailbox_grants` |
<details><summary>13 Spalten</summary>
`id` BIGSERIAL, `grant_key` TEXT, `provider` TEXT, `scope` TEXT, `token_ciphertext` TEXT, `token_iv` TEXT, `token_tag` TEXT, `token_alg` TEXT, `expires_hint` TIMESTAMPTZ, `status` TEXT, `source_workflow` TEXT, `created_at` TIMESTAMPTZ, `updated_at` TIMESTAMPTZ
</details>
### Kontakte und Projekte (KanBau)
#### `contacts`
Kontakte (Firmen, Personen).
**Sensibel:** Personenbezogen: E-Mail, Telefon, Steuer-Nr., USt-IdNr. (IBAN nur als Hash).
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | ASP - Absender-Profil Backfill: `upsert_contact`<br>BCAT - Historische Mail-Analyse (inaktiv): `update_sender_relevance`<br>KGW - KNOW Query Gateway: `update_sender_relevance, upsert_contact` |
| liest | ASP - Absender-Report: `SELECT (Report)`<br>CAT - Postfach Pipeline: `find_workflow_hints`<br>KGW - KNOW Query Gateway: `find_contact_candidates, find_workflow_hints, get_sender_profile`<br>OR - PDF and E-Rechnung Extraction (inaktiv): `find_workflow_hints` |
<details><summary>24 Spalten</summary>
`id` BIGSERIAL, `contact_key` TEXT, `contact_type` TEXT, `display_name` TEXT, `legal_name` TEXT, `normalized_name` TEXT, `email` TEXT, `phone` TEXT, `website` TEXT, `tax_id` TEXT, `vat_id` TEXT, `iban_hash` TEXT, `address_json` JSONB, `source` TEXT, `source_confidence` NUMERIC, `kanbau_address_id` TEXT, `raw_evidence_json` JSONB, `quality_status` TEXT, `first_seen_at` TIMESTAMPTZ, `last_seen_at` TIMESTAMPTZ, `verified_at` TIMESTAMPTZ, `created_at` TIMESTAMPTZ, `updated_at` TIMESTAMPTZ, `business_relevance_score` NUMERIC
</details>
#### `contact_aliases`
Schreibweisen von Kontakten.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | – |
| liest | KGW - KNOW Query Gateway: `find_contact_candidates` |
<details><summary>11 Spalten</summary>
`id` BIGSERIAL, `alias_key` TEXT, `contact_key` TEXT, `alias_type` TEXT, `alias_value` TEXT, `normalized_value` TEXT, `source_workflow` TEXT, `confidence` NUMERIC, `evidence_json` JSONB, `quality_status` TEXT, `created_at` TIMESTAMPTZ
</details>
#### `contact_project_relations`
Kontakt ↔ Projekt.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | – |
| liest | – |
<details><summary>12 Spalten</summary>
`id` BIGSERIAL, `relation_key` TEXT, `contact_key` TEXT, `project_key` TEXT, `relation_type` TEXT, `confidence` NUMERIC, `source_workflow` TEXT, `evidence_json` JSONB, `valid_from` DATE, `valid_to` DATE, `quality_status` TEXT, `created_at` TIMESTAMPTZ
</details>
#### `projects`
KanBau-Projekte mit Adresse.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | – |
| liest | KGW - KNOW Query Gateway: `find_project_candidates, get_sender_profile`<br>NEX - Teams Chat Agent: `find_project_candidates` |
<details><summary>17 Spalten</summary>
`id` BIGSERIAL, `project_key` TEXT, `kanbau_project_id` TEXT, `project_number` TEXT, `project_name` TEXT, `short_code` TEXT, `company_context` TEXT, `street` TEXT, `zip` TEXT, `city` TEXT, `full_address` TEXT, `normalized_address` TEXT, `status` TEXT, `raw_kanbau_json` JSONB, `last_synced_at` TIMESTAMPTZ, `created_at` TIMESTAMPTZ, `updated_at` TIMESTAMPTZ
</details>
#### `project_aliases`
Aliase und gelernte Tokens zu Projekten.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | KGW - KNOW Query Gateway: `purge_spf_aliases, upsert_project_alias`<br>MPA - Projekt-Ablage: `reject_project_alias, upsert_project_alias, upsert_project_alias_tokens` |
| liest | KGW - KNOW Query Gateway: `find_project_candidates, get_sender_profile, list_sender_domains`<br>NEX - Teams Chat Agent: `find_project_candidates`<br>PRO - Project Assignment: `find_project_by_alias` |
<details><summary>13 Spalten</summary>
`id` BIGSERIAL, `alias_key` TEXT, `project_key` TEXT, `kanbau_project_id` TEXT, `alias_type` TEXT, `alias_value` TEXT, `normalized_value` TEXT, `source_workflow` TEXT, `confidence` NUMERIC, `evidence_json` JSONB, `quality_status` TEXT, `verified_at` TIMESTAMPTZ, `created_at` TIMESTAMPTZ
</details>
#### `project_events`
Ereignisse aus Mails (Verzögerung, Mangel, Termin, Kosten).
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | MPA - Projekt-Ablage: `upsert_project_event` |
| liest | – |
<details><summary>15 Spalten</summary>
`id` BIGSERIAL, `message_id` TEXT, `project_key` TEXT, `mailbox` TEXT, `sender` TEXT, `event_date` TIMESTAMPTZ, `category` TEXT, `affected_unit` TEXT, `urgency` TEXT, `summary` TEXT, `sharepoint_path` TEXT, `quality_status` TEXT, `source_workflow` TEXT, `created_at` TIMESTAMPTZ, `updated_at` TIMESTAMPTZ
</details>
#### `project_folders`
SharePoint-Ordner je Projekt.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | – |
| liest | – |
<details><summary>10 Spalten</summary>
`id` BIGSERIAL, `project_key` TEXT, `drive_id` TEXT, `folder_item_id` TEXT, `folder_path` TEXT, `folder_name` TEXT, `verified_at` TIMESTAMPTZ, `source_workflow` TEXT, `created_at` TIMESTAMPTZ, `updated_at` TIMESTAMPTZ
</details>
#### `project_companies`
Kürzel → Gesellschaft.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | RAR - Rechnungsanschrift pruefen: `record_invoice_company` |
| liest | RAR - Rechnungsanschrift pruefen: `get_project_company` |
<details><summary>8 Spalten</summary>
`kuerzel` TEXT, `gesellschaft` TEXT, `quelle` TEXT, `bestaetigt` BOOLEAN, `baustelle` TEXT, `evidence_json` JSONB, `created_at` TIMESTAMPTZ, `updated_at` TIMESTAMPTZ
</details>
#### `invoice_company_observations`
Gesellschaft aus Rechnungen beobachtet.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | RAR - Rechnungsanschrift pruefen: `record_invoice_company` |
| liest | RAR - Rechnungsanschrift pruefen: `get_project_company` |
<details><summary>8 Spalten</summary>
`message_id` TEXT, `file_name` TEXT, `kuerzel` TEXT, `gesellschaft` TEXT, `mailbox` TEXT, `source_workflow` TEXT, `created_at` TIMESTAMPTZ, `updated_at` TIMESTAMPTZ
</details>
### Dokumente
#### `document_extractions`
Extraktionen aus Anhängen.
**Sensibel:** Extrahierter Volltext von Anhängen.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | KGW - KNOW Query Gateway: `store_document_extraction` |
| liest | – |
<details><summary>17 Spalten</summary>
`id` BIGSERIAL, `request_id` TEXT, `message_id` TEXT, `mailbox` TEXT, `attachment_id` TEXT, `file_name` TEXT, `content_type` TEXT, `extraction_source` TEXT, `document_type` TEXT, `sender_contact_key` TEXT, `project_key` TEXT, `extracted_text` TEXT, `structured_json` JSONB, `detected_addresses_json` JSONB, `detected_contacts_json` JSONB, `confidence` NUMERIC, `created_at` TIMESTAMPTZ
</details>
### Ankauf und Projektentwicklung (KAF)
#### `property_development_cases`
Ankaufsfälle (~70 Felder).
**Sensibel:** Kaufpreise, Provisionen und Besprechungsmitschriften.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | – |
| liest | KGW - KNOW Query Gateway: `find_cases_by_deadline, find_cases_missing_documents, find_cases_needing_next_action, find_cases_with_offer_status, find_property_development_candidates, summarize_property_development_case` |
<details><summary>73 Spalten</summary>
`id` BIGSERIAL, `case_key` TEXT, `name` TEXT, `normalized_name` TEXT, `status` TEXT, `pipeline_status` TEXT, `outcome_status` TEXT, `case_code` TEXT, `case_opened_at` DATE, `folder_name` TEXT, `folder_path` TEXT, `folder_status_source` TEXT, `source_channel` TEXT, `location_city` TEXT, `location_district` TEXT, `street_address` TEXT, `assigned_to` TEXT, `asset_type` TEXT, `process_type` TEXT, `notes` TEXT, `property_size_sqm` NUMERIC, `original_purchase_price_eur` NUMERIC, `average_price_per_sqm_eur` NUMERIC, `commission_including_vat` TEXT, `apartment_count` INTEGER, `development_plan_available` BOOLEAN, `godel_internal` BOOLEAN, `contact_broker` TEXT, `start_date` DATE, `offer_deadline` DATE, `offer_submitted_at` DATE, `last_offer_at` DATE, `offer_count` INTEGER, `review_status` TEXT, `review_result` TEXT, `meeting_transcripts` TEXT, `just_one_week_more` BOOLEAN, `deadline_is_overdue` BOOLEAN, `godel_offer` TEXT, `project_lead` TEXT, `planning` TEXT, `nda_present` BOOLEAN, `loi_present` BOOLEAN, `bplan_document_present` BOOLEAN, `wirtschaftlichkeit_present` BOOLEAN, `purchase_offer_present` BOOLEAN, `building_permit_present` BOOLEAN, `risk_summary` TEXT, `missing_documents` JSONB, `next_action` TEXT, `next_action_due_at` DATE, `last_document_at` DATE, `document_count` INTEGER, `case_readiness` TEXT, `detected_documents` JSONB, `risk_level` TEXT, `readiness_json` JSONB, `source_system` TEXT, `sharepoint_json` JSONB, `raw_evidence_json` JSONB, `quality_status` TEXT, `confidence` NUMERIC, `first_seen_at` TIMESTAMPTZ, `last_seen_at` TIMESTAMPTZ, `created_at` TIMESTAMPTZ, `updated_at` TIMESTAMPTZ, `confirmation_token_hash` TEXT, `confirmation_status` TEXT, `confirmation_expires_at` TIMESTAMPTZ, `selected_project_id` TEXT, `created_saleagr_id` TEXT, `created_saleagr_url` TEXT, `confirmation_request_id` TEXT, `confirmed_by` TEXT (Laufzeit-DDL, B6), `confirmed_at` TIMESTAMPTZ (Laufzeit-DDL, B6)
</details>
#### `property_development_documents`
Dokumente je Ankaufsfall.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | – |
| liest | KGW - KNOW Query Gateway: `summarize_property_development_case` |
<details><summary>20 Spalten</summary>
`id` BIGSERIAL, `case_key` TEXT, `document_key` TEXT, `sharepoint_item_id` TEXT, `file_name` TEXT, `file_path` TEXT, `folder_segment` TEXT, `document_type` TEXT, `document_date` DATE, `received_at` DATE, `author_or_sender` TEXT, `source_party` TEXT, `extracted_summary` TEXT, `extracted_fields_json` JSONB, `confidence` NUMERIC, `is_current_version` BOOLEAN, `supersedes_document_key` TEXT, `processing_status` TEXT, `created_at` TIMESTAMPTZ, `updated_at` TIMESTAMPTZ
</details>
#### `kaf_review_snapshots`
Prüfstände von Kaufverträgen.
**Sensibel:** Kaufvertragsfelder und Prüfer.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | KAF - Bestätigung Webhook: `complete_kaf_review`<br>KAF - Kaufvertrag Erkennung und Extraktion: `store_kaf_review_snapshot` |
| liest | KAF - Bestätigung Webhook: `get_kaf_review_snapshot` |
<details><summary>22 Spalten</summary>
`id` BIGSERIAL, `case_key` TEXT, `review_schema_version` TEXT, `confirmation_token_hash` TEXT, `confirmation_expires_at` TIMESTAMPTZ, `review_status` TEXT, `request_id` TEXT, `review_to` TEXT, `delivery_mode` TEXT, `original_json` JSONB, `distribution_json` JSONB, `fields_json` JSONB, `units_json` JSONB, `artifacts_json` JSONB, `reviewed_values_json` JSONB, `review_comment` TEXT, `reviewed_by` TEXT, `review_identity_source` TEXT, `review_submission_hash` TEXT, `reviewed_at` TIMESTAMPTZ, `created_at` TIMESTAMPTZ, `updated_at` TIMESTAMPTZ
</details>
#### `kanzlei_field_patterns`
Feldmuster je Notarkanzlei.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | KAF - Kaufvertrag Erkennung und Extraktion: `upsert_kanzlei_field_patterns` |
| liest | KAF - Kaufvertrag Erkennung und Extraktion: `get_kanzlei_field_patterns` |
<details><summary>4 Spalten</summary>
`kanzlei_key` TEXT, `kanzlei_display` TEXT, `patterns` JSONB, `updated_at` TIMESTAMPTZ
</details>
#### `kanzlei_project_relations`
Kanzlei ↔ Projekt.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | KAF - Kaufvertrag Erkennung und Extraktion: `upsert_kanzlei_project_relation` |
| liest | – |
<details><summary>6 Spalten</summary>
`relation_key` TEXT, `kanzlei_key` TEXT, `project_key` TEXT, `project_name` TEXT, `notar_name` TEXT, `updated_at` TIMESTAMPTZ
</details>
#### `place_enrichments`
Adresse → Flurstück, Koordinaten.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | – |
| liest | – |
<details><summary>15 Spalten</summary>
`id` BIGSERIAL, `place_key` TEXT, `input_address` TEXT, `match_address` TEXT, `flurstueck` TEXT, `lat` DOUBLE, `lon` DOUBLE, `bundesland` TEXT, `enrichment_json` JSONB, `provenance_json` JSONB, `quality_status` TEXT, `confidence` DOUBLE, `source_workflow` TEXT, `created_at` TIMESTAMPTZ, `refreshed_at` TIMESTAMPTZ
</details>
### Buchhaltung
#### `bel_processed_belege`
Verarbeitete SEPA-Belege.
**Sensibel:** Bank, Konto-Referenz und Beträge.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | BEL - SEPA-Beleg-Verteilung: `confirm_beleg_uploaded, reserve_beleg` |
| liest | – |
<details><summary>15 Spalten</summary>
`id` BIGSERIAL, `beleg_hash` TEXT, `status` TEXT, `bank` TEXT, `account_ref` TEXT, `amount_cents` BIGINT, `sharepoint_path` TEXT, `export_ref` TEXT, `request_id` TEXT, `raw_evidence_json` JSONB, `reserved_at` TIMESTAMPTZ, `uploaded_at` TIMESTAMPTZ, `last_seen_at` TIMESTAMPTZ, `created_at` TIMESTAMPTZ, `updated_at` TIMESTAMPTZ
</details>
### Lernen und Workflow-Gedächtnis
#### `workflow_decisions`
Vorschlag vs. tatsächliche Entscheidung.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | – |
| liest | – |
<details><summary>13 Spalten</summary>
`id` BIGSERIAL, `request_id` TEXT, `workflow` TEXT, `reference_type` TEXT, `reference_id` TEXT, `proposed_decision` JSONB, `final_decision` JSONB, `confidence` NUMERIC, `correction_reason` TEXT, `quality_status` TEXT, `created_at` TIMESTAMPTZ, `verified_at` TIMESTAMPTZ, `decision_type` TEXT, `decided_by` TEXT, `updated_at` TIMESTAMPTZ
</details>
#### `workflow_hints`
Gelernte Muster mit Bestätigung.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | ASP - Absender-Profil Backfill: `backfill_workflow_hint`<br>CAT - Postfach Pipeline: `UPDATE … SET notified_at, pending_notification, upsert_workflow_hint`<br>KAN - Kanbau API Gateway: `upsert_workflow_hint`<br>KGW - KNOW Query Gateway: `promote_workflow_hint, reject_workflow_hint, upsert_workflow_hint`<br>KNOW - Hint Confirmation Webhook: `promote_workflow_hint, reject_workflow_hint`<br>OR - PDF and E-Rechnung Extraction (inaktiv): `upsert_workflow_hint` |
| liest | CAT - Postfach Pipeline: `find_workflow_hints, get_pending_hint_proposals`<br>KGW - KNOW Query Gateway: `find_workflow_hints, get_pending_hint_proposals, get_sender_profile, summarize_workflow_lessons`<br>OR - PDF and E-Rechnung Extraction (inaktiv): `find_workflow_hints` |
<details><summary>15 Spalten</summary>
`hint_key` TEXT, `workflow` TEXT, `hint_type` TEXT, `pattern_key` TEXT, `pattern_value` TEXT, `suggested_action` JSONB, `observation_count` INTEGER, `confidence` NUMERIC, `quality_status` TEXT, `pending_notification` BOOLEAN, `notified_at` TIMESTAMPTZ, `confirmed_at` TIMESTAMPTZ, `confirmed_by` TEXT, `created_at` TIMESTAMPTZ, `updated_at` TIMESTAMPTZ
</details>
#### `workflow_hint_config`
Ab wann die KI übersprungen wird.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | – |
| liest | BCAT - Historische Mail-Analyse (inaktiv): `update_sender_relevance`<br>CAT - Postfach Pipeline: `find_workflow_hints, get_pending_hint_proposals, upsert_workflow_hint`<br>KAN - Kanbau API Gateway: `upsert_workflow_hint`<br>KGW - KNOW Query Gateway: `find_workflow_hints, get_hint_config, get_pending_hint_proposals, summarize_workflow_lessons, update_sender_relevance, upsert_workflow_hint`<br>OR - PDF and E-Rechnung Extraction (inaktiv): `find_workflow_hints, upsert_workflow_hint` |
<details><summary>10 Spalten</summary>
`config_key` TEXT, `min_observations_for_skip_ai` INTEGER, `priority_level` TEXT, `preferred_model` TEXT, `safety_floor` BOOLEAN, `relevance_weight` NUMERIC, `outlook_category` TEXT, `requires_action` BOOLEAN, `description` TEXT, `updated_at` TIMESTAMPTZ
</details>
#### `kanbau_method_examples`
Beispiele je KanBau-API-Methode.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | KAN - Kanbau API Gateway: `upsert_kanbau_method_example` |
| liest | KGW - KNOW Query Gateway: `get_kanbau_method_example` |
<details><summary>7 Spalten</summary>
`method_key` TEXT, `swagger_ref` TEXT, `example_request` JSONB, `example_response` JSONB, `validated_at` TIMESTAMPTZ, `created_at` TIMESTAMPTZ, `updated_at` TIMESTAMPTZ
</details>
### Agent
#### `agent_capabilities`
Fähigkeiten eines Agenten.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | – |
| liest | – |
<details><summary>14 Spalten</summary>
`id` BIGSERIAL, `scope_key` TEXT, `capability_key` TEXT, `title` TEXT, `description` TEXT, `intent_examples` JSONB, `workflow_name` TEXT, `workflow_operation` TEXT, `read_or_write` TEXT, `risk_level` TEXT, `requires_approval` BOOLEAN, `enabled` BOOLEAN, `created_at` TIMESTAMPTZ, `updated_at` TIMESTAMPTZ
</details>
#### `agent_context_documents`
Kontextdokumente für Agenten.
**Sensibel:** Volltext eingelesener Dokumente.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | – |
| liest | – |
<details><summary>8 Spalten</summary>
`id` BIGSERIAL, `scope_key` TEXT, `source_path` TEXT, `document_type` TEXT, `title` TEXT, `content` TEXT, `content_hash` TEXT, `last_ingested_at` TIMESTAMPTZ
</details>
#### `agent_execution_log`
Protokoll der Agent-Aufrufe.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | – |
| liest | – |
<details><summary>10 Spalten</summary>
`id` BIGSERIAL, `request_id` TEXT, `agent_scope` TEXT, `user_request` TEXT, `selected_capability` TEXT, `workflow_called` TEXT, `inputs_json` JSONB, `status` TEXT, `errors_json` JSONB, `created_at` TIMESTAMPTZ
</details>
### Browser-Automation (BSB)
#### `session_providers`
Login-Abläufe für Browser-Automation.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | BSB - Discover Session: `upsert_session_provider` |
| liest | BSB - Get Session: `get_session_provider` |
<details><summary>10 Spalten</summary>
`provider_id` TEXT, `display_name` TEXT, `login_url` TEXT, `auth_type` TEXT, `steps_json` JSONB, `credential_key` TEXT, `max_session_age_minutes` INTEGER, `notes` TEXT, `created_at` TIMESTAMPTZ, `updated_at` TIMESTAMPTZ
</details>
#### `browser_sessions`
Gespeicherte Browser-Sessions.
**Sensibel:** Session-Daten als JSON; am Schema nicht erkennbar, ob verschlüsselt.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | BSB - Execute Login: `upsert_browser_session` |
| liest | BSB - Get Session: `get_browser_session` |
<details><summary>5 Spalten</summary>
`provider_id` TEXT, `session_data` JSONB, `expires_at` TIMESTAMPTZ, `created_at` TIMESTAMPTZ, `updated_at` TIMESTAMPTZ
</details>
### LLM-Worker
#### `llm_workers`
Registrierte lokale LLM-Worker.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | LLM - Job Poll: `claim_llm_job`<br>LLM - Worker Registry: `update_llm_worker_status, upsert_llm_worker` |
| liest | KGW - KNOW Query Gateway: `find_active_llm_workers`<br>LLM - Router: `find_active_llm_workers` |
<details><summary>12 Spalten</summary>
`id` BIGSERIAL, `mac` TEXT, `host` TEXT, `model` TEXT, `tier` TEXT, `tier_rank` SMALLINT, `status` TEXT, `hardware_json` JSONB, `last_seen_at` TIMESTAMPTZ, `registered_at` TIMESTAMPTZ, `updated_at` TIMESTAMPTZ, `endpoint` JSONB
</details>
#### `llm_worker_blocklist`
Gesperrte Worker.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | LLM - Worker Registry: `blocklist_llm_worker` |
| liest | – |
<details><summary>6 Spalten</summary>
`id` BIGSERIAL, `mac` TEXT, `host` TEXT, `reason` TEXT, `hardware_json` JSONB, `blocked_at` TIMESTAMPTZ
</details>
#### `llm_jobs`
Job-Board für lokale LLMs.
**Sensibel:** Prompt und Ergebnis im Klartext.
| Zugriff | Workflows und Operationen |
|---|---|
| schreibt | LLM - Job Poll: `claim_llm_job`<br>LLM - Job Submit: `enqueue_llm_job`<br>LLM - Result Handler: `complete_llm_job`<br>LLM - Router: `cancel_llm_job, enqueue_llm_job` |
| liest | KGW - KNOW Query Gateway: `find_active_llm_workers`<br>LLM - Router: `find_active_llm_workers, get_llm_job` |
<details><summary>16 Spalten</summary>
`id` BIGSERIAL, `job_id` TEXT, `prompt` TEXT, `min_tier` TEXT, `min_tier_rank` SMALLINT, `status` TEXT, `assigned_to` TEXT, `model` TEXT, `tier` TEXT, `result` TEXT, `duration_ms` INTEGER, `error_message` TEXT, `created_at` TIMESTAMPTZ, `assigned_at` TIMESTAMPTZ, `completed_at` TIMESTAMPTZ, `updated_at` TIMESTAMPTZ
</details>
+1 -1
View File
@@ -29,7 +29,7 @@ Deterministisch:
- `prefer: largest` wählt die GPU mit dem meisten VRAM (für LLM-Rollen).
- `prefer: smallest_fitting` wählt die kleinste passende GPU (für Embedding und Rerank). So bleiben große Karten frei.
3. Rollen mit `share_with: <rolle>` teilen sich die GPU (bzw. bei `gpu: false` den Node) einer bereits platzierten Rolle. Ein Beispiel ist TTS auf `gpu-flex`.
4. Rollen mit `gpu: false` bzw. `cpu_fallback: true` werden ohne passende GPU den CPU-Nodes zugewiesen, sortiert nach RAM.
4. Rollen mit `gpu: false` bzw. `cpu_fallback: true` werden ohne passende GPU den CPU-Nodes zugewiesen, sortiert nach RAM. Nodes mit `cpu_roles: false` (Arbeitsplätze, Kiosk) sind davon ausgenommen. Dort wird nur die eigene GPU verplant.
5. Pro zugewiesener Rolle werden die Katalogmodelle gelistet, die passen (Engine, VRAM, CC).
6. Ergebnis: `assignments[{role, node, gpu_index?, models_fitting[]}]` und `unplaced[{role, reason}]`.
+100
View File
@@ -0,0 +1,100 @@
# SPEC-ontology: Ontologie der Datenhaltung
Paket: `knaster.ontology`. Quelle: `ontology/know.yaml`. CLI: `knaster ontology …`.
Die Ontologie beschreibt, *was* in der Datenhaltung steht (Projekt, Kontakt, Ankaufsfall, Mail …) und wie die Begriffe zusammenhängen. Tabellen sind nur die Ablage dahinter. Sie ist die eine Quelle der Wahrheit; Turtle, Graph und Ansicht werden daraus erzeugt, nie von Hand gepflegt.
## 1. Drei Ebenen, drei Pflegeregeln
| Ebene | Inhalt in `know.yaml` | Wie sie sich ändert |
|---|---|---|
| Kern | `classes`, `relations`, `modules` | bewusst, per Commit (Review wie Code) |
| Vokabulare | `vocabularies.*.concepts`, `alt`, `rejected` | wachsen über Vorschläge der Service-Routine → Freigabe → `knaster ontology apply` |
| Instanzen | – (stehen in den Daten) | automatisch; unsichere Verknüpfungen später ebenfalls über Vorschläge |
Ohne diese Regeln zerfällt ein Vokabular wieder in mehrere Listen (KNOW-Befund B20). Deshalb legt nie ein Werkzeug oder LLM selbstständig Klassen oder Konzepte an; es schlägt sie vor.
## 2. Modell (`know.yaml`)
```yaml
id: know
version: 3 # steigt bei jeder Übernahme von Entscheidungen
namespace: urn:mygodel:know#
modules: {mail: Mail & Absender, …}
classes:
Mail:
label: Mail
module: mail
table: mail_events # fehlt = implizite Klasse (nur Wert in Spalten) oder externes System
key: message_id
definition: …
sensitive: … # steuert Datenschutz-Regeln (geplant: PSE/lokal)
subclass_of: Dokument # optional
attributes: {message_id: TEXT, …} # Spalten der Tabelle, Abbild des KNOW-Schemas
relations:
- {source: Mail, name: zugeordnetZu, target: Projekt, via: mail_events.assigned_project_key, kind: key}
# kind: key (gemeinsamer Schlüssel) | soft (Wertabgleich) | external (ID in KanBau/SharePoint/M365)
vocabularies:
MailKategorie:
label: Mail-Kategorie
class: MailKategorie # optional: Konzepte sind Ausprägungen dieser Klasse
columns: [mail_events.category, workflow_hints.hint_type, workflow_hint_config.config_key]
concepts:
buchhaltung: {label: Rechnungen, alt: [Buchhaltung]}
default: {label: Standard, technical: true}
rejected: [MPA] # bewusst kein Konzept
ignore: [sql:ASP - Absender-Report:…] # bewusst hingenommene Befunde
```
`Ontology.validate_model()` prüft: Module, Oberklassen und Beziehungsenden existieren; jede Tabelle gehört zu höchstens einer Klasse; Vokabel-Spalten existieren als Attribut; kein Name zeigt auf zwei Konzepte; kein verworfener Wert ist zugleich Konzept. `knaster validate` prüft das in der CI und warnt, wenn `know.ttl` nicht zur YAML passt.
## 3. Abgleich mit n8n (`check`)
Eingabe ist eine Momentaufnahme der n8n-Workflows (`build_snapshot`, nur GET `/api/v1/workflows` bzw. eine JSON-Datei):
- **KNOW-Spezifikation** aus dem Code des KNOW-Workflows: erlaubte Operationen (`const allowed`), Schema (`CREATE TABLE`, `ALTER … ADD COLUMN`), pro Operation geschriebene Spalten und dabei gesetzte Literale, Seeds (reine Literal-INSERTs).
- **Aufrufe** von KNOW mit Operation und Payload-Ausdruck. Wird die Operation erst zur Laufzeit gesetzt, werden Kandidaten aus den Literalen des Workflows abgeleitet (`inferred`).
- **Direktes SQL**: Postgres-Knoten außerhalb von KNOW, die das Schema `knowledge` ansprechen.
Ergebnisse (`OntologyFinding`, sortiert nach Schwere):
| Art | Schwere | Vorschlag? |
|---|---|---|
| `unmapped_table` – Tabelle ohne Klasse | medium | ja: `add_class` |
| `unmapped_column` – Spalte ohne Attribut | low | ja: `add_attribute` |
| `vocab_value` – Wert in einer Vokabel-Spalte, der kein Konzept ist | medium (low, wenn nur aus inaktiven Workflows) | ja: aufnehmen, zuordnen oder verwerfen |
| `unknown_operation` – Aufruf einer Operation, die KNOW nicht kennt | high (aktiv) / low | nein, Befund |
| `direct_sql` – SQL an KNOW vorbei | medium (schreibend) / low | nein |
| `missing_table` / `missing_column` – Ontologie verweist auf etwas, das KNOW nicht anlegt | medium / low | nein |
| `concept_without_producer` – Konzept, das keine *feste* Zuordnung erzeugt | info | nein |
| `no_write_operation` / `no_active_writer` – Klasse ohne Schreibweg | info | nein |
Werte werden nur aus Schlüsseln der obersten Payload-Ebene gelesen (verschachtelte JSON-Felder gehören zu einer JSON-Spalte). „Fest“ heißt: Literal oder Zuordnungstabelle mit Literal-Rückfall; bei freien Werten (LLM-Ausgabe) trifft die Prüfung keine Aussage. Entschiedene Schlüssel (Tabelle `ontology_decisions`) und `ignore` werden übersprungen.
## 4. Service-Routine und Freigabe
Schritt 8 der Routine (`MaintenanceAgent.run_routine`) ruft den Abgleich auf, wenn `ontology_path` existiert und eine Quelle konfiguriert ist (`KNASTER_ONTOLOGY_SNAPSHOT` oder `N8N_HOST` + `N8N_API_KEY`):
- Befunde ohne Vorschlag erscheinen als Zeilen „Ontologie [schwere]: …“ (höchstens `ontology_max_findings`, Standard 15).
- Befunde mit Vorschlag werden zu Vorschlägen der Art `ontology_change` (Richtlinie: immer Freigabe, Risiko niedrig), dedupliziert über `ontology:<key>`.
- `genehmige P-x` → Entscheidung `accept`; `ordne P-x <konzept> zu` → `map` (Wert wird alternative Schreibweise des Konzepts); `lehne P-x ab: …` → `reject` (Wert landet in `rejected` bzw. der Schlüssel in `ignore`).
- Entscheidungen liegen in `ontology_decisions`; ein entschiedener Schlüssel wird nicht erneut vorgeschlagen.
- `knaster ontology apply` schreibt offene Entscheidungen in `know.yaml`, erhöht `version`, prüft das Ergebnis und markiert die Entscheidungen als übernommen. Danach `knaster ontology export --ttl ontology/know.ttl` und committen.
Fällt n8n aus, meldet die Routine „Ontologie-Abgleich nicht möglich“ und läuft weiter.
## 5. CLI
| Befehl | Zweck |
|---|---|
| `knaster ontology validate` | Modell prüfen |
| `knaster ontology export --ttl F --graph F --html F` | Turtle (OWL + SKOS), Graph-JSON, eigenständige Ansicht (`ontology/viewer.html.tmpl`) |
| `knaster ontology snapshot --out F` | Workflows lesen und speichern (enthält Workflow-Code, nicht committen) |
| `knaster ontology check [--snapshot F] [--json]` | Abgleich; Exit 1 bei Befunden der Schwere high |
| `knaster ontology apply [--dry-run]` | freigegebene Entscheidungen übernehmen |
## 6. Grenzen und nächste Schritte
- Die Prüfung liest Code, keine Daten: ob Tabellen tatsächlich leer sind, zeigt erst eine Zählung in der Datenbank.
- Weiche Bezüge (z. B. Absender ↔ Kontakt) werden noch nicht automatisch als Verknüpfungsvorschläge erzeugt; geplant über Embeddings mit Schwelle (Instanz-Ebene).
- Aus der Ontologie sollen erzeugt werden: Sichten pro Klasse („Projekt 360°“), JSON-Schemas für Skill-Ausgaben, Werkzeugkatalog für Hermes/NEX, Datenschutz-Regeln aus `sensitive`.
@@ -22,14 +22,16 @@ Dieser Plan **löst** die Abläufe in `docs/PLAN.md` §8 („Roadmap“) und `do
| Knoten | IP | Hardware | Platten | GPUs | Besonderheit |
|---|---|---|---|---|---|
| pve44 | 192.168.100.210 | ThinkStation P310, i7-6700, 32 GB | 2× 256 GB (sdb: altes Windows, NTFS) | M4000 + P2000 | – |
| pve44 | 192.168.100.210 | ThinkStation P310, i7-6700, 32 GB | 2× 256 GB (sdb: früher Windows, seit 12.10. `tank`) | M4000 + P2000 | – |
| pve46 | 192.168.100.209 | ThinkStation P310, i7-6700, 32 GB | 1× 256 GB | M4000 + P2000 | bootet Legacy statt UEFI |
| pve48 | 192.168.100.212 | ThinkStation P310, i7-6700, 32 GB | 3 Platten (SanDisk): sda 256 GB Proxmox, sdb 256 GB **altes Windows, 220 GB belegt**, sdc 128 GB **Windows 10, 101 GB belegt** | M4000 + P2000 | bootet Legacy statt UEFI |
| pve52 | 192.168.100.207 | ThinkStation P320, i7-7700, 32 GB | 2× 256 GB (sdb: altes Windows, **BitLocker „GODEL55“**) | nur M4000 (keine P2000 verbaut) | – |
| pve48 | 192.168.100.212 | ThinkStation P310, i7-6700, 32 GB | 3 Platten (SanDisk): sda 256 GB Proxmox, sdb 256 GB (früher Windows, seit 12.10. `tank`), sdc 128 GB (früher Windows 10, seit 12.10. leer) | M4000 + P2000 | bootet Legacy statt UEFI |
| pve52 | 192.168.100.207 | ThinkStation P320, i7-7700, 32 GB | 2× 256 GB (sdb: früher Windows mit BitLocker „GODEL55“, seit 12.10. `tank`) | nur M4000 (keine P2000 verbaut) | – |
| pve61 | 192.168.100.214 | Surface Book, i5-8350U, 8 GB | 1× 256 GB NVMe | Intel UHD 620 | LAN nur über USB-NIC im Lenovo-Dock, 2 Akkus |
Alle Knoten: PVE 9.2.2, Kernel 7.0.2, **ext4 + LVM-thin auf einer SSD (kein ZFS)**, 1 GbE, kein Cluster.
Stand 12.10. abends: PVE 9.2.21, Kernel 7.0.14 (pve61 noch 7.0.2 bis zum Neustart vor Ort), Cluster `knaster` mit 5 Stimmen, ZFS-Pool `tank` (238 GB) auf pve44, pve48 und pve52.
Aufteilung der Systemplatte (per SSH geprüft, auf allen fünf Knoten gleich, alles noch leer): 69 GB `local` (System, ISOs, Vorlagen, lokale Backups), 141 GB `local-lvm` (Platten der VMs und Container), 8 GB Swap, 16 GB frei in der Volume Group. SMART: alle Platten `PASSED`. Samsung-SSDs (pve44, pve52) zu 3–17 % verschlissen, Surface-NVMe (pve61) zu 46 %. Bei der SanDisk in pve46 steht der Rohwert für umgelagerte Sektoren auf 100; der Wert wird überwacht. VT-x und VT-d sind aktiv. IOMMU ist geprüft: Jede Karte liegt mit ihrer Audio-Funktion in einer eigenen Gruppe (M4000 in Gruppe 1, P2000 in Gruppe 8), Passthrough ist also möglich.
Außerhalb des Clusters:
@@ -49,7 +51,7 @@ Außerhalb des Clusters:
- **STT:** `whisper-server` auf Port 8178, nur von `192.168.100.29` erreichbar. `stt.openai.base_url` nur als IP-Literal (nex-STT-Plan, Annahmen).
- **Ada-Rechner:** Windows bleibt, Mitarbeitende haben tagsüber Vorrang, kein Proxmox darauf.
- **Datacenter-weite Einstellungen** (Benutzer, 2FA, Firewall, Benachrichtigungen, Pools, Metriken) erst, wenn alle Knoten im Cluster sind.
- **Alte Windows-SSDs** (pve44 sdb, pve52 sdb) nur nach ausdrücklicher Freigabe löschen (D1).
- **Platten:** Die alten Windows-SSDs sind seit 12.10. gelöscht (Freigabe D1). Systemplatten (`sda`, NVMe) nie anfassen; Platten nur über `/dev/disk/by-id/…` mit Seriennummer ansprechen (auf pve52 unterscheiden sich System- und `tank`-Platte nur in der letzten Ziffer).
- **Geheimnisse** nie ins Repo. Zugangsdaten liegen in `C:\Users\r.rapp\it\proxmox\.env`, der Schlüssel unter `…\proxmox\keys\`.
- **Planner-Webhook `tdo-codex-update`:** pro Aufruf nur **eine** Aktualisierung, vorhandene Titel nie umbenennen (Abgleich läuft über den Titel, Schwelle 0.8).
@@ -71,11 +73,12 @@ Außerhalb des Clusters:
| **D2** | GPU als VM oder LXC | **Pro GPU-Knoten eine Ubuntu-24.04-VM mit allen Karten des Knotens**, darin Docker Compose pro Rolle. | Der Host-Kernel (7.0) bleibt ohne NVIDIA, Proxmox-Updates gefährden den Treiber nicht. R580 kommt als Ubuntu-Paket und ist per Snapshot rückrollbar. IOMMU-Gruppen sind geprüft. Eine VM pro Knoten statt pro Rolle (ADR-0001) spart RAM. Das Argument „gemischte Generationen“ aus ADR-0001 gilt im Cluster nicht mehr, alle Karten sind Maxwell/Pascal. Kosten: 2–5 % CPU, fest reservierter VM-RAM (12–16 GB von 32 GB). | LXC mit Host-Treiber: braucht R580 auf Kernel 7.0 oder einen festgehaltenen älteren Kernel. |
| **D3** | Ada-Rechner | **Windows bleibt.** `LocalLLMWorker.ps1` mit llama-server (CUDA) meldet sich über die Worker-Registry v2. Die knaster-Slots `tier1-general`/`tier1-flex` lösen auf einen Worker auf, der online ist, sonst tier0. | Die Registry v2 ist fast fertig (44 von 55 Schritten). Mitarbeitende werden nicht gestört. vLLM läuft unter Windows nicht nativ, Multi-LoRA, QLoRA und Qwen3-TTS kommen deshalb erst mit einer späteren WSL2-Entscheidung (Phase 9). | Dual-Boot mit Proxmox: scheidet aus, die Rechner werden tagsüber genutzt. |
| **D4** | LiteLLM | **Kein eigener LiteLLM im knaster-Infra.** Der Router nutzt den LiteLLM auf appsrv08 (mit PSE), die lokalen Slots werden dort als Modelle eingetragen. | Es läuft bereits ein dritter LiteLLM; noch einer würde PSE für tier0 umgehen. Ein Modellwechsel bleibt eine Datenänderung. | Eigener LiteLLM nur für lokale Slots, ohne OpenRouter-Zugang. |
| **D6** | Orchestrierung der Gäste-Dienste | **k3s** (Server auf Dienst-VM `k3s`, Agents = GPU-VMs, NVIDIA Device Plugin), Portainer auf appsrv08 als Oberfläche über den Portainer-Agent (NodePort 30778). Entschieden 15.10.2026. | Nutzer will die vier GPU-Hosts in Portainer als einen Cluster sehen. Portainer kennt dafür nur Swarm oder Kubernetes; Kubernetes plant GPUs sauber (Device-Plugin), Ökosystem (Helm) größer. GPU-Rechenleistung identisch, Grundlast k3s ~0,5–1 GB je Knoten. | Docker Swarm (kaum GPU-Planung, kein Helm), RKE2 (gleiches Haus, schwerer), kubeadm. |
| **D5** | IP-Plan | **Knoten behalten .207–.214.** Gäste bekommen einen festen Block außerhalb des DHCP-Bereichs. Vorschlag `.220–.239`, wird in Task 0.3 gegen den DHCP-Bereich geprüft. | Die STT-Konfiguration braucht IP-Literale. Die alten GODEL-DNS-Einträge zeigen, dass die Knoten-IPs aus DHCP stammen, deshalb Reservierungen anlegen. | VLAN `svc` (PLAN §1.4) später, wenn der Switch das zulässt. |
## Was hängt woran (Stand 12.10.)
Die Speicherfreigabe (D1) blockiert nur wenig. D2 ist entschieden (12.10.). D5 ist zurückgestellt: Gäste starten mit DHCP; feste IPs müssen vor Phase 6 stehen, weil STT und die LiteLLM-Slots IP-Literale nutzen.
Die Speicherfreigabe (D1) liegt seit 12.10. vor, `tank` steht auf pve44, pve48 und pve52; die rechte Spalte ist damit freigeschaltet. D2 ist entschieden (12.10.). D5 ist zurückgestellt: Gäste starten mit DHCP; feste IPs müssen vor Phase 6 stehen, weil STT und die LiteLLM-Slots IP-Literale nutzen. 19.10.: Reservierungen für pve44, pve52, gpu-pve52 und den Laptop bestätigt, Rest siehe Task 0.2.
| Sofort möglich | Braucht D2 (entschieden) | Braucht die Speicherfreigabe |
|---|---|---|
@@ -93,7 +96,7 @@ pve48 hat laut Inventar vom 12.10. M4000 und P2000 und ist damit der vierte GPU-
|---|---|---|
| `gpu-pve44`, `gpu-pve46`, `gpu-pve48`, `gpu-pve52` | je 12 GB, fest | auf dem eigenen Knoten |
| `infra` (LXC) | 8 GB | pve44 auf `tank`, Replikation nach pve52 und pve48 |
| `ops` (LXC) | 1 GB | pve46 (kein HA nötig, per Ansible neu aufsetzbar) |
| `ops` (LXC) | 2 GB | pve46 (kein HA nötig, per Ansible neu aufsetzbar; 2 GB statt 1 GB, pve46 hat keinen ZFS-ARC) |
| `pbs` (VM) | 4 GB | pve52 |
| `grp-test` (LXC) | 4 GB | pve48 auf `tank`, Replikation nach pve44 |
| pve61 | 8 GB gesamt | keine Gäste, nur Kiosk |
@@ -126,7 +129,13 @@ Ersetzt PLAN §8 „0 Fundament“ (Teil Inventar) und IMPLEMENTATION „Gemeins
**Wo:** remote (DHCP-Server, vermutlich DC `.120` oder UniFi) · **Planner:** „PVE-Config: Netzwerk prüfen – Bridge, VLAN-Fähigkeit und NIC-Kompatibilität“
- [ ] DHCP-Bereich auslesen. Für .207, .209, .210, .212 und .214 Reservierungen auf die MACs der Knoten anlegen (MACs stehen im Healthcheck). Ebenso eine Reservierung für den Admin-Laptop (heute .151 per WLAN). Die Firewall in 4.4 lässt nur diese Adresse zu, ein Lease-Wechsel würde sonst aussperren.
- [x] (14.10.) ops und infra auf feste Adressen .173/.188 umgestellt (BETRIEB §10), damit hängt der Steuerrechner nicht mehr am Lease. Laptop kam am 14.10. per Dock mit .181, als zweite Adresse in `firewall.management` aufgenommen.
- [ ] Reservierungen beim DC-Support anfordern (15.10. angefragt; MAC-Liste vom 14.10.: Knoten, ops bc:24:11:c1:d9:c8, infra bc:24:11:6d:e5:c0, gpu-pve52 bc:24:11:92:4b:eb, **nachreichen: k3s-VM bc:24:11:a5:d5:52 → .124**, Laptop WLAN 34:fd:70:65:06:6c und Dock 84:ba:59:fb:e0:36) plus Ausschluss `.220–.239`. Nötig ist dafür nur die Gruppe „DHCP-Administratoren“ auf dem DC .120, kein Domänen-Admin.
- [x] (19.10., Bestätigung an den Nutzer) Feste Adressen per Reservierung: pve44 (.210), pve52 (.207), gpu-pve52 (.174, `node.json` auf infra) und der Laptop über WLAN (.151, gemessen) und LAN im Dock (.181, gemessen 19.10.). Damit ist die Laptop-Sperre aus BETRIEB §10 behoben.
- [ ] Laut DC derzeit **nicht konfigurierbar**: infra (.188), pve46 (.209), pve48 (.212). Alle drei sind im Gast bzw. in `/etc/network/interfaces` statisch eingestellt (19.10. geprüft), hängen also an keinem Lease. Restrisiko: Der DHCP-Server vergibt die Adresse an ein anderes Gerät, dann gibt es einen Konflikt. Ob das droht, zeigt erst „DHCP-Bereich auslesen“ (liegen die Adressen im Bereich?). Liegen sie darin, als Alternative einen Ausschluss der Einzeladressen erfragen.
- [ ] Ohne Rückmeldung: ops (.173, statisch), k3s-VM (.124), gpu-pve44/46/48, pve61 (.214), Ausschluss `.220–.239`. 19.10. vom Nutzer erneut beim DC angefragt.
- [ ] Später (Phase 9): eigenes Verwaltungs-/Dienstnetz als VLAN, Vorbedingung: Switch kann VLAN, Routing beim Netzverantwortlichen geklärt. Bis dahin bleibt die Adressliste in der Proxmox-Firewall der Schutz.
- [ ] DHCP-Bereich auslesen. **Seit 13.10. dringender:** Die Firewall (4.4) lässt nur .151 (Laptop), .173 (ops) und .188 (infra) an die Verwaltungsports; ein Lease-Wechsel sperrt die Quelle aus (BETRIEB §10). Für .207, .209, .210, .212 und .214 Reservierungen auf die MACs der Knoten anlegen (MACs stehen im Healthcheck). Ebenso eine Reservierung für den Admin-Laptop (heute .151 per WLAN). Die Firewall in 4.4 lässt nur diese Adresse zu, ein Lease-Wechsel würde sonst aussperren. 19.10.: Laptop, pve44 und pve52 reserviert; der Bereich selbst ist noch nicht ausgelesen und entscheidet über das Restrisiko für infra, pve46, pve48.
- [ ] Gäste-Block festlegen (Vorschlag `.220–.239`) und als Ausschluss im DHCP eintragen.
Prüfung: `ping` auf jede Adresse des Blocks → keine Antwort. DHCP-Ausschluss sichtbar.
@@ -135,9 +144,9 @@ Ersetzt PLAN §8 „0 Fundament“ (Teil Inventar) und IMPLEMENTATION „Gemeins
**Wo:** Gespräch mit dem Nutzer
- [x] D1 entschieden (12.10.): **Variante B**, keine Neuinstallation. Alle alten Windows-Platten werden für den Cluster vollständig gelöscht.
- [ ] D1-Freigabe zum Löschen: Der Nutzer prüft die alten Windows-Platten zuerst (pve44 sdb NTFS, pve52 sdb BitLocker „GODEL55“, pve48 sdb mit 220 GB und sdc mit 101 GB belegt). Gelöscht wird erst nach seinem ausdrücklichen „Freigabe“.
- [x] D1-Freigabe zum Löschen (12.10., „festplatten alle platt machen“): pve44 sdb NTFS, pve52 sdb BitLocker „GODEL55“, pve48 sdb und sdc. Alle vier gelöscht (Task 1.2).
- [x] D2 entschieden (12.10.): eine GPU-VM pro Knoten. Vorteil, der schon heute greift: Das anstehende Kernel-Update 7.0.2 → 7.0.14 berührt keinen NVIDIA-Treiber.
- [ ] D5 zurückgestellt (12.10., „Schritt zwei“): Gäste vorerst per DHCP. Für feste IPs braucht es keinen DNS-Eintrag, sondern einen Ausschluss bzw. Reservierungen im DHCP-Server. Den verwaltet nicht der Nutzer. Dabei auch die Knoten-IPs .207–.214 reservieren lassen (Corosync bindet an sie).
- [ ] D5 zurückgestellt (12.10., „Schritt zwei“): Gäste vorerst per DHCP. Für feste IPs braucht es keinen DNS-Eintrag, sondern einen Ausschluss bzw. Reservierungen im DHCP-Server. Den verwaltet nicht der Nutzer. Dabei auch die Knoten-IPs .207–.214 reservieren lassen (Corosync bindet an sie). Stand 19.10. in Task 0.2.
- [ ] D3 und D4 bestätigen. Ergebnis als Abschnitt „Entscheidungen 2026-10“ in `docs/PLAN.md` §9 eintragen.
### Task 0.4: Site-Dateien, Ansible-Inventar und ADRs an die echte Hardware anpassen
@@ -170,15 +179,16 @@ Ersetzt PLAN §8 „0 Fundament“ (Teil Inventar) und IMPLEMENTATION „Gemeins
**Interfaces:**
- Produces: `bootstrap-node.sh <knoten>`, idempotent. Ausgabe endet mit `BOOTSTRAP OK <hostname>` oder Exit ≠ 0.
- [ ] **Step 1:** Das Skript (läuft auf dem Laptop, Git-Bash) erledigt Folgendes:
- [x] **Step 1:** Das Skript (läuft auf dem Laptop, Git-Bash) erledigt Folgendes (12.10.: `ssh-keygen -R` nur mit `--reinstall`, Update abgekoppelt per `systemd-run`):
- alten Hostschlüssel mit `ssh-keygen -R <ip>` entfernen
- Schlüssel per Passwort aus `.env` eintragen, mit dem SSH_ASKPASS-Verfahren vom 08.10. (nur anhängen, `grep -qxF`)
- Enterprise-Quellen auf `Enabled: no` setzen, `proxmox.sources` mit `pve-no-subscription` anlegen, vorher sichern
- `apt-get update && apt-get -y full-upgrade`
- Pakete `ethtool lm-sensors` installieren
- Prüfen: `timedatectl show -p NTPSynchronized` = `yes`
- [ ] **Step 2:** Zweiter Lauf gegen denselben Knoten → keine Änderung, wieder `BOOTSTRAP OK`.
- [ ] **Step 3:** Commit.
- [x] **Step 2:** Zweiter Lauf gegen denselben Knoten → keine Änderung, wieder `BOOTSTRAP OK` (pve44, 12.10., 17 s).
- [x] **Step 3:** Commit.
- [ ] **Step 4 (Ausführung 12.10.):** Alle Knoten auf PVE 9.2.21 / Kernel 7.0.14-23 gebracht (pve44, pve46, pve48, pve52 neu gestartet und geprüft; **pve61 Neustart offen**); nach jedem Neustart Kernel, `systemctl --failed`, vmbr0, NTP sowie GPU-Adressen und IOMMU-Gruppen geprüft. pve61 wird erst neu gestartet, wenn der Nutzer vor Ort ist (USB-NIC im Dock).
### Task 1.2: Speicher (D1)
@@ -186,10 +196,20 @@ Ersetzt PLAN §8 „0 Fundament“ (Teil Inventar) und IMPLEMENTATION „Gemeins
**Variante B (Empfehlung) – remote, ohne Neuinstallation**
- [ ] Freigabe aus Task 0.3 liegt vor. Dann auf pve44, pve48 und pve52: Partitionen von `sdb` mit `wipefs -a /dev/disk/by-id/<ssd2>` entfernen, danach `zpool create -o ashift=12 -O compression=lz4 -O atime=off tank /dev/disk/by-id/<ssd2>` und `pvesm add zfspool tank --pool tank --content images,rootdir --nodes pve44,pve48,pve52`. Auf pve48 bleibt `sdc` unberührt.
Das Löschen ist nicht umkehrbar. Vorher `lsblk -o NAME,SIZE,FSTYPE,LABEL /dev/sdb` prüfen: Es muss die freigegebene Windows-Platte sein, nie `sda`.
- [ ] ZFS-ARC auf 4 GiB begrenzen (`/etc/modprobe.d/zfs.conf`, `update-initramfs -u`, wirksam beim nächsten Neustart).
Prüfung: `zpool status tank` → `ONLINE`. `pvesm status` → `tank active` auf pve44, pve48 und pve52.
- [x] Freigabe aus Task 0.3 liegt vor (12.10.). Auf pve44, pve48 und pve52 die zweite SSD gelöscht und `tank` angelegt. Die Platten wurden nur über ihre Seriennummer angesprochen; vor dem Löschen wurde geprüft, dass sie nicht die Systemplatte, nicht eingehängt und nicht in Benutzung sind:
| Knoten | `tank` auf | Systemplatte (unberührt) |
|---|---|---|
| pve44 | `ata-SAMSUNG_MZ7LN256HAJQ-000L7_S3S7NY0K403015` | Samsung S20HNAAH271598 |
| pve48 | `ata-SanDisk_SD6SB1M256G1022I_140877401621` | SanDisk 164862429106 |
| pve52 | `ata-SAMSUNG_MZ7LN256HAJQ-000L7_S3S7NY0K403017` | Samsung S3S7NY0K403016 (gleiches Modell, nur die letzte Ziffer unterscheidet sich) |
Ablauf je Platte: `wipefs -a` (Partitionen, dann Platte), `sgdisk --zap-all`, `blkdiscard`. Danach `zpool create -o ashift=12 -O compression=lz4 -O atime=off tank /dev/disk/by-id/<ssd2>` und `systemctl enable zfs-import@tank.service`. Einmal im Cluster: `pvesm add zfspool tank --pool tank --content images,rootdir --sparse 1 --nodes pve44,pve48,pve52`.
pve48 `sdc` (SanDisk 128 GB, 140621401586) ist ebenfalls gelöscht und bleibt vorerst ungenutzt. Als Stripe in `tank` würde sie das Ausfallrisiko verdoppeln.
- [x] ZFS-ARC auf 4 GiB begrenzt: `/etc/modprobe.d/zfs.conf`, `update-initramfs -u -k all`, zusätzlich sofort über `/sys/module/zfs/parameters/zfs_arc_max`.
Prüfung 12.10.: `zpool status tank` → `ONLINE` auf allen drei Knoten. `pvesm status` → `tank active` auf pve44, pve48 und pve52 (auf pve46 und pve61 `disabled`, wie gewollt).
- [x] Neustart-Probe (12.10.): pve48 (Legacy-BIOS) und pve44 (UEFI) neu gestartet. Beide starten von `sda`, `tank` wird importiert (`ONLINE`, `pvesm` active), ARC = `4294967296`, keine fehlgeschlagenen Dienste, GPU-Stand unverändert, Quorum 5/5.
- [ ] Nebenbefund pve44: Die EFI-Partition `sda2` ist nicht unter `/boot/efi` eingehängt (kein Eintrag in `/etc/fstab`). Der Start klappt, aber GRUB-Updates erreichen die EFI-Partition nicht. Eintrag `UUID=BB96-13D2 /boot/efi vfat defaults 0 1` nachtragen, `grub-install` prüfen. Die alten Windows-Starteinträge im UEFI (pve44 `Boot000E`, pve52 `Boot000D`) zeigen ins Leere und können mit `efibootmgr -b <nr> -B` entfernt werden.
**Variante A – Neuinstallation mit ZFS**
@@ -225,17 +245,19 @@ Ersetzt GPU-Knoten 3 (Teil Cluster). Mit 5 Stimmen ist **kein QDevice** nötig.
**Wo:** remote · **Planner:** „GPU-Knoten 3: Cluster mit QDevice (Container auf appsrv08), NVIDIA-Treiber, GPU-Container“ (Teil Cluster)
- [ ] Auf pve44: `pvecm create knaster --link0 192.168.100.210`.
- [ ] Nacheinander auf pve46, pve48, pve52 und pve61: `pvecm add 192.168.100.210 --link0 <eigene IP>`. Nach **jedem** Beitritt den Laptop-Schlüssel in `/etc/pve/priv/authorized_keys` prüfen und gegebenenfalls einmal ergänzen.
- [x] Auf pve44: `pvecm create knaster --link0 192.168.100.210` (12.10.).
- [x] Nacheinander auf pve46, pve48, pve52 und pve61 beigetreten (12.10.). Statt `pvecm add` (fragt das Passwort am Terminal ab) `pvesh create /cluster/config/join --hostname 192.168.100.210 --fingerprint <sha256 von pve44> --link0 <eigene IP> --password …`, Passwort per stdin, Windows-Zeilenende entfernen. Nach **jedem** Beitritt den Laptop-Schlüssel in `/etc/pve/priv/authorized_keys` prüfen und gegebenenfalls einmal ergänzen.
Prüfung: `pvecm status` → `Quorate: Yes`, `Total votes: 5`. `for n in pve44 pve46 pve48 pve52 pve61; do ssh -o BatchMode=yes $n hostname; done` → fünf Namen.
- [ ] Datacenter-Optionen: Migrationsnetz `192.168.100.0/24`, Typ `secure`.
- [x] Datacenter-Optionen: Migrationsnetz `192.168.100.0/24`, Typ `secure`.
Ergebnis 12.10.: `Nodes: 5`, `Quorate: Yes`, `Total votes: 5`, Schlüssel-Login auf allen fünf Knoten ok.
### Task 2.2: HA-Regeln und Replikation
**Wo:** remote
- [ ] Node-Affinity-Regeln: Infra-Dienste nur auf pve44, pve48, pve52. **pve61** bekommt keine HA-Gäste, GPU-VMs bleiben an ihrem Knoten.
- [ ] Replikationspartner festlegen: Infra-LXC von seinem Knoten auf einen zweiten, `*/15`.
- [x] Node-Affinity-Regeln (12.10., per Ansible `pve-host`, nicht tofu – der Token braucht so kein `Sys.Console`): Regel `knaster-infra`, `ct:101`, `pve44:2,pve52:1,pve48:1`, strikt. Infra-Dienste nur auf pve44, pve48, pve52. **pve61** bekommt keine HA-Gäste, GPU-VMs bleiben an ihrem Knoten.
- [x] Replikation (12.10.): Jobs `101-0` → pve52 und `101-1` → pve48, `*/15`, Erstlauf je 14 s.
**Test 12.10.:** Manuelles `migrate` nach pve52 lehnt PVE 9 ab (niedrigere Priorität, solange pve44 lebt – gewollt). Wartungsmodus `ha-manager crm-command node-maintenance enable pve44` → `infra` nach 36 s auf pve48 gestartet, Testdatei aktuell, Docker läuft, IP gleich; `disable` → nach 41 s zurück auf pve44. Echter Ausfall (Strom weg) noch nicht getestet – nur vor Ort.
Prüfung: `ha-manager rules config` bzw. `ha-manager groupconfig` zeigt die Regeln.
---
@@ -246,31 +268,41 @@ Ersetzt GPU-Knoten 3 (Teil Cluster). Mit 5 Stimmen ist **kein QDevice** nötig.
**Wo:** remote · **Files:** Modify `platform/proxmox/ansible/inventory.yml`, `platform/proxmox/ansible/playbooks/site.yml`, Create `platform/proxmox/README.md` (Abschnitt ops-LXC)
- [ ] LXC `ops` (Debian 13, vmid 100, auf pve46 laut RAM-Budget, IP aus D5) mit `ansible-core`, `opentofu`, `git` und dem Repo-Clone. Ansible läuft nicht auf dem Windows-Laptop.
- [ ] `site.yml`: `pve-host` nur für `pve_gpu_hosts` (pve44, pve46, pve48, pve52). pve61 steht in `pve_kiosk` ohne VFIO und Blacklist.
- [ ] Rolle `pve-host` ergänzen: `/etc/modprobe.d/vfio.conf` mit `options vfio-pci ids=<liste>` und `softdep nvidia pre: vfio-pci`. Die Liste enthält die Grafik- **und** die Audio-Funktionen der Karten. Die IDs aus `lspci -nn -s 01:00` und `lspci -nn -s 02:00` auf dem Knoten lesen, nicht aus dem Gedächtnis eintragen; bekannt sind bisher nur die VGA-IDs `10de:13f1` und `10de:1c30`.
- [ ] `ansible-playbook playbooks/site.yml --limit pve_gpu_hosts --check`, danach der echte Lauf. Knoten **einzeln** neu starten.
- [x] LXC `ops` (12.10.): CT 100 auf pve46, Debian 13.6, unprivilegiert, 2 Kerne, 2 GB, 8 GB `local-lvm`, DHCP (heute `192.168.100.173`, Alias `ops` in der SSH-Konfiguration des Laptops). `ansible-core` 2.19.11, OpenTofu 1.13.1 (offizielles signiertes Repo), `git`, `make`, `python3-venv`. Eigener Schlüssel `root@ops` steht in `/etc/pve/priv/authorized_keys` (gilt für alle Knoten), `known_hosts` vom Laptop übernommen (geprüfte Einträge), `StrictHostKeyChecking yes`. Repo-Clone anonym über HTTPS (Branch `plan/aufbau-2026-10-12`); geändert wird auf dem Laptop, `ops` macht nur `git pull`.
- [x] `site.yml`: `pve-host` nur für `pve_gpu_hosts` (pve44, pve46, pve48, pve52). pve61 steht in `pve_kiosk` ohne VFIO und Blacklist.
- [x] Rolle `pve-host` ergänzen (12.10., IDs liest die Rolle per `lspci -n -d 10de:` auf dem Knoten; zusätzlich `softdep nouveau/snd_hda_intel pre: vfio-pci`, `initcall_blacklist=sysfb_init`, Handler `update-initramfs`; Handler-Fehler behoben: `proxmox-boot-tool refresh || update-grub` erreichte `update-grub` nie): `/etc/modprobe.d/vfio.conf` mit `options vfio-pci ids=<liste>` und `softdep nvidia pre: vfio-pci`. Die Liste enthält die Grafik- **und** die Audio-Funktionen der Karten. Die IDs aus `lspci -nn -s 01:00` und `lspci -nn -s 02:00` auf dem Knoten lesen, nicht aus dem Gedächtnis eintragen; auf allen GPU-Knoten gelesen (12.10.): M4000 `10de:13f1` + Audio `10de:0fbb`, P2000 `10de:1c30` + Audio `10de:10f1`.
- [x] `ansible-playbook playbooks/site.yml --limit pve_gpu_hosts --check`, danach der echte Lauf. Knoten **einzeln** neu starten (12.10.: pve48 als Pilot inkl. Wegwerf-VM mit beiden Karten, dann pve44, pve52, pve46).
Ergebnis 12.10.: auf allen vier GPU-Knoten jede NVIDIA-Funktion an `vfio-pci`, keine fehlgeschlagenen Dienste. Die M4000 ist überall die Boot-Grafik und es gibt keine iGPU: **Der lokale Bildschirm bleibt nach dem frühen Start schwarz**, Zugang per SSH/Weboberfläche, GRUB-Menü bleibt sichtbar. Die P2000 kann kein FLR, der Bus-Reset klappt („reset done“).
Prüfung: `lspci -nnk -s 01:00` und `lspci -nnk -s 02:00` auf jedem GPU-Knoten → bei allen Funktionen `Kernel driver in use: vfio-pci`. Auf pve61: `lspci -k -s 00:02.0` → `i915`.
### Task 3.2: Resource Mappings, Template und GPU-VMs
**Wo:** remote (ops-LXC) · **Planner:** „GPU-Knoten 3 …“ (Teil NVIDIA, GPU-Container)
- [ ] PCI-Resource-Mappings aus `sites/knaster/proxmox.yaml` anlegen (Namen aus Task 0.4).
- [ ] Ubuntu-24.04-Cloud-Template vmid 9000 auf jedem GPU-Knoten.
- [ ] `tofu plan` und dann `tofu apply` für `gpu-pve44`, `gpu-pve46`, `gpu-pve48` und `gpu-pve52`: alle Karten des Knotens, 12 GB RAM, 4 Kerne, Systemplatte 40 GB und Modellplatte 60 GB auf `local-lvm` (Variante B) bzw. `local-zfs` (Variante A), IPs aus D5.
- [ ] `ansible-playbook … --limit gpu_vms` (Rollen `docker-host` und `gpu-node`, `driver_generation: legacy` = R580).
- [x] PCI-Resource-Mappings aus `sites/knaster/proxmox.yaml` anlegen (Namen aus Task 0.4) – per tofu (`proxmox_hardware_mapping_pci`), 12.10.
- [x] Ubuntu-24.04-Cloud-Template je Knoten (12.10.): **9044, 9046, 9048, 9052** statt 9000 überall, weil VMIDs clusterweit eindeutig sind. `platform/proxmox/scripts/make-template.sh <knoten> <vmid>` (Prüfsumme gegen SHA256SUMS, kein Secure Boot wegen DKMS, Gast-Agent an).
- [x] API-Benutzer `tofu@pve` mit Token `tofu@pve!ops` (12.10.), Rechte nur: `/` PVEAuditor, `/vms` PVEVMAdmin, `/storage` PVEDatastoreUser, `/sdn/zones/localnetwork` PVESDNUser, `/mapping/pci` PVEMappingAdmin. Token nur auf `ops` in `/root/.config/knaster/tofu.env` (600). Für HA (5.2) kommt `Sys.Console` hinzu.
- [x] tofu-Code an D2/D5 angepasst (12.10.): `bpg/proxmox ~> 0.116.0`, `hostpci` dynamisch aus `vms.*.gpus`, DHCP mit Adresse über den `qemu-guest-agent` (Vendor-Snippet `local:snippets/knaster-vendor.yaml` aus `pve-host`), Speicher je Gast, Ausgabe `ansible_inventory`. `tofu plan`: 11 neue Objekte.
- [x] (12.10.) `tofu plan` und dann `tofu apply` für `gpu-pve44`, `gpu-pve46`, `gpu-pve48` und `gpu-pve52`: alle Karten des Knotens, 12 GB RAM, 4 Kerne, Systemplatte 40 GB und Modellplatte 60 GB auf `local-lvm` (Variante B) bzw. `local-zfs` (Variante A), IPs aus D5.
- [x] `ansible-playbook … --limit gpu_vms` (Rollen `docker-host` und `gpu-node`, `driver_generation: legacy` = R580 → `580.178.04`, DKMS), 12.10.
Ergebnis 12.10., auch nach Neustart aller VMs: `gpu-pve44/46/48` sehen M4000 + P2000, `gpu-pve52` die M4000, `docker run --gpus all …` sieht alle Karten, Modellplatte 60 GB unter `/srv/models`.
**Befund DHCP:** Nach dem ersten Neustart hatten zwei VMs ihre Adressen getauscht (DUID als Client-ID); die strenge Hostschlüssel-Prüfung hat gestoppt. Jetzt `dhcp-identifier: mac` (netplan), Zugang nach jedem Neustart mit `scripts/refresh-vm-access.sh` auffrischen. Dauerhaft löst das nur D5 (Reservierungen auf die MACs: gpu-pve44 `bc:24:11:6a:67:15`, gpu-pve46 `bc:24:11:75:ad:33`, gpu-pve48 `bc:24:11:f5:da:73`, gpu-pve52 `bc:24:11:92:4b:eb`). 19.10.: gpu-pve52 reserviert (.174), die übrigen drei offen.
Prüfung in jeder VM: `nvidia-smi -L` → beide Karten. `docker run --rm --gpus all nvidia/cuda:12.6.3-base-ubuntu24.04 nvidia-smi` → beide Karten.
### Task 3.3: Benchmark-Tag (fasst drei Pläne zusammen)
**Wo:** remote, **Strommessung vor Ort** (Messgerät an der Steckdose) · **Planner:** „GPU-Knoten 4: Geschwindigkeitstests (Gate) auf M4000 und P2000“ · ersetzt außerdem den „Benchmark-Tag“ aus IMPLEMENTATION und den Gate-Task 1 des nex-STT-Plans
- [ ] `llama-bench` je Kartentyp: Embedding-Modell, 3–4B Q4 (verifier/signature), 7B Q4. Tokens/s und VRAM messen.
- [x] `llama-bench` je Kartentyp: Embedding-Modell, 3–4B Q4 (verifier/signature), 7B Q4. Tokens/s und VRAM messen.
(12.10., `gpu-pve48`, llama.cpp b6000/CUDA 12.4, sm_52/61) pp512/tg128 in t/s: Embedding 0.6B Q8 M4000 929 / P2000 1009; Qwen3-4B Q4 M4000 197/15,1, P2000 271/14,5; Qwen3-8B Q4 M4000 115/10,0, P2000 152/9,0. VRAM beim Katalog-Kontext 2,8 / 4,1 / 7,7 GiB, das 8B mit 16k passt nur auf die M4000. Details in ADR-0006.
- [ ] whisper.cpp `large-v3-turbo`, Sprache `de`: Sekunden je 10 s Diktat auf M4000 und P2000. Klärt, ob CC 5.2 überhaupt rechnet. Zusätzlich die Dialekt-Testsätze.
- [ ] `knaster bench tts` (Piper, CPU).
- [x] (12.10., `gpu-pve48`, v1.9.5, synthetisches Piper-Audio) P2000: 3,5 s je 10-s-Clip, 2,5 s je 10 s bei 33 s Audio; M4000 zum Vergleich 2,7 s bzw. 2,1 s. CC 5.2 rechnet, Flash-Attention eingeschlossen. M4000 im Dienst: `gpu-pve52` (Task 6.2).
- [ ] Offen: Dialekt-Testsätze (echte Sprecher nötig).
- [x] `knaster bench tts` (Piper, CPU). (12.10., Image `tts-piper`, 4 vCPU i7-6700) RTF 0,17 bei 241 Zeichen, 0,11 bei 596 Zeichen.
- [ ] Strom je Knoten: Leerlauf und Volllast beider Karten (400-W-Netzteil, erwartet etwa 300 W).
- [ ] Ergebnis eintragen in `catalog/models.yaml` und `sites/knaster/registry.yaml`, in ADR-0006 „M4000 wieder aufnehmen ja/nein“ und in die STT-Entscheidung D131/D133 (nex). Commit.
- [x] (12.10.) Katalog: `vram_gb` gemessen (Embedding 2,8, 4B 4,1, 8B 7,8 aufgerundet) und Messwerte als `note`. Die Registry hat keine Felder für Messwerte und bleibt unverändert. ADR-0006 Nachtrag „Benchmark“: M4000 **ja für STT auf pve52**, auf pve44/46/48 vorerst nein (P2000 gleich schnell bei halber Leistung). `knaster validate` und `pytest core` grün (außer dem bekannten Windows-Fehler in `test_tts_server`), Placement unverändert, alle aktiven Rollen auf gemessenen P2000.
- [ ] Offen: STT-Entscheidung D131/D133 im nex-Repo, `disabled` der M4000 auf pve52 erst nach der Strommessung (Nutzer). Rerank-VRAM neben Embedding: 19.10. gemessen, passt mit `-ub 4096` (Task 6.1). Für Task 6.1: Compose `count: 1` greift die M4000, `device_ids` setzen.
Prüfung: `make validate` → PASS, Placement ordnet jede aktive Rolle einer gemessenen Karte zu.
---
@@ -279,13 +311,13 @@ Ersetzt GPU-Knoten 3 (Teil Cluster). Mit 5 Stimmen ist **kein QDevice** nötig.
**Wo:** remote. Jede Aufgabe hier entspricht einer Planner-Aufgabe.
- [ ] **4.1 Benutzer und Token** („PVE-Config: Persönlicher Admin-Benutzer, Rollen und API-Token anlegen“): Admin-Benutzer und Gruppe, root@pam nur als Notfallzugang. Token `monitoring@pve!prom` (PVEAuditor), `healer@pve!knaster` (nur `VM.PowerMgmt` auf GPU-VMs, PLAN §3), `tofu@pve!ops`.
- [ ] **4.2 2FA** („PVE-Config: Zwei-Faktor-Authentifizierung für Admin-Konten aktivieren“): TOTP für root@pam und Admin. Recovery-Keys offline ablegen.
- [ ] **4.3 Benachrichtigungen** („PVE-Config: Benachrichtigungen (Notification Center) einrichten“): SMTP-Ziel Büro-Postfach, Matcher für Backup, Fencing und Updates. Testnachricht kommt an.
- [ ] **4.4 Firewall** („PVE-Config: Firewall auf Datacenter- und Knotenebene einrichten“): IP-Set `admin` (Laptop) und `appsrv08` (.29). Zuerst Allow-Regeln für 8006, 22 und ICMP, **dann** aktivieren. Konsole als Rückfall bereithalten. Prüfung: Weboberfläche vom Laptop ok, von einem anderen Rechner gesperrt.
- [ ] **4.5 Ordnung** („PVE-Config: Ressourcenpools und Tags festlegen“, „PVE-Config: Standard für neue LXC-Container festlegen“): Pools `infra`, `gpu`, `test`. Tags `gpu`, `infra`, `test`. LXC-Standard: unprivilegiert, Debian-13-Vorlage, Nesting nur für Docker.
- [ ] **4.6 Zertifikat** („PVE-Config: Web-GUI-Zertifikat festlegen (niedrige Priorität)“): `/etc/pve/pve-root-ca.pem` auf dem Laptop als vertrauenswürdig importieren.
- [ ] **4.7 Update-Routine** („PVE-Config: Update- und Neustart-Routine festlegen (rollierend, Knoten für Knoten)“): Ablauf in `docs/BETRIEB.md` eintragen (Wartungsmodus, ein Knoten nach dem anderen, GPU-VMs vorher sauber stoppen).
- [x] **4.1 Benutzer und Token** (13.10. erledigt, Rolle `pve-cluster`/`playbooks/cluster.yml`: Gruppe `admins` (Administrator auf `/`), `r.rapp@pve` mit Passwort per `scripts/pve-admin-user.sh`, Rolle `KnasterHealer` + `healer@pve!knaster` nur auf `/pool/gpu`, Token auf ops `/root/.config/knaster/healer.env`; BETRIEB §8) (12.10. teilweise: `tofu@pve!ops` mit engen ACLs und `monitoring@pve!prom` (PVEAuditor, Geheimnis nur auf ops `/root/.config/knaster/pve-exporter.env`) angelegt; offen: persönlicher Admin-Benutzer (Name vom Nutzer), `healer@pve!knaster`) („PVE-Config: Persönlicher Admin-Benutzer, Rollen und API-Token anlegen“): Admin-Benutzer und Gruppe, root@pam nur als Notfallzugang. Token `monitoring@pve!prom` (PVEAuditor), `healer@pve!knaster` (nur `VM.PowerMgmt` auf GPU-VMs, PLAN §3), `tofu@pve!ops`.
- [x] **4.2 2FA** (13.10.: TOTP + Recovery-Keys für `r.rapp@pve`, Datei `it/proxmox/tfa-r.rapp.txt`; root@pam bewusst ohne 2FA als Notfallzugang, BETRIEB §8) („PVE-Config: Zwei-Faktor-Authentifizierung für Admin-Konten aktivieren“): TOTP für root@pam und Admin. Recovery-Keys offline ablegen.
- [x] **4.3 Benachrichtigungen** (13.10.: Ziel `knaster-mail` → ki@mygodel.de per Sendmail direkt an die MX, Matcher `knaster-alles`, `default-matcher` aus, Testmail zugestellt; BETRIEB §9) („PVE-Config: Benachrichtigungen (Notification Center) einrichten“): SMTP-Ziel Büro-Postfach, Matcher für Backup, Fencing und Updates. Testnachricht kommt an.
- [x] **4.4 Firewall** (13.10.: aktiv auf allen Knoten, IPSET `admin` = Knoten + .151/.29/.173/.188, ACCEPT +admin dann DROP /24 auf 22/8006/3128/5900-5999/60000-60050 – `management` taugt nicht, Proxmox ergänzt es stets um das /24; Totmannschalter im Playbook; Negativtest aus GPU-VM bestanden; BETRIEB §10) („PVE-Config: Firewall auf Datacenter- und Knotenebene einrichten“): IP-Set `admin` (Laptop) und `appsrv08` (.29). Zuerst Allow-Regeln für 8006, 22 und ICMP, **dann** aktivieren. Konsole als Rückfall bereithalten. Prüfung: Weboberfläche vom Laptop ok, von einem anderen Rechner gesperrt.
- [x] **4.5 Ordnung** (12.10.: Pools `gpu` = 244/246/248/252 per tofu, `infra` = 100/101 per `pveum` – beim Container würde `pool_id` in tofu ein Ersetzen erzwingen; Tags `knaster`, `gpu`, `legacy`, `infra`, `ops` per tofu/pct. LXC-Standard so umgesetzt: unprivilegiert, Debian 13.6, Nesting nur bei Docker) („PVE-Config: Ressourcenpools und Tags festlegen“, „PVE-Config: Standard für neue LXC-Container festlegen“): Pools `infra`, `gpu`, `test`. Tags `gpu`, `infra`, `test`. LXC-Standard: unprivilegiert, Debian-13-Vorlage, Nesting nur für Docker.
- [x] **4.6 Zertifikat** (13.10.: CA nach `it/proxmox/pve-root-ca.pem` kopiert, Knotenzertifikate tragen die IP als SAN; Import am Laptop per `certutil -user -addstore Root …`, BETRIEB §11) („PVE-Config: Web-GUI-Zertifikat festlegen (niedrige Priorität)“): `/etc/pve/pve-root-ca.pem` auf dem Laptop als vertrauenswürdig importieren.
- [x] **4.7 Update-Routine** (12.10.: `docs/BETRIEB.md` Abschnitt 6, Wartungsmodus getestet) („PVE-Config: Update- und Neustart-Routine festlegen (rollierend, Knoten für Knoten)“): Ablauf in `docs/BETRIEB.md` eintragen (Wartungsmodus, ein Knoten nach dem anderen, GPU-VMs vorher sauber stoppen).
---
@@ -295,7 +327,8 @@ Ersetzt GPU-Knoten 3 (Teil Cluster). Mit 5 Stimmen ist **kein QDevice** nötig.
**Wo:** remote, Speicherort vor Ort · **Planner:** „Proxmox: Backup-System überlegen“ (beantwortet PLAN §9, offener Punkt 6)
- [ ] Ziel-Speicher entscheiden. Empfehlung: externer Datenträger oder NAS, **nicht** die Toshiba-Platte „BACKUP“ des Nutzers. PBS als VM `pbs` (vmid 110), Datastore auf dem Ziel.
- [ ] Ziel-Speicher entscheiden. Empfehlung: externer Datenträger oder NAS, **nicht** die Toshiba-Platte „BACKUP“ des Nutzers.
Prüfung 12.10. (nur lesend): Die angeschlossene 4-TB-HDD **ist** die Toshiba „BACKUP“ (USB, exFAT, rund 126 GB belegt), Windows meldet „Full Repair Needed“ und Controller-/Paging-Fehler (Event 11/51). Für den Cluster ausgeschlossen. Vorschlag: eigene 4-TB-CMR-Platte intern per SATA in pve52 oder pve44 (auf allen vier P310/P320 melden ata2–ata4 „link down“, freie Schächte/Kabel vor Ort prüfen). Surface/pve61 scheidet aus (Platte und einzige Netzkarte hängen an derselben USB-Kette). PBS als VM `pbs` (vmid 110), Datastore auf dem Ziel.
- [ ] Jobs: nächtlich Infra-LXC, ops-LXC und GPU-VMs (ohne Modellplatte), Aufbewahrung 7 Tage, 4 Wochen und 6 Monate. Dazu ein wöchentlicher Verify-Job.
- [ ] **Restore-Test:** Infra-LXC als Kopie zurückspielen.
Prüfung: Die Kopie startet, `docker ps` zeigt die Dienste.
@@ -304,12 +337,13 @@ Ersetzt GPU-Knoten 3 (Teil Cluster). Mit 5 Stimmen ist **kein QDevice** nötig.
**Wo:** remote · **Planner:** „Proxmox: Grafana-Dashboard für den Cluster“, „PVE-Config: Verfügbarkeits-Monitoring und Log-Kontrolle ergänzen“ · **Files:** Modify `deploy/compose/infra/docker-compose.yml`, `deploy/prometheus/prometheus.yml`
- [ ] `deploy/compose/infra` ohne den Dienst `litellm` (D4). Router zeigt auf `http://192.168.100.29:<LiteLLM-Port>`. Image-Tags pinnen (offener Punkt aus IMPLEMENTATION).
- [ ] LXC `infra` (vmid 101, Nesting, 4 Kerne, 8 GB, Knoten laut RAM-Budget) mit HA und Replikation (Task 2.2). Compose mit `KNASTER_DRY_RUN=true`.
- [ ] Prometheus-Ziele: `pve-exporter` (Token aus 4.1), `node-exporter` und `nvidia_gpu_exporter` in den GPU-VMs, Router.
- [ ] Grafana: Dashboards für Proxmox, Knoten, GPU (Community-JSON) und knaster. Konto `kiosk` mit Rolle Viewer.
- [ ] Alertmanager an den Router (Healer, Dry-Run) und an n8n `knaster-notify`. Der Workflow wird inaktiv importiert, dann aktiviert.
Prüfung: Grafana zeigt alle 5 Knoten und alle GPUs. Ein Test-Alert (`amtool alert add test`) erscheint im Kanal.
- [x] `deploy/compose/infra` ohne den Dienst `litellm` (D4). Router zeigt auf `http://192.168.100.29:<LiteLLM-Port>`. Image-Tags pinnen (offener Punkt aus IMPLEMENTATION). 12.10.: erledigt, alle Images gepinnt; **Port offen** (LiteLLM auf appsrv08 nicht im LAN veröffentlicht, vorläufig `:4000` in `roles/infra-stack/defaults`, Task 6.3).
- [x] LXC `infra` (vmid 101, Nesting, 4 Kerne, 8 GB, Knoten laut RAM-Budget) mit HA und Replikation (Task 2.2), 12.10.: tofu, Debian 13 auf `tank` (60 GB), DHCP `.188`, Docker + Compose 2.26 (Rolle `docker-host`).
- [x] Compose-Stack mit `KNASTER_DRY_RUN=true`. 12.10.: Rolle `infra-stack`, `playbooks/monitoring.yml`, idempotent; Geheimnisse nur in `/opt/knaster/infra/.env` (BETRIEB §7).
- [x] Prometheus-Ziele: `pve-exporter` (Token aus 4.1), `node-exporter` und `nvidia_gpu_exporter` in den GPU-VMs, Router. 12.10.: GPU-VMs per file_sd (`monitoring-targets.yml`), Rolle `gpu-exporters`; alle 5 Knoten und 7 GPUs sichtbar.
- [x] Grafana: Dashboards für Proxmox, Knoten, GPU (Community-JSON) und knaster. Konto `kiosk` mit Rolle Viewer. 12.10.: grafana.com 10347, 1860, 14574 und „knaster – Übersicht“; Kiosk-Passwort in `/opt/knaster/infra/.env` (für 5.3).
- [ ] Alertmanager an den Router (Healer, Dry-Run) und an n8n `knaster-notify`. Der Workflow wird inaktiv importiert, dann aktiviert. 12.10.: **Router erledigt** (Test-Alert kommt mit 200 an), n8n-Receiver nur vorbereitet (auskommentiert, Format passt nicht direkt, braucht den Nutzer).
Prüfung: Grafana zeigt alle 5 Knoten und alle GPUs. Ein Test-Alert (`amtool alert add test`) erscheint im Kanal. 12.10.: Knoten/GPUs ja, Test-Alert im Alertmanager und beim Router ja, Kanal (n8n) offen. HA-Probe (Wartungsmodus pve44, zweimal hin und zurück) bestanden, nachdem ein ZFS-Cache-Fehler gefunden und im LXC abgefangen wurde (BETRIEB §7).
### Task 5.3: Kiosk auf pve61
@@ -325,8 +359,24 @@ Ersetzt GPU-Knoten 3 (Teil Cluster). Mit 5 Stimmen ist **kein QDevice** nötig.
Ersetzt PLAN §8 „1 Data Plane“.
- [ ] **6.1 GPU-Rollen:** `deploy/compose/gpu-llamacpp` in den GPU-VMs nach der Zuordnung aus Task 3.3, also Embedding und Rerank redundant auf zwei Knoten, Chat für verifier und signature. Prüfung: Canary-Checks grün (`knaster` Canary-Prober).
- [ ] **6.2 STT** (nex-STT-Plan Tasks 2–4): `whisper-server` als zusätzlicher Dienst in der GPU-VM des in Task 3.3 gewählten Knotens. Neuer Slot `stt-de` im Katalog. Port 8178 nur von `192.168.100.29` (Firewall der VM bzw. des Gasts). Prüfung: Von appsrv08 liefert `curl -F file=@probe.wav http://<IP>:8178/v1/audio/transcriptions` deutschen Text, von einem anderen Rechner wird der Zugriff abgelehnt.
- [x] (15.10.) **6.0 k3s (D6):** Dienst-VM `k3s` (tofu `service_vms`, HA + Replikation über Rolle pve-host), Rollen `k3s-server`, `k3s-agent`, `k3s-apps`, Manifeste `deploy/k8s/`, `playbooks/k3s.yml`. Fünf Knoten Ready, `nvidia.com/gpu` 2/2/2/1, Portainer-Agent läuft (NodePort 30778 von Laptop erreichbar). Umzugstest: NEX-Sprachdienste als Deployments (`playbooks/nex-speech-k3s.yml`), Rundlauf TTS→STT korrekt. BETRIEB §14.
- [x] (15.10.) Portainer CE 2.45.1 im Cluster (Dienst-VM k3s) mit der Datenbank von appsrv08: Rollback BE→CE geprüft, API-Schlüssel gültig (`playbooks/portainer-k3s.yml`, BETRIEB §14). Grund: Business-Lizenz zählt die fünf k3s-Knoten (Angebot ~1000 €), CE ist frei. Offen (Nutzer, Admin-Login nötig): Umgebung „appsrv08“ (Agent `192.168.100.29:9001`, läuft) anlegen und `godel-deployer` freigeben; dann nginx `portainer.mygodel.de` auf `:30779` umstellen, EE stoppen, nex-Admin-Endpoint/URL anpassen (mit SearXNG-Variable im nex-Repo).
- [x] (15.10.) Zuteilung angehoben, weil der Cluster nur die Hälfte der Hosts zeigte: GPU-VMs 4→8 vCPU, 12→20 GB; infra 8→4 GB; ZFS-ARC 4→2 GB (neu in Rolle pve-host, `cluster.zfs_arc_max_mb`); tofu `reboot_after_update=false`, VMs einzeln per `qm reboot` (je ~30 s bis Ready). k3s: 34 vCPU, 83 GB. Nutzer-Idee pve61 als Cache: geht nicht, ARC ist Host-lokal; pve61 (8 GB, kein tank) bleibt Quorum/Kiosk, könnte später CPU-Agent werden.
- [x] (15.10.) Umzug 3: RustDesk-Server (Stack `rustdesk` auf appsrv08) als Deployment + Service LoadBalancer in k3s (`playbooks/rustdesk-k3s.yml`), gleiches Schlüsselpaar, Ports auf allen Knoten erreichbar. Clients zeigen weiter auf .29; Umstellung erst mit fester Adresse/DNS-Name (Nutzer), dann Installer-Name im Stack `rustdesk-updater` und Stack `rustdesk` auf appsrv08 abschalten.
- [x] (15.10.) Umzugstest 2 aus Portainer-Umgebung appsrv08: SearXNG (`nex-searxng`) als Deployment + NodePort 30880 in k3s, Suche liefert Treffer. Cutover: Nutzer hat 15.10. Weg 2 gewählt (SEARXNG_URL im nex-Admin als Stack-Variable konfigurierbar machen, nex-Repo), Rollout nach DHCP-Reservierung der k3s-VM.
- [x] (19.10.) Agenten im Cluster (nex-Spec 2026-10-19 D256–D267): Namespace nex mit Brücke zu appsrv08 (Services ohne Selector: router 4100, know 14200, access 4300, grp-mcp-intern 8790), ServiceAccount nex-admin (Role nur Namespace, Token auf ops), Playbook `playbooks/nex-k3s.yml`; nex-admin 0.2.1 auf appsrv08 legt neue Agenten als Deployment/PVC/Secret/NodePort-Service an, Umzug per UI/API, Traefik auf appsrv08 leitet Port-Adressen an NodePort <port>+20000. Offen: Pull-Secret `gitea-registry` (Gitea-Token read:package in /root/.config/knaster/gitea-registry.env, dann Playbook), Umzugsprobe nextest3, feste Adresse der k3s-VM (NEX_K3S_HOST).
- [ ] Offen: Umgebung in Portainer (appsrv08) anlegen (Nutzer, 15.10. laut Nutzer erledigt), DHCP-Reservierung für VM 110 (`bc:24:11:a5:d5:52`), llama.cpp (6.1, seit 19.10. in k3s), Monitoring der Pods (Task 6.5), Exporter-Stacks umziehen, Docker danach aus den VMs entfernen.
- [ ] **6.1 GPU-Rollen:** ab 15.10. als k3s-Manifeste (D6) statt Compose: `deploy/compose/gpu-llamacpp` in den GPU-VMs nach der Zuordnung aus Task 3.3, also Embedding und Rerank redundant auf zwei Knoten, Chat für verifier und signature. Prüfung: Canary-Checks grün (`knaster` Canary-Prober).
- [x] (19.10.) llama.cpp läuft in k3s (`playbooks/gpu-llamacpp-k3s.yml`, Rolle gpu-llamacpp `tasks/k3s.yml`, Vorlage `deploy/k8s/llamacpp/llamacpp.yaml.j2`, Namespace `knaster`): ein Pod je VM, Host-Netz mit den alten Ports (8081/8082 auf gpu-pve44/46, 8080 auf gpu-pve48), Image aus Docker in containerd importiert. Compose-Stack auf gpu-pve48 angehalten (Rückweg: Deployment löschen, `docker compose --project-directory /opt/knaster/gpu-llamacpp start`); `playbooks/gpu-llamacpp.yml` bricht ohne `-e llamacpp_compose=true` ab.
- [x] (19.10.) Kartenwahl: Das Device Plugin vergibt nur noch Index 0 (M4000, per Playbook auf allen GPU-VMs geprüft), allocatable `nvidia.com/gpu` 1/1/1/1 statt 2/2/2/1. Die P2000 bekommt der llama.cpp-Pod fest per UUID (`NVIDIA_VISIBLE_DEVICES`); im Pod ist nur sie sichtbar, `nex-stt` lief durch.
- [x] (19.10.) Befund VRAM: Embedding mit `-ub 8192` braucht allein 5 GB Rechenpuffer (wächst mit ub × Vokabular 151k), Flash-Attention ändert daran nichts. Mit `-ub 4096`: Embedding + Rerank zusammen 4,4–4,6 von 5 GB auf der P2000, Chat 4,1 GB. Auch im Compose (Rückweg) angepasst.
- [x] (19.10.) Prüfung statt Canary (die Canaries fragen über LiteLLM, das kommt mit 6.3): Prometheus `llamacpp` 5/5 `up`; vom LAN (ops) liefern beide Embedding-Instanzen 1024-dim Vektoren (0,06–0,08 s), beide Rerank-Instanzen sortieren richtig, Chat antwortet „OK.“ (0,46 s); 5.467 Tokens in einem Embedding-Aufruf (11,6 s).
- [ ] Offen: Canary-Checks grün nach 6.3. Registry und Healer-Regeln führen noch `docker_host`/`container` (Aktion `restart_container` träfe einen Docker-Container, den es nicht mehr gibt); heute folgenlos (Dry-Run, `enabled_actions: []`), vor dem Scharfschalten auf Pod-Neustart umstellen.
- [ ] **6.2 STT und TTS für NEX** – Auftrag vom 12.10. mit Abgleich: `docs/auftraege/2026-10-12-sprachmodelle-nex.md` (Erkennung whisper.cpp auf einer M4000, naheliegend `gpu-pve52`; Ausgabe Piper auf CPU, Port 8179; Abnahme und Rückmeldeformat dort). Läuft nach Task 3.2, gern als Subagent. **Blockiert für die Abnahme:** feste IP (D5). Bisheriger Text (nex-STT-Plan Tasks 2–4): `whisper-server` als zusätzlicher Dienst in der GPU-VM des in Task 3.3 gewählten Knotens. Neuer Slot `stt-de` im Katalog. Port 8178 nur von `192.168.100.29` (Firewall der VM bzw. des Gasts). Prüfung: Von appsrv08 liefert `curl -F file=@probe.wav http://<IP>:8178/v1/audio/transcriptions` deutschen Text, von einem anderen Rechner wird der Zugriff abgelehnt.
- [x] (12.10.) Umgesetzt in `gpu-pve52`: `nex-stt` (whisper.cpp v1.9.4, CUDA 12.6, sm_52/61, `large-v3-turbo`, Port 8178) und `nex-tts` (Piper 1.8.0, `de_DE-thorsten-high` + `de_DE-kerstin-low`, Port 8179) als `deploy/compose/nex-speech`, Rolle/Playbook `nex-speech`. ufw in der VM (8178/8179 nur von `.29`, SSH aus dem LAN), Proxmox-Firewall bewusst aus.
- [x] (12.10.) Tests in der VM und von ops (temporäre Regel, danach entfernt): 10 s → 2,94 s, 60 s → 8,63 s (Median), TTS-TTFB 0,64–0,91 s (mp3/pcm/opus), Autostart nach `qm reboot 252` geprüft, keine Texte in Logs. Rückmeldung im Auftrag.
- [x] (19.10.) Feste IP: `192.168.100.174` per DHCP-Reservierung bestätigt (D5, Task 0.2).
- [ ] Offen: Abnahme von appsrv08 durch das NEX-Team, Slot `stt-de` (Core kennt kein `kind: stt`), `tts-de` später nach `infra`.
- [ ] **6.3 Lokale Slots in LiteLLM (D4):** `embed-default`, `rerank-default`, `verifier-small` und `signature-extractor` als Modelle im LiteLLM auf appsrv08 eintragen (IP-Literale). Prüfung: `curl http://192.168.100.29:<port>/v1/models` listet die Slots, ein Aufruf je Slot antwortet.
- [ ] **6.4 Ada-Worker (D3)** · **Planner:** „LLM-Worker: direkte Inferenz-Schnittstelle für LiteLLM (zusätzlich zum Job-Poll), Tunnel, Registry, Fallback-Kette“
- Offene Schritte der Worker-Registry v2 im Repo `mygodel/nex` abschließen.
@@ -337,6 +387,24 @@ Ersetzt PLAN §8 „1 Data Plane“.
- `test_worker_registry_slot_falls_back_to_tier0_when_list_empty`
- `test_worker_registry_slot_falls_back_when_list_unreachable`
- Prüfung: `make test` PASS. Live tagsüber geht eine tier1-Anfrage an tier0, nachts an GODE-PC-05.
- [ ] **6.5 Pod-Monitoring (k3s in Grafana)** · **Planner:** „Proxmox: Grafana-Dashboard für den Cluster“ · **Files:** Create `deploy/k8s/monitoring/`, `platform/proxmox/ansible/playbooks/monitoring-k3s.yml`, `deploy/grafana/dashboards/k3s-pods.json`; Modify `deploy/prometheus/prometheus.yml`, `deploy/prometheus/alerts.yml`, `config/healer-rules.yaml`, `platform/proxmox/scripts/monitoring-targets.py`, Rolle `infra-stack` (Token, Ziele), `deploy/compose/infra/docker-compose.yml`, `core/tests/test_artifacts.py`, BETRIEB §7/§14
Ausgangslage 19.10.: Grafana 12.4 und Prometheus laufen auf infra mit Dashboards für Proxmox, Knoten, GPU und knaster; für k3s gibt es kein Ziel. Zustand und Speicher der Pods zeigt bisher nur nex-admin über die Metrics-API (metrics-server, nur Momentwerte, keine Grafana-Datenquelle).
- [x] (19.10.) kube-state-metrics v2.19.1 im Namespace `kube-system` (`deploy/k8s/monitoring/kube-state-metrics.yaml`, nur die nötigen Ressourcen), Service NodePort 30808.
- [x] (19.10.) ServiceAccount `prometheus-infra`, ClusterRole nur `get nodes/metrics`, langlebiges Token-Secret. Token und CA legt `playbooks/monitoring-k3s.yml` auf ops ab (`/root/.config/knaster/k3s-prometheus.token`, `k3s-ca.crt`), die Rolle `infra-stack` kopiert sie nach `/opt/knaster/infra/secrets/k3s/` (nur dieser Ordner in Prometheus eingehängt). Die kubelet-Zertifikate sind von `k3s-server-ca` signiert und tragen die Knoten-IP (geprüft auf gpu-pve52), daher TLS ohne `insecure_skip_verify`.
- [x] (19.10.) Ziele per file_sd aus `kubectl get nodes` (statt tofu, so ist die k3s-VM dabei): `monitoring-targets.py --k3s-nodes` schreibt `kubelet.json` (alle Knoten, 10250) und `kube-state-metrics.json` (ein Ziel, NodePort auf der Dienst-VM k3s, sonst erster bereiter Knoten; `instance` fest, damit ein Wechsel keine neuen Reihen erzeugt).
- [x] (19.10.) Prometheus-Jobs `kubelet`, `cadvisor`, `kube-state-metrics`. cAdvisor behält nur CPU, Arbeitssatz, RSS, OOM und Netz und verwirft Reihen ohne Pod sowie Pod-Summen/Pause-Container.
- [ ] Speicher von Prometheus eine Woche beobachten (infra hat 4 GB).
- [x] ufw: Die Rolle `k3s-agent` erlaubt 10250 und 30000–32767 schon seit 15.10. aus dem LAN, keine Änderung nötig.
- [x] (19.10.) Dashboard „k3s – Pods“ (`deploy/grafana/dashboards/k3s-pods.json`, eigenes statt Community, passend zu den behaltenen Metriken): Namespace-Auswahl mit Vorgabe `nex`, Kennzahlen, Speicher/CPU je Pod, Tabelle mit Knoten, Neustarts, Speicher gegen Request/Limit, Pods und Speicher je Knoten, PVC-Belegung.
- [ ] Dashboard in die Kiosk-Playlist (5.3) aufnehmen, sobald es die Playlist gibt.
- [x] (19.10.) Alerts `K3sNodeNotReady`, `K3sPodCrashLooping` (> 10 min), `K3sDeploymentUnavailable` (> 15 min), `K3sContainerMemoryNearLimit` (> 90 %, 10 min); Healer-Regeln nur `notify`.
- Prüfung: `make test` PASS (Struktur von prometheus.yml/alerts.yml). Auf infra zeigt `api/v1/targets` die Jobs `kubelet`, `cadvisor`, `kube-state-metrics` mit allen fünf Knoten `up`. Grafana zeigt für `nex` dieselben Pods und ungefähr denselben Speicher wie `kubectl top pods -n nex`. Ein absichtlich kaputtes Test-Deployment löst den CrashLoop-Alert aus.
- [x] (19.10.) Prüfung bestanden: alle 11 k3s-Ziele `up`; Speicher je Pod in `nex` gleich `kubectl top` (344/354/122 MiB); ~2000 neue Reihen; Dashboard im Ordner `knaster`. Test-Deployment `knaster-crashtest` (busybox, `exit 1`): `K3sDeploymentUnavailable` feuert nach 15 min; `K3sPodCrashLooping` flackerte zwischen den Neustarts und blieb pending, seit `max_over_time(…[5m])` feuert er nach ~14 min. Test-Deployment gelöscht.
- [ ] **6.6 Namespace nex absichern (Pod Security)** · vom Nutzer am 19.10. gewünscht · **Files:** `deploy/k8s/nex-speech/` (Namespace `nex-speech`), `deploy/k8s/nex/` (Label), `playbooks/nex-speech-k3s.yml`, Rolle `nex-speech-k3s`, BETRIEB §14
Anlass: nex-admin darf in `nex` Pods anlegen, und ohne Pod-Security-Regel auch mit `privileged`, `hostPath`, `hostNetwork`; ein abgeflossenes Token hieße damit Root auf einer GPU-VM. „baseline“ für `nex` geht erst, wenn die Sprachdienste (Host-Netz, hostPath) dort nicht mehr laufen.
- [ ] `nex-stt` und `nex-tts` in einen eigenen Namespace `nex-speech` umziehen (gleiche Host-Ports 8178/8179 auf gpu-pve52, kurze Unterbrechung, vorher mit dem NEX-Team abstimmen).
- [ ] Namespace `nex`: `pod-security.kubernetes.io/enforce: baseline` (zuerst `warn`/`audit` und prüfen, ob nex-admin-Agenten und SearXNG passen).
- [ ] Prüfung: Als nex-admin wird ein Pod mit `hostPath` oder `privileged` in `nex` abgelehnt; Agenten, SearXNG und die Sprachdienste laufen; Rundlauf TTS→STT von appsrv08.
---
@@ -364,6 +432,20 @@ Die Software-Phasen aus PLAN §8 bleiben inhaltlich gültig und bekommen jeweils
- „5 Lifecycle“: QLoRA-Training. Voraussetzung ist die Entscheidung über WSL2 bzw. vLLM auf den Ada-Rechnern nachts (D3, Folgeentscheidung).
- „6 Portabilität“
### Verwaltung durch mehrere Personen (Nutzerwunsch 19.10.)
- [x] **Weg 1 (19.10.):** Verwaltung an Personen statt an den Admin-Laptop gebunden. Einstieg ist ops (Bastion):
Schlüssel je Person in `sites/knaster/proxmox.yaml` (`access.ops_ssh_keys`, `ops_ssh_keys_revoked`), Playbook
`playbooks/admins.yml` (nur hinzufügen bzw. ausdrücklich entziehen), Test `test_ops_admin_keys_in_site_file`.
Aufnahme und Entzug in BETRIEB §15. Einstieg für Agenten: `AGENTS.md`, `project-map.md`, `known-issues.md`.
- [ ] **Weg 2 (später, Entwurf):** Verwaltung über NEX statt persönlicher Shell. nex-admin bekommt einen Bereich
„Infrastruktur“ (Anmeldung, Rollen und Protokoll von NEX). Dahinter auf ops ein kleiner Dienst, der nur freigegebene
Aktionen ausführt (bestimmtes Playbook, Dienst neu starten, Zustand zeigen), keine freie Shell. Neue Dinge entstehen
weiter im Repo: Ein Agent in NEX schreibt die Änderung als Branch in Gitea, ein Mensch gibt per Merge frei, der Dienst
rollt danach aus. Agenten schlagen nur vor (Grundsatz wie beim Healer). Anknüpfen an das Vorschlag-/Freigabe-Verfahren
von knaster (Policy „approve“). Offen vor dem Start: Wer verwaltet künftig (interne IT, Dienstleister, mehrere
Rollen)? Eigener Plan.
Am Ende von Phase 9 wird das Betriebshandbuch abgeschlossen („PVE-Config: Betriebshandbuch und Testvorgehen anlegen“). Das geschieht als Erweiterung von `docs/BETRIEB.md`, nicht als neues Dokument.
---
@@ -388,7 +470,7 @@ Am Ende von Phase 9 wird das Betriebshandbuch abgeschlossen („PVE-Config: Betr
| Planner-Aufgabe (exakter Titel) | Im Plan | Planner-Aktion nach Freigabe |
|---|---|---|
| Proxmox: pve48 root-Passwort klären und SSH-Schlüssel nachtragen | Task 0.1 | bleibt |
| PVE-Config: Netzwerk prüfen – Bridge, VLAN-Fähigkeit und NIC-Kompatibilität | Task 0.2 (D5), VLAN später | bleibt, Beschreibung ergänzen |
| PVE-Config: Netzwerk prüfen – Bridge, VLAN-Fähigkeit und NIC-Kompatibilität | Task 0.2 (D5), VLAN später | 13.10. geprüft (BETRIEB §12: e1000e/r8152, 1 GBit, kein VLAN-aware, keine Hänger), erledigt; Reservierungen bleiben in 0.2 |
| Proxmox: Konfiguration aller Knoten durcharbeiten | aufgeteilt auf 0.1, 1.1–1.3 | schließen mit Verweis auf diesen Plan |
| PVE-Config: Erst-Update aller Knoten (apt full-upgrade) | Task 1.1 | bleibt |
| Proxmox: Speicher aller Knoten richtig konfigurieren | D1, Task 1.2 | bleibt |
@@ -409,7 +491,7 @@ Am Ende von Phase 9 wird das Betriebshandbuch abgeschlossen („PVE-Config: Betr
| Proxmox: pve61 (Surface) zeigt das Grafana-Dashboard als Kiosk | Tasks 1.3, 5.3 | bleibt |
| LLM-Worker: direkte Inferenz-Schnittstelle für LiteLLM (zusätzlich zum Job-Poll), Tunnel, Registry, Fallback-Kette | Task 6.4 | bleibt, Beschreibung: Registry v2 + knaster-Brücke |
| Proxmox: GRP als Testservice in einen LXC-Container umziehen | Phase 8 | bleibt |
| PVE-Config: Betriebshandbuch und Testvorgehen anlegen | Phase 9 (`docs/BETRIEB.md`) | bleibt |
| PVE-Config: Betriebshandbuch und Testvorgehen anlegen | Phase 9 (`docs/BETRIEB.md`) | 13.10. erledigt: §8–§13 und `scripts/healthcheck.sh` |
| P1–P8 (NEX-Aufgaben) | Parallelspur | bleiben |
**Neu angelegt** (nur Schritte ohne bestehende Aufgabe, keine Duplikate):
+86
View File
@@ -0,0 +1,86 @@
# Bekannte Probleme und Fallen
Stand 19.10.2026. Je Eintrag: was passiert, Auswirkung, Umgang, wo es weitergeht. Behobene Einträge nicht löschen,
sondern unter „Erledigt“ verschieben (mit Datum), damit die Lehre erhalten bleibt. Siehe auch [AGENTS.md](AGENTS.md),
[project-map.md](project-map.md).
## Offen
### Netz und Zugang
- **DHCP-Reservierungen unvollständig.** Reserviert sind pve44, pve52, gpu-pve52 und der Laptop. Für infra, pve46 und
pve48 geht laut DC keine Reservierung (alle drei sind statisch konfiguriert, Risiko: doppelt vergebene Adresse).
Ohne Rückmeldung: ops, k3s-VM (.124), gpu-pve44/46/48, pve61, Gäste-Block `.220–.239` (am 19.10. erneut angefragt).
Auswirkung: Ändert sich die Adresse der k3s-VM, brechen Portainer CE (nginx-Ziel), `NEX_K3S_HOST` und die geplante
RustDesk-Umstellung. Ändern sich GPU-VM-Adressen: `refresh-vm-access.sh`, dann `playbooks/monitoring-targets.yml`.
→ Aufbauplan Task 0.2.
- **Firewall sperrt bei Adresswechsel aus.** Die Proxmox-Firewall lässt nur Adressen aus `firewall.management` an
22/8006. Rückweg über ops oder Konsole (BETRIEB §10).
- **Namespace `nex` ohne Pod Security.** nex-admin darf dort Pods mit `privileged`/`hostPath`/`hostNetwork` anlegen;
ein abgeflossenes Token wäre damit Root auf einer GPU-VM. → Task 6.6 (Sprachdienste nach `nex-speech`, dann
„baseline“).
### Monitoring und Healer
- **`SlotEndpointDown` feuert seit 15.10.** für tier1-flex, tier1-general (Ada-Rechner noch nicht angebunden, 6.4),
tts-de, tts-de-hq, embed-default, verifier-small, signature-extractor (Canaries laufen über LiteLLM, das kommt mit 6.3).
Erwartet, kein Handlungsbedarf bis 6.3/6.4.
- **vLLM-Ziele `gpu-main`/`gpu-flex` down** (DNS-Namen ohne Auflösung, Ada-Rechner nicht angebunden). → Task 6.4.
- **Healer kennt nur Docker-Container.** Registry und Healer-Regeln führen `docker_host`/`container`; llama.cpp und
die Sprachdienste laufen inzwischen in k3s. `restart_container` würde ins Leere greifen. Heute folgenlos
(Dry-Run, `enabled_actions: []`); vor dem Scharfschalten auf Pod-Neustart umstellen. → Task 6.1 (offen), Phase 9.
- **n8n-Receiver im Alertmanager nur vorbereitet** (auskommentiert, Format passt nicht). Alerts gehen nur an den
Router. → Task 5.2.
- **Prometheus-Neustart setzt Alert-Zustände zurück** (alles wieder „pending“). Nach einem Neustart des Stacks die
`for:`-Dauer abwarten, bevor man auf fehlende Alerts schließt.
- **Speicher von Prometheus auf infra beobachten** (4 GB RAM, seit 19.10. ~2000 Reihen mehr durch k3s). → Task 6.5.
- **`K3sPodCrashLooping` bleibt bis zu 5 min länger stehen,** nachdem ein Pod behoben oder gelöscht wurde
(`max_over_time[5m]`, nötig gegen Flackern).
### GPU und Modelle
- **Embedding auf der P2000 nur mit `-ub 4096`.** Mit 8192 braucht llama.cpp (Stand b6000) allein 5 GB Rechenpuffer
(ub × Vokabular), Flash-Attention hilft nicht. Belegung mit Rerank: 4,4–4,6 von 5 GB, wenig Reserve.
- **`nvidia.com/gpu` vergibt nur die M4000** (Device Plugin sieht nur Index 0). Wer einen GPU-Pod anlegt, bekommt
eine M4000; die P2000 gehört llama.cpp. Annahme: Index 0 ist in jeder GPU-VM die M4000 – das Playbook
`gpu-llamacpp-k3s.yml` prüft das vor jeder Änderung.
- **LiteLLM-Port auf appsrv08 nicht im LAN veröffentlicht.** Blockiert Task 6.3 (lokale Slots in LiteLLM) und damit
grüne Canaries. → Nutzer, Task 6.3.
### Knoten und Speicher
- **ZFS-Seiten-Cache nach HA-Umzug.** Nach `zfs receive` liefert ein Knoten für früher gemappte Dateien veralteten
Inhalt; Docker brach am 12.10. mit `invalid freelist page` ab. Abgefangen im LXC infra durch
`knaster-drop-stale-cache.service`; jeder neue replizierte LXC mit mmap-Nutzern braucht denselben Schutz (BETRIEB §7).
- **pve44: EFI-Partition nicht eingehängt** (`sda2` fehlt in `/etc/fstab`); GRUB-Updates erreichen sie nicht.
→ Aufbauplan Task 1.1, Nebenbefund.
- **pve61: Neustart nach dem Kernel-Update offen.** → Aufbauplan Task 1.1, Step 4.
- **Kein Backup.** Proxmox Backup Server und Ziel-Speicher stehen aus. → Task 5.1 (Entscheidung Nutzer).
### Übergänge (alt und neu laufen parallel)
- **Portainer:** EE auf appsrv08 läuft weiter, `portainer.mygodel.de` zeigt noch dorthin; CE im Cluster ist bereit.
Umstellung braucht Admin-Login und feste Adresse der k3s-VM (BETRIEB §14).
- **RustDesk:** Server läuft zusätzlich in k3s, Clients zeigen weiter auf `.29`.
- **SearXNG:** läuft in k3s und auf appsrv08; Umstellung der Agenten über `SEARXNG_URL` im nex-Admin.
### Arbeitsumgebung
- **Windows-Arbeitskopie mit CRLF:** `yamllint` meldet „wrong new line character“ für geänderte Dateien. Den Stand aus
Git prüfen (`git show HEAD:<datei> | yamllint -`), siehe AGENTS.md.
- **`test_tts_server` scheitert unter Windows** (kein Piper-Programm). Unter Linux grün.
- **`test_ontology.py::test_cli_check_with_snapshot` scheitert unter Windows** (liest ohne UTF-8, cp1252). Mit
`PYTHONUTF8=1` grün; unter Linux grün.
- **`main` und `plan/aufbau-2026-10-12` laufen auseinander,** wenn auf `main` direkt gearbeitet wird (13.10. passiert,
19.10. zusammengeführt). Gitea zeigt `main`, ops arbeitet mit dem Plan-Branch. Nur auf dem Plan-Branch arbeiten
oder sofort zusammenführen.
- **Kein Python-venv auf ops:** `make test`/`make lint` laufen auf dem Laptop; auf ops nur Ansible-Syntaxprüfung.
- **Ansible-Ad-hoc mit Go-Templates** (`docker ps --format '{{.Names}}'`) scheitert, weil Ansible `{{ }}` auswertet.
Skript per `-m script` verwenden.
- **Mehrere Agenten-Sitzungen im selben Repo:** Eine Sitzung kann fremde, noch nicht committete Änderungen mit
committen. Nur eigene Dateien stagen, vor dem Push `git pull --rebase`.
## Erledigt
- 19.10.: Embedding/Rerank auf gpu-pve44/46 liefen nicht (Prometheus-Ziele 8081/8082 down) – seit Task 6.1 in k3s.
- 19.10.: Laptop wurde bei Lease-Wechsel von der Firewall ausgesperrt (14.10. passiert) – WLAN und Dock reserviert.
+1906
View File
File diff suppressed because it is too large Load Diff
+1292
View File
File diff suppressed because it is too large Load Diff
+459
View File
@@ -0,0 +1,459 @@
<title>KNOW Ontologie</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans+Condensed:wght@500;600&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;1,500&display=swap">
<style>
/* Layout: Begriffslandkarte – Klassen als beschriftete Kästen, Bereiche als Cluster; rechts das Register mit Definitionen und Turtle */
:root {
--bg: #f3f4f1;
--surface: #ffffff;
--fg: #1d2421;
--muted: #5f6b66;
--line: #d5dad6;
--edge: #8f9a94;
--key: #2e3a35;
--soft: #7b6a3f;
--ext: #5b7682;
--sys-bg: #1d2421;
--sys-fg: #f3f4f1;
--focus: #3f6f9f;
--warn: #b42318;
--d-mail: #3f7cac;
--d-kanbau: #2f8f6b;
--d-docs: #6d6a9e;
--d-kaf: #b8742a;
--d-finance: #8a5a44;
--d-learning: #7a8c2e;
--d-agent: #9b4f8f;
--d-bsb: #5b7682;
--d-llm: #c4471b;
--d-system: #1d2421;
--font-ui: "IBM Plex Sans", "Segoe UI", system-ui, sans-serif;
--font-head: "IBM Plex Sans Condensed", "IBM Plex Sans", "Arial Narrow", system-ui, sans-serif;
--font-mono: "IBM Plex Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #121615; --surface: #1a201e; --fg: #e3e8e5; --muted: #98a59f; --line: #2c3532; --edge: #5f6c66;
--key: #c9d2cd; --soft: #d2b56e; --ext: #8fb0bd; --sys-bg: #e3e8e5; --sys-fg: #121615; --focus: #78a9d6; --warn: #ff8a7a;
--d-mail: #6fa8d6; --d-kanbau: #5cc29b; --d-docs: #a29fd6; --d-kaf: #e0a35a; --d-finance: #c49176;
--d-learning: #b3c65a; --d-agent: #d283c4; --d-bsb: #8fb0bd; --d-llm: #f0784a; --d-system: #e3e8e5;
color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #121615; --surface: #1a201e; --fg: #e3e8e5; --muted: #98a59f; --line: #2c3532; --edge: #5f6c66;
--key: #c9d2cd; --soft: #d2b56e; --ext: #8fb0bd; --sys-bg: #e3e8e5; --sys-fg: #121615; --focus: #78a9d6; --warn: #ff8a7a;
--d-mail: #6fa8d6; --d-kanbau: #5cc29b; --d-docs: #a29fd6; --d-kaf: #e0a35a; --d-finance: #c49176;
--d-learning: #b3c65a; --d-agent: #d283c4; --d-bsb: #8fb0bd; --d-llm: #f0784a; --d-system: #e3e8e5;
color-scheme: dark;
}
html, body { height: 100%; }
body { background: var(--bg); color: var(--fg); font-family: var(--font-ui); font-size: 14px; }
.app { height: 100%; display: grid; grid-template-rows: auto 1fr; }
header { display: flex; flex-wrap: wrap; gap: 10px 20px; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--line); background: var(--surface); }
.brand { display: flex; flex-direction: column; gap: 2px; min-width: 0; margin-right: auto; }
h1 { margin: 0; font-family: var(--font-head); font-weight: 600; font-size: 1.35rem; letter-spacing: .01em; text-wrap: balance; }
.brand p { margin: 0; color: var(--muted); font-size: .82rem; }
.controls { display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: center; }
#q { font: inherit; padding: 6px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--fg); width: 15rem; max-width: 100%; }
.toggle { display: inline-flex; align-items: center; gap: 6px; font-size: .85rem; color: var(--muted); cursor: pointer; user-select: none; }
.toggle input { accent-color: var(--focus); }
:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
main { display: grid; grid-template-columns: minmax(0, 1fr) 400px; min-height: 0; }
.stage { position: relative; min-width: 0; min-height: 0; }
svg#graph { width: 100%; height: 100%; display: block; cursor: grab; touch-action: none; }
.legend { position: absolute; left: 12px; bottom: 12px; right: 12px; display: flex; flex-wrap: wrap; gap: 6px; pointer-events: none; }
.legend > * { pointer-events: auto; }
.chip { display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px; border-radius: 999px; border: 1px solid var(--line); background: var(--surface); font-size: .78rem; color: var(--fg); cursor: pointer; font-family: var(--font-ui); }
.chip[aria-pressed="true"] { border-color: var(--fg); }
.sw { width: 10px; height: 10px; border-radius: 2px; display: inline-block; flex: none; }
.edgekey { display: inline-flex; gap: 12px; flex-wrap: wrap; padding: 4px 10px; border-radius: 6px; background: var(--surface); border: 1px solid var(--line); font-size: .78rem; color: var(--muted); }
.edgekey span { display: inline-flex; align-items: center; gap: 6px; }
.edgekey i { display: inline-block; width: 22px; height: 0; border-top: 2px solid; }
aside { border-left: 1px solid var(--line); background: var(--surface); overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 14px; min-width: 0; }
aside h2 { margin: 0; font-family: var(--font-head); font-weight: 600; font-size: 1.2rem; overflow-wrap: anywhere; }
aside h3 { margin: 0 0 6px; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); font-weight: 600; }
aside p { margin: 0; line-height: 1.5; }
.mono { font-family: var(--font-mono); font-size: .85em; }
.meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.tag { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: .75rem; border: 1px solid var(--line); color: var(--muted); }
.sensbox { border-left: 3px solid var(--d-kaf); padding: 6px 10px; background: var(--bg); border-radius: 4px; }
ul.rel { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 7px; }
ul.rel li { display: grid; gap: 1px; }
.prop { font-family: var(--font-mono); font-size: .8rem; color: var(--muted); }
button.link { all: unset; cursor: pointer; color: var(--fg); font-weight: 500; text-decoration: underline; text-decoration-color: var(--line); text-underline-offset: 3px; overflow-wrap: anywhere; }
button.link:hover { text-decoration-color: currentColor; }
button.link:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: 2px; }
.via { color: var(--muted); font-family: var(--font-mono); font-size: .74rem; overflow-wrap: anywhere; }
.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.stat { background: var(--bg); border-radius: 6px; padding: 8px 10px; display: grid; gap: 2px; }
.stat b { font-size: 1.3rem; font-family: var(--font-head); font-variant-numeric: tabular-nums; }
.stat span { font-size: .75rem; color: var(--muted); }
.vals { display: flex; flex-wrap: wrap; gap: 4px; }
.vals span { font-family: var(--font-mono); font-size: .74rem; padding: 1px 6px; border-radius: 4px; background: var(--bg); }
table.cols { width: 100%; border-collapse: collapse; font-family: var(--font-mono); font-size: .76rem; }
table.cols td { padding: 2px 0; border-bottom: 1px solid var(--line); vertical-align: top; overflow-wrap: anywhere; }
table.cols td:last-child { color: var(--muted); text-align: right; padding-left: 8px; white-space: nowrap; }
details summary { cursor: pointer; color: var(--muted); font-size: .8rem; }
pre.ttl { margin: 8px 0 0; max-height: 340px; overflow: auto; background: var(--bg); padding: 10px; border-radius: 6px; font-family: var(--font-mono); font-size: .72rem; line-height: 1.45; white-space: pre; }
.btn { font: inherit; font-size: .8rem; padding: 4px 10px; border-radius: 6px; border: 1px solid var(--line); background: var(--bg); color: var(--fg); cursor: pointer; }
.src { color: var(--muted); font-size: .75rem; line-height: 1.45; border-top: 1px solid var(--line); padding-top: 10px; }
.back { align-self: flex-start; font-size: .8rem; }
dl.kv { display: grid; grid-template-columns: max-content 1fr; gap: 4px 12px; margin: 0; }
dl.kv dt { color: var(--muted); font-size: .8rem; }
dl.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
/* SVG */
.cls { cursor: pointer; }
.cls rect.box { fill: var(--surface); stroke-width: 1.6; }
.cls.implicit rect.box { stroke-dasharray: 4 3; }
.cls.system rect.box { fill: var(--sys-bg); stroke: var(--sys-bg); }
.cls.system text { fill: var(--sys-fg); }
.cls text { font-family: var(--font-ui); font-size: 11.5px; font-weight: 500; fill: var(--fg); pointer-events: none; }
.cls.abstract text { font-style: italic; }
.cls rect.dot { stroke: none; }
.cls.sel rect.box { stroke: var(--focus); stroke-width: 3; }
.cls.match text { fill: var(--focus); font-weight: 600; }
.ind circle { fill: var(--surface); stroke: var(--muted); stroke-width: 1.2; }
.ind text { font-family: var(--font-mono); font-size: 9.5px; fill: var(--muted); paint-order: stroke; stroke: var(--bg); stroke-width: 3px; pointer-events: none; }
.rel path { fill: none; stroke-width: 1.4; }
.rel.key path { stroke: var(--key); }
.rel.soft path { stroke: var(--soft); stroke-dasharray: 5 3; }
.rel.external path { stroke: var(--ext); stroke-dasharray: 1 3; stroke-linecap: round; stroke-width: 1.8; }
.rel.subclass path { stroke: var(--edge); stroke-width: 1.6; }
.rel.instance path { stroke: var(--edge); stroke-width: .8; opacity: .7; }
.rel text { font-family: var(--font-mono); font-size: 9.5px; fill: var(--muted); paint-order: stroke; stroke: var(--bg); stroke-width: 3.5px; stroke-linejoin: round; pointer-events: none; }
svg.nolabels .rel text { display: none; }
svg.focus .cls:not(.hl), svg.focus .ind:not(.hl) { opacity: .15; }
svg.focus .rel:not(.hl) { opacity: .06; }
.mk-key { fill: var(--key); } .mk-soft { fill: var(--soft); } .mk-ext { fill: var(--ext); } .mk-sub { fill: var(--bg); stroke: var(--edge); stroke-width: 1.2; }
@media (max-width: 860px) {
main { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(440px, 64vh) auto; }
aside { border-left: 0; border-top: 1px solid var(--line); overflow: visible; }
.app { height: auto; min-height: 100%; }
#q { width: 100%; }
.controls { width: 100%; }
}
</style>
<div class="app">
<header>
<div class="brand">
<h1>KNOW Ontologie</h1>
<p>Begriffe der Wissensdatenbank <span class="mono">knowledge</span> und wie sie zusammenhängen, abgeleitet aus Schema und Joins</p>
</div>
<div class="controls">
<input id="q" type="search" placeholder="Begriff suchen" aria-label="Begriff suchen" autocomplete="off">
<label class="toggle"><input type="checkbox" id="t-labels" checked> Beziehungsnamen</label>
<label class="toggle"><input type="checkbox" id="t-soft" checked> Weiche Bezüge</label>
<label class="toggle"><input type="checkbox" id="t-ext" checked> Externe Systeme</label>
<label class="toggle"><input type="checkbox" id="t-ind"> Ausprägungen</label>
</div>
</header>
<main>
<div class="stage">
<svg id="graph" role="img" aria-label="Ontologie als Netz aus Klassen und Beziehungen"></svg>
<div class="legend" id="legend"></div>
</div>
<aside id="panel" aria-live="polite"></aside>
</main>
</div>
<script type="application/json" id="data">__DATA__</script>
<script type="application/json" id="ttl">__TTL__</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.9.0/d3.min.js"></script>
<script>
(function () {
const DATA = JSON.parse(document.getElementById('data').textContent);
const TTL = JSON.parse(document.getElementById('ttl').textContent);
const MODULES = {
mail: 'Mail & Absender', kanbau: 'Kontakte & Projekte', docs: 'Dokumente', kaf: 'Ankauf (KAF)', finance: 'Buchhaltung',
learning: 'Lernen', agent: 'Agent', bsb: 'Browser-Sessions', llm: 'LLM-Worker', system: 'Externe Systeme'
};
const KIND = { key: 'Schlüsselbezug', soft: 'weicher Bezug (Wertabgleich)', external: 'Verweis auf externes System', subclass: 'Unterklasse von', instance: 'Ausprägung von' };
const css = n => getComputedStyle(document.documentElement).getPropertyValue(n).trim();
const modColor = m => css('--d-' + m);
const classes = DATA.classes.map(c => Object.assign({ kind: 'class' }, c));
const byId = new Map(classes.map(c => [c.id, c]));
const inds = [];
classes.forEach(c => c.individuals.forEach(v => inds.push({ id: c.id + '::' + v, label: v, kind: 'ind', cls: c.id, module: c.module })));
inds.forEach(i => byId.set(i.id, i));
const rels = DATA.relations.map(r => Object.assign({}, r));
inds.forEach(i => rels.push({ source: i.id, target: i.cls, name: 'a', via: '', kind: 'instance' }));
const allNodes = classes.concat(inds);
const state = { labels: true, soft: true, ext: true, ind: false, selected: null, module: null, query: '' };
const svg = d3.select('#graph');
const defs = svg.append('defs');
[['key', 'mk-key'], ['soft', 'mk-soft'], ['ext', 'mk-ext']].forEach(([k, cls]) => {
defs.append('marker').attr('id', 'm-' + k).attr('viewBox', '0 -4 8 8').attr('refX', 7).attr('refY', 0)
.attr('markerWidth', 7).attr('markerHeight', 7).attr('orient', 'auto')
.append('path').attr('d', 'M0,-4L8,0L0,4Z').attr('class', cls);
});
defs.append('marker').attr('id', 'm-sub').attr('viewBox', '0 -6 12 12').attr('refX', 11).attr('refY', 0)
.attr('markerWidth', 11).attr('markerHeight', 11).attr('orient', 'auto').attr('markerUnits', 'userSpaceOnUse')
.append('path').attr('d', 'M0,-6L11,0L0,6Z').attr('class', 'mk-sub');
const root = svg.append('g');
const gRels = root.append('g');
const gNodes = root.append('g');
const zoom = d3.zoom().scaleExtent([0.2, 3.5]).on('zoom', e => root.attr('transform', e.transform));
svg.call(zoom).on('dblclick.zoom', null);
svg.on('click', e => { if (e.target === svg.node()) select(null); });
// Textbreite messen, damit Kästen zur Beschriftung passen
const measure = (() => { const c = document.createElement('canvas').getContext('2d'); return (t, f) => { c.font = f; return c.measureText(t).width; }; })();
classes.forEach(c => { c.w = Math.max(54, measure(c.label, '500 11.5px "IBM Plex Sans", system-ui') + 26); c.h = 26; });
inds.forEach(i => { i.w = 8; i.h = 8; });
const modKeys = Object.keys(MODULES);
const anchors = {};
modKeys.forEach((m, i) => {
if (m === 'system') { anchors[m] = { x: 0, y: 0 }; return; }
const n = modKeys.length - 1, a = (i / n) * Math.PI * 2 - Math.PI / 2;
anchors[m] = { x: Math.cos(a) * 640, y: Math.sin(a) * 440 };
});
const sim = d3.forceSimulation()
.force('link', d3.forceLink().id(d => d.id).distance(l => l.kind === 'instance' ? 34 : l.kind === 'external' ? 200 : 140).strength(l => l.kind === 'instance' ? 0.9 : l.kind === 'external' ? 0.08 : 0.3))
.force('charge', d3.forceManyBody().strength(d => d.kind === 'ind' ? -40 : -560))
.force('collide', d3.forceCollide(d => d.kind === 'ind' ? 10 : d.w / 2 + 14))
.force('x', d3.forceX(d => anchors[d.module].x).strength(d => d.kind === 'ind' ? 0.01 : 0.12))
.force('y', d3.forceY(d => anchors[d.module].y).strength(d => d.kind === 'ind' ? 0.01 : 0.12))
.stop();
function visible() {
const ns = allNodes.filter(n => {
if (n.kind === 'ind') return state.ind;
if (n.module === 'system') return state.ext;
return true;
});
const ids = new Set(ns.map(n => n.id));
const ls = rels.filter(r => {
const s = typeof r.source === 'object' ? r.source.id : r.source, t = typeof r.target === 'object' ? r.target.id : r.target;
if (!ids.has(s) || !ids.has(t)) return false;
if (r.kind === 'soft') return state.soft;
return true;
});
return { ns, ls };
}
let relSel, nodeSel;
function render(initial) {
const { ns, ls } = visible();
sim.nodes(ns); sim.force('link').links(ls);
relSel = gRels.selectAll('g.rel').data(ls, r => r.source.id + '|' + r.name + '|' + r.target.id).join(enter => {
const g = enter.append('g');
g.append('path');
g.append('text').attr('text-anchor', 'middle').attr('dy', -3);
g.append('title');
return g;
});
relSel.attr('class', r => 'rel ' + r.kind);
relSel.select('path').attr('marker-end', r => r.kind === 'key' ? 'url(#m-key)' : r.kind === 'soft' ? 'url(#m-soft)' : r.kind === 'external' ? 'url(#m-ext)' : r.kind === 'subclass' ? 'url(#m-sub)' : null);
relSel.select('text').text(r => r.kind === 'instance' ? '' : r.kind === 'subclass' ? 'ist ein' : r.name);
relSel.select('title').text(r => r.kind === 'instance' ? `${r.source.label} ist Ausprägung von ${r.target.label}` : `${r.source.label} ${r.kind === 'subclass' ? 'ist Unterklasse von' : r.name} ${r.target.label}\n${KIND[r.kind]}${r.via ? '\nüber ' + r.via : ''}`);
nodeSel = gNodes.selectAll('g.node').data(ns, d => d.id).join(enter => {
const g = enter.append('g').attr('tabindex', 0).attr('role', 'button')
.attr('aria-label', d => (d.kind === 'ind' ? 'Ausprägung ' : 'Klasse ') + d.label);
g.each(function (d) {
const s = d3.select(this);
if (d.kind === 'ind') {
s.append('circle').attr('r', 4);
s.append('text').attr('x', 7).attr('y', 3).text(d.label);
} else {
s.append('rect').attr('class', 'box').attr('x', -d.w / 2).attr('y', -d.h / 2).attr('width', d.w).attr('height', d.h).attr('rx', 13);
if (d.module !== 'system') s.append('rect').attr('class', 'dot').attr('x', -d.w / 2 + 9).attr('y', -4).attr('width', 8).attr('height', 8).attr('rx', 2);
s.append('text').attr('x', d.module === 'system' ? 0 : 6).attr('y', 4).attr('text-anchor', 'middle').text(d.label);
}
});
g.on('click', (e, d) => { e.stopPropagation(); select(d.kind === 'ind' ? d.cls : d.id, false); })
.on('keydown', (e, d) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); select(d.kind === 'ind' ? d.cls : d.id); } })
.on('mouseenter', (e, d) => hover(d.kind === 'ind' ? d.cls : d.id)).on('mouseleave', () => hover(null))
.call(d3.drag()
.on('start', (e, d) => { if (!e.active) sim.alphaTarget(0.2).restart(); d.fx = d.x; d.fy = d.y; })
.on('drag', (e, d) => { d.fx = e.x; d.fy = e.y; })
.on('end', (e, d) => { if (!e.active) sim.alphaTarget(0); d.fx = null; d.fy = null; }));
return g;
});
nodeSel.attr('class', d => d.kind === 'ind' ? 'node ind' : ['node', 'cls', d.table ? '' : 'implicit', d.module === 'system' ? 'system' : '', d.abstract ? 'abstract' : ''].join(' '));
recolor();
const n = initial ? 360 : 120;
sim.alpha(initial ? 1 : 0.6); for (let i = 0; i < n; i++) sim.tick();
draw(); sim.on('tick', draw);
applyHighlight();
}
function recolor() {
if (!nodeSel) return;
nodeSel.select('rect.box').attr('stroke', d => d.module === 'system' ? null : modColor(d.module));
nodeSel.select('rect.dot').attr('fill', d => modColor(d.module));
}
// Kante am Kastenrand enden lassen
function edgePoint(n, tx, ty) {
const dx = tx - n.x, dy = ty - n.y;
if (n.kind === 'ind') { const l = Math.hypot(dx, dy) || 1; return [n.x + dx / l * 5, n.y + dy / l * 5]; }
const hw = n.w / 2 + 2, hh = n.h / 2 + 2;
const s = Math.min(Math.abs(dx) > 1e-6 ? hw / Math.abs(dx) : Infinity, Math.abs(dy) > 1e-6 ? hh / Math.abs(dy) : Infinity);
return [n.x + dx * s, n.y + dy * s];
}
function draw() {
relSel.each(function (r) {
const g = d3.select(this);
if (r.source === r.target) {
const n = r.source, sx = n.x + n.w / 4, sy = n.y - n.h / 2 - 2, ex = n.x + n.w / 2 + 2, ey = n.y;
g.select('path').attr('d', `M${sx},${sy}C${sx},${sy - 40} ${ex + 44},${ey} ${ex},${ey}`);
g.select('text').attr('x', ex + 18).attr('y', sy - 22);
return;
}
const [x1, y1] = edgePoint(r.source, r.target.x, r.target.y);
const [x2, y2] = edgePoint(r.target, r.source.x, r.source.y);
g.select('path').attr('d', `M${x1},${y1}L${x2},${y2}`);
g.select('text').attr('x', (x1 + x2) / 2).attr('y', (y1 + y2) / 2);
});
nodeSel.attr('transform', d => `translate(${d.x},${d.y})`);
}
function fit() {
const box = root.node().getBBox(); const w = svg.node().clientWidth, h = svg.node().clientHeight;
if (!box.width || !w || !h) return;
const k = Math.min(1.4, 0.94 / Math.max(box.width / w, box.height / (h - 60)));
svg.call(zoom.transform, d3.zoomIdentity.translate(w / 2 - k * (box.x + box.width / 2), (h - 50) / 2 - k * (box.y + box.height / 2)).scale(k));
}
let hoverId = null;
function hover(id) { hoverId = id; applyHighlight(); }
function applyHighlight() {
const f = hoverId || state.selected;
let ns = null, ls = null;
if (f) {
ns = new Set([f]); ls = new Set();
relSel.each(r => { if (r.source.id === f || r.target.id === f) { ns.add(r.source.id); ns.add(r.target.id); ls.add(r); } });
} else if (state.module) {
ns = new Set(allNodes.filter(n => n.module === state.module).map(n => n.id)); ls = new Set();
relSel.each(r => { if (r.source.module === state.module || r.target.module === state.module) { ls.add(r); ns.add(r.source.id); ns.add(r.target.id); } });
}
svg.classed('focus', !!ns);
nodeSel.classed('hl', d => !!ns && ns.has(d.id)).classed('sel', d => d.id === state.selected)
.classed('match', d => !!state.query && d.kind !== 'ind' && (d.label.toLowerCase().includes(state.query) || (d.table || '').includes(state.query)));
relSel.classed('hl', r => !!ls && ls.has(r));
}
// ---------- Panel ----------
const panel = document.getElementById('panel');
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const btn = id => `<button class="link" data-id="${esc(id)}">${esc(byId.get(id).label)}</button>`;
const idOf = x => typeof x === 'object' ? x.id : x;
function relItems(list, dir) {
if (!list.length) return '<p class="via">keine</p>';
return '<ul class="rel">' + list.map(r => {
const other = dir === 'out' ? idOf(r.target) : idOf(r.source);
const name = r.kind === 'subclass' ? 'ist ein' : r.name;
const line = dir === 'out' ? `<span class="prop">${esc(name)} →</span> ${btn(other)}` : `${btn(other)} <span class="prop">${esc(name)} →</span>`;
return `<li><span>${line}</span><span class="via">${esc(KIND[r.kind])}${r.via ? ' · ' + esc(r.via) : ''}</span></li>`;
}).join('') + '</ul>';
}
function overview() {
const implicit = classes.filter(c => !c.table && c.module !== 'system').length;
const nRel = DATA.relations.filter(r => r.kind !== 'subclass').length;
panel.innerHTML = `
<div class="stats">
<div class="stat"><b>${classes.length}</b><span>Klassen, davon ${implicit} implizit</span></div>
<div class="stat"><b>${nRel}</b><span>Beziehungen</span></div>
<div class="stat"><b>${inds.length}</b><span>Ausprägungen</span></div>
</div>
<p>Jede der ${classes.filter(c => c.table).length} Tabellen ist eine Klasse. Dazu kommen <b>implizite Klassen</b> (gestrichelt): Begriffe ohne eigene Tabelle, die nur als Wert in Spalten vorkommen, etwa Postfach, n8n-Workflow, Gesellschaft oder Tier. Die Beziehungen stammen aus Schlüsselspalten und den Joins im KNOW-Code.</p>
<section><h3>Lesart</h3><dl class="kv">
<dt>Schlüsselbezug</dt><dd>gemeinsamer Schlüssel, z. B. <span class="mono">project_key</span></dd>
<dt>weicher Bezug</dt><dd>Abgleich über einen Wert wie E-Mail oder Adresse, ohne festen Schlüssel</dd>
<dt>externer Verweis</dt><dd>ID oder Pfad in KanBau, SharePoint oder Microsoft 365</dd>
<dt>ist ein</dt><dd>Unterklasse, z. B. Falldokument ist ein Dokument</dd>
</dl></section>
<section><h3>Auffällig</h3><p>${btn('Mail')} ist der zentrale Eingang: Absender, Anhang-Extraktion, Projektereignis und Rechnungsbeobachtung hängen an ihr. ${btn('Projekt')} ist der zentrale Bezugspunkt. ${btn('Ankaufsfall')} soll über <span class="mono">selected_project_id</span> zum Projekt werden; laut Befund B5 schreibt aber kein Workflow Ankaufsfälle.</p></section>
<section><h3>OWL / Turtle</h3>
<p>Dieselbe Ontologie als Turtle-Datei, mit Klassen, Objekt- und Datentyp-Properties und Ausprägungen. Quelle im Repo ist <span class="mono">ontology/know.yaml</span>, daraus erzeugt <span class="mono">ontology/know.ttl</span>.</p>
<div class="meta" style="margin-top:8px"><button class="btn" id="copy">Turtle kopieren</button><span class="via" id="copied" role="status"></span></div>
<details><summary>Turtle anzeigen</summary><pre class="ttl" id="ttlpre"></pre></details>
</section>
<p class="src">Erzeugt aus ontology/know.yaml (Version ${esc(DATA.version)}) am ${esc(DATA.generated)}. Grundlage: Postgres-Schema und SQL-Vorlagen aus dem n8n-Workflow „KNOW - Knowledge Base Gateway“. Datenbankinhalte wurden nicht gelesen. Die Namen der Beziehungen und die impliziten Klassen sind eine Modellierung aus diesen Quellen und nicht im Schema festgelegt.</p>`;
document.getElementById('ttlpre').textContent = TTL;
const status = document.getElementById('copied');
document.getElementById('copy').addEventListener('click', () => {
const done = () => { status.textContent = 'Kopiert'; };
const fallback = () => { const d = panel.querySelector('details'); d.open = true; const r = document.createRange(); r.selectNodeContents(document.getElementById('ttlpre')); const s = getSelection(); s.removeAllRanges(); s.addRange(r); status.textContent = 'Markiert, mit Strg/Cmd+C kopieren'; };
try { navigator.clipboard.writeText(TTL).then(done, fallback); } catch (e) { fallback(); }
});
}
function detail(id) {
const c = byId.get(id);
const out = rels.filter(r => idOf(r.source) === id && r.kind !== 'instance');
const inc = rels.filter(r => idOf(r.target) === id && r.kind !== 'instance');
const enums = Object.entries(c.enums || {});
panel.innerHTML = `<button class="link back" data-id="">← Überblick</button>
<h2>${esc(c.label)}</h2>
<div class="meta"><span class="chip" style="cursor:default"><span class="sw" style="background:var(--d-${c.module})"></span>${esc(MODULES[c.module])}</span>
${c.table ? `<span class="tag mono">${esc(c.table)}</span>` : c.module === 'system' ? '<span class="tag">externes System</span>' : '<span class="tag">implizit, ohne eigene Tabelle</span>'}
${c.abstract ? '<span class="tag">abstrakt</span>' : ''}</div>
<p>${esc(c.def)}</p>
<dl class="kv"><dt>Schlüssel</dt><dd class="mono">${esc(c.key)}</dd><dt>OWL</dt><dd class="mono">know:${esc(c.id)}</dd></dl>
${c.sens ? `<div class="sensbox"><h3>Sensible Inhalte</h3><p>${esc(c.sens)}</p></div>` : ''}
<section><h3>Beziehungen von hier</h3>${relItems(out, 'out')}</section>
<section><h3>Beziehungen hierher</h3>${relItems(inc, 'in')}</section>
${c.individuals.length ? `<section><h3>Ausprägungen</h3><div class="vals">${c.individuals.map(v => `<span>${esc(v)}</span>`).join('')}</div></section>` : ''}
${enums.map(([k, v]) => `<section><h3>Werte von ${esc(k)}</h3><div class="vals">${v.map(x => `<span>${esc(x)}</span>`).join('')}</div></section>`).join('')}
${c.attrs.length ? `<section><details><summary>${c.attrs.length} Attribute (Datentyp-Properties)</summary><table class="cols">${c.attrs.map(a => `<tr><td>${esc(a[0])}</td><td>${esc(a[1])}</td></tr>`).join('')}</table></details></section>` : ''}`;
}
panel.addEventListener('click', e => { const b = e.target.closest('button[data-id]'); if (b) select(b.getAttribute('data-id') || null, true); });
function select(id, center) {
state.selected = id; state.module = null; updateChips();
if (id) detail(id); else overview();
applyHighlight();
if (id && center) { const n = byId.get(id); if (n && n.x != null) svg.transition().duration(matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 450).call(zoom.translateTo, n.x, n.y); }
}
// ---------- Legende und Steuerung ----------
const legend = document.getElementById('legend');
legend.innerHTML = modKeys.filter(m => m !== 'system').map(m => `<button class="chip" data-mod="${m}" aria-pressed="false"><span class="sw" style="background:var(--d-${m})"></span>${MODULES[m]}</button>`).join('') +
`<span class="edgekey"><span><i style="border-color:var(--key)"></i>Schlüssel</span><span><i style="border-color:var(--soft);border-top-style:dashed"></i>weich</span><span><i style="border-color:var(--ext);border-top-style:dotted"></i>extern</span><span><i style="border-color:var(--edge)"></i>▷ ist ein</span></span>`;
legend.addEventListener('click', e => {
const b = e.target.closest('[data-mod]'); if (!b) return;
const m = b.getAttribute('data-mod'); state.module = state.module === m ? null : m; state.selected = null; overview(); updateChips(); applyHighlight();
});
function updateChips() { legend.querySelectorAll('[data-mod]').forEach(b => b.setAttribute('aria-pressed', String(b.getAttribute('data-mod') === state.module))); }
[['t-labels', 'labels'], ['t-soft', 'soft'], ['t-ext', 'ext'], ['t-ind', 'ind']].forEach(([id, key]) => {
const el = document.getElementById(id);
try { const v = localStorage.getItem('know-onto:' + key); if (v !== null) el.checked = v === '1'; } catch (e) {}
state[key] = el.checked;
el.addEventListener('change', () => {
state[key] = el.checked;
try { localStorage.setItem('know-onto:' + key, el.checked ? '1' : '0'); } catch (e) {}
if (key === 'labels') { svg.classed('nolabels', !state.labels); return; }
if (state.selected && !visible().ns.some(n => n.id === state.selected)) select(null);
render(false);
});
});
svg.classed('nolabels', !state.labels);
const q = document.getElementById('q');
q.addEventListener('input', () => { state.query = q.value.trim().toLowerCase(); applyHighlight(); });
q.addEventListener('keydown', e => {
if (e.key !== 'Enter' || !state.query) return;
const hit = classes.find(c => c.label.toLowerCase().includes(state.query) || (c.table || '').includes(state.query));
if (hit && visible().ns.includes(hit)) select(hit.id, true);
});
const mq = matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener && mq.addEventListener('change', recolor);
new MutationObserver(recolor).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
render(true);
overview();
requestAnimationFrame(fit);
if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => {
classes.forEach(c => { c.w = Math.max(54, measure(c.label, '500 11.5px "IBM Plex Sans", system-ui') + 26); });
nodeSel.select('rect.box').attr('x', d => -d.w / 2).attr('width', d => d.w);
nodeSel.select('rect.dot').attr('x', d => -d.w / 2 + 9);
draw();
});
window.addEventListener('resize', () => requestAnimationFrame(fit));
})();
</script>
+2 -2
View File
@@ -7,8 +7,8 @@ Proxmox-spezifische Schicht (austauschbar, siehe PLAN §7.2). Eingabe ist immer
## Ablauf
1. **Inventar:** auf jedem Node `knaster inventory collect` ausführen und `sites/knaster/inventory.yaml` ersetzen. Danach `knaster placement plan --inventory sites/knaster/inventory.yaml` und die Ergebnisse in `proxmox.yaml` übertragen (PCI-Pfade über `lspci -nn | grep -i nvidia`, IOMMU-Gruppen).
2. **Hosts vorbereiten:** `ansible-playbook playbooks/site.yml --limit pve_hosts`. Setzt IOMMU und VFIO und sperrt die NVIDIA-Treiber auf dem Host. Danach die Hosts neu starten. Der ZFS-Pool `tank` wird **nicht** automatisch angelegt (Datenverlustgefahr), die Rolle gibt dafür den Befehl aus.
3. **Template:** pro Node ein Ubuntu-24.04-Cloud-Image als Template mit VMID 9000.
2. **Hosts vorbereiten:** `ansible-playbook playbooks/site.yml --limit pve_gpu_hosts`. Setzt IOMMU und VFIO und sperrt die NVIDIA-Treiber, nur auf GPU-Knoten (pve61 bleibt unberührt, Kiosk). Danach die Hosts neu starten. Der ZFS-Pool `tank` wird **nicht** automatisch angelegt (Datenverlustgefahr), die Rolle gibt dafür den Befehl aus.
3. **Template:** pro GPU-Knoten ein Ubuntu-24.04-Cloud-Image als Template mit VMID 9000.
4. **VMs/LXC:** `cd tofu && tofu init && tofu plan -var proxmox_api_token=...` und danach `tofu apply`.
5. **Gäste:** `ansible-playbook playbooks/site.yml --limit gpu_vms,infra`. Installiert die Treiber pro Generation gepinnt (legacy/modern), das Container-Toolkit, Docker und den Xid-Exporter.
6. **Dienste:** `deploy/compose/<rolle>` auf den jeweiligen Gästen starten.
+5 -2
View File
@@ -1,5 +1,8 @@
[defaults]
inventory = inventory.yml
inventory = inventory.yml,inventory.generated.yml
roles_path = roles
host_key_checking = True
stdout_callback = yaml
callback_result_format = yaml
[inventory]
unparsed_is_failed = False
+40 -14
View File
@@ -1,29 +1,55 @@
# ANNAHME bis Deployment – Hosts und IPs aus sites/knaster/proxmox.yaml
# Knoten und Gäste von knaster (Stand 12.10.2026). Läuft auf dem Steuerrechner ops.
# Gäste bekommen ihre IP per DHCP (D5 zurückgestellt): ansible_host der GPU-VMs und von infra
# kommt aus inventory.generated.yml (`tofu output -raw ansible_inventory`, nicht im Repo).
all:
vars:
knaster_site: knaster
children:
pve_hosts:
pve_all:
children:
pve_gpu_hosts:
hosts:
pve-ada: {}
pve1: {}
pve2: {}
pve3: {}
pve4: {}
pve44: {ansible_host: 192.168.100.210}
pve46: {ansible_host: 192.168.100.209}
pve48: {ansible_host: 192.168.100.212}
pve52: {ansible_host: 192.168.100.207}
pve_kiosk:
hosts:
pve61: {ansible_host: 192.168.100.214}
vars:
ansible_user: root
gpu_vms:
hosts:
gpu-main: {ansible_host: 10.20.0.201, driver_generation: modern, knaster_role: gpu-vllm}
gpu-flex: {ansible_host: 10.20.0.202, driver_generation: modern, knaster_role: gpu-vllm}
gpu-embed-1: {ansible_host: 10.20.0.221, driver_generation: legacy, knaster_role: gpu-llamacpp, llamacpp_profile: embed}
gpu-embed-2: {ansible_host: 10.20.0.222, driver_generation: legacy, knaster_role: gpu-llamacpp, llamacpp_profile: embed}
gpu-small: {ansible_host: 10.20.0.231, driver_generation: legacy, knaster_role: gpu-llamacpp, llamacpp_profile: chat}
hosts: # k3s_node_labels: Knoten-Labels für die Platzierung von GPU-Pods (D6)
gpu-pve44: {driver_generation: legacy, k3s_node_labels: [knaster.gpu=true, knaster.gpu.m4000=1, knaster.gpu.p2000=1]}
gpu-pve46: {driver_generation: legacy, k3s_node_labels: [knaster.gpu=true, knaster.gpu.m4000=1, knaster.gpu.p2000=1]}
gpu-pve48: {driver_generation: legacy, k3s_node_labels: [knaster.gpu=true, knaster.gpu.m4000=1, knaster.gpu.p2000=1]}
gpu-pve52: {driver_generation: legacy, k3s_node_labels: [knaster.gpu=true, knaster.gpu.m4000=1]}
vars:
ansible_user: knaster
ansible_become: true
ansible_python_interpreter: /usr/bin/python3
# llama.cpp-Profile je GPU-VM (Task 6.1, Rolle gpu-llamacpp). Liefert auch die Prometheus-Ziele (file_sd).
gpu_llamacpp:
hosts:
gpu-pve44: {llamacpp_profiles: [embed]}
gpu-pve46: {llamacpp_profiles: [embed]}
gpu-pve48: {llamacpp_profiles: [chat]}
infra:
hosts:
infra: {ansible_host: 10.20.0.101}
infra: {}
vars:
ansible_user: root
# Dienst-VMs ohne GPU (D6): k3s-Steuerung. Adresse aus inventory.generated.yml (Gruppe service_vms).
service_vms:
hosts:
k3s: {}
vars:
ansible_user: knaster
ansible_become: true
ansible_python_interpreter: /usr/bin/python3
k3s_server:
hosts:
k3s: {}
k3s_agents:
children:
gpu_vms: {}
@@ -0,0 +1,52 @@
# Verwaltung durch Personen statt durch ein Gerät („Weg 1“, 19.10.): SSH-Schlüssel der Admins auf ops (Bastion).
# Quelle: sites/<site>/proxmox.yaml, access.ops_ssh_keys und access.ops_ssh_keys_revoked. Läuft auf ops:
# ansible-playbook playbooks/admins.yml
# Fügt nur hinzu bzw. entfernt die ausdrücklich entzogenen Schlüssel; fremde Einträge (z. B. Notfallschlüssel) bleiben.
# Proxmox-Weboberfläche: access.admin_users + playbooks/cluster.yml + scripts/pve-admin-user.sh, Gerät in
# firewall.management (BETRIEB §8, §10, §15).
- name: ops – SSH-Schlüssel der Admins
hosts: localhost
connection: local
gather_facts: false
vars:
site_file: "{{ playbook_dir }}/../../../../sites/{{ knaster_site | default('knaster') }}/proxmox.yaml"
access: "{{ (lookup('file', site_file) | from_yaml).access }}"
admin_keys: "{{ access.ops_ssh_keys | default([]) }}"
revoked_keys: "{{ access.ops_ssh_keys_revoked | default([]) }}"
tasks:
- name: Liste prüfen (mindestens ein Schlüssel, Namen eindeutig, kein Schlüssel zugleich entzogen)
ansible.builtin.assert:
that:
- admin_keys | length > 0
- admin_keys | map(attribute='name') | unique | length == admin_keys | length
- admin_keys | map(attribute='key') | select('match', '^ssh-(ed25519|rsa) ') | list | length == admin_keys | length
- (admin_keys | map(attribute='key') | map('split', ' ') | map(attribute=1) | list)
| intersect(revoked_keys | map(attribute='key') | map('split', ' ') | map(attribute=1) | list) | length == 0
fail_msg: "access.ops_ssh_keys in {{ site_file }} prüfen"
- name: Schlüssel eintragen
ansible.posix.authorized_key:
user: root
key: "{{ item.key }}"
state: present
loop: "{{ admin_keys }}"
loop_control:
label: "{{ item.name }}"
- name: Entzogene Schlüssel entfernen
ansible.posix.authorized_key:
user: root
key: "{{ item.key }}"
state: absent
loop: "{{ revoked_keys }}"
loop_control:
label: "{{ item.name }}"
- name: Schlüssel auf ops (Fingerabdrücke)
ansible.builtin.command: ssh-keygen -lf /root/.ssh/authorized_keys
register: fingerprints
changed_when: false
- name: Ergebnis
ansible.builtin.debug:
msg: "{{ fingerprints.stdout_lines }}"
@@ -0,0 +1,6 @@
# Clusterweite Grundkonfiguration (Phase 4): Admin-Gruppe und -Konten, Healer-Rolle/-Token,
# Benachrichtigungen, Firewall mit IPSET management. Läuft auf einem Knoten (Einstellungen in /etc/pve).
# Vorher: Laptop-, ops- und infra-Adresse in sites/knaster/proxmox.yaml (firewall.management) prüfen.
- name: Cluster-Grundkonfiguration
hosts: "{{ pve_cluster_node | default('pve44') }}"
roles: [pve-cluster]
@@ -0,0 +1,52 @@
# llama.cpp-Rollen nach k3s (Task 6.1, D6). Läuft auf ops (Verzeichnis platform/proxmox/ansible):
# ansible-playbook playbooks/gpu-llamacpp-k3s.yml # alle VMs der Gruppe gpu_llamacpp
# ansible-playbook playbooks/gpu-llamacpp-k3s.yml -e llamacpp_k3s_hosts=gpu-pve44 # nur eine VM (Pilot)
# Danach: ansible-playbook playbooks/monitoring-targets.yml (Ziele bleiben gleich, Ports wie im Compose).
#
# Kartenwahl: Das Device Plugin verwaltet nur Index 0 (M4000, in allen GPU-VMs); die P2000 bekommen die
# llama.cpp-Pods fest per UUID. Erst prüfen, dass Index 0 überall die M4000 ist, dann das Device Plugin anwenden.
- name: GPU-VMs – Index 0 ist die M4000
hosts: gpu_vms
gather_facts: false
tasks:
- name: Karte auf Index 0 lesen
ansible.builtin.command: nvidia-smi -i 0 --query-gpu=name --format=csv,noheader
register: gpu0
changed_when: false
- name: Index 0 muss die M4000 sein (sonst gibt das Device Plugin die falsche Karte frei)
ansible.builtin.assert:
that: gpu0.stdout == "Quadro M4000"
fail_msg: "{{ inventory_hostname }}: Index 0 ist {{ gpu0.stdout }}"
- name: k3s – Device Plugin nur für die M4000
hosts: localhost
connection: local
gather_facts: false
tasks:
- name: Device Plugin anwenden
ansible.builtin.command: kubectl apply -f {{ playbook_dir }}/../../../../deploy/k8s/nvidia-device-plugin.yaml
register: dp
changed_when: "'configured' in dp.stdout"
- name: Warten, bis das Device Plugin auf allen Knoten läuft
ansible.builtin.command: kubectl -n kube-system rollout status daemonset/nvidia-device-plugin-daemonset --timeout=180s
changed_when: false
- name: Jede GPU-VM meldet genau eine Karte (M4000)
ansible.builtin.command: kubectl get node {{ item }} -o jsonpath='{.status.allocatable.nvidia\.com/gpu}'
loop: "{{ groups['gpu_vms'] }}"
register: alloc
until: alloc.stdout == "1"
retries: 24
delay: 5
changed_when: false
- name: llama.cpp-Server in k3s
hosts: "{{ llamacpp_k3s_hosts | default('gpu_llamacpp') }}"
serial: 1
tasks:
- name: Rolle gpu-llamacpp (k3s)
ansible.builtin.include_role:
name: gpu-llamacpp
tasks_from: k3s
@@ -0,0 +1,16 @@
# llama.cpp-Rollen (Task 6.1): Embedding + Rerank in gpu-pve44 und gpu-pve46, Chat (verifier-small,
# signature-extractor) in gpu-pve48. Zuordnung: Inventar, Gruppe gpu_llamacpp (gpu-pve52 gehört nicht dazu).
# Aufruf auf ops (Verzeichnis platform/proxmox/ansible), vorher Zugang auffrischen (refresh-vm-access.sh):
# ansible-playbook playbooks/gpu-llamacpp.yml
# ansible-playbook playbooks/monitoring-targets.yml # Prometheus-Ziele (file_sd) nachziehen
#
# Seit 19.10. laufen die Server in k3s (playbooks/gpu-llamacpp-k3s.yml). Dieses Playbook würde den Compose-Stack
# wieder starten und Host-Ports und VRAM doppelt belegen. Nur mit -e llamacpp_compose=true (Rückweg).
- name: llama.cpp-Server in den GPU-VMs
hosts: gpu_llamacpp
pre_tasks:
- name: Abbruch, wenn llama.cpp in k3s läuft
ansible.builtin.fail:
msg: "llama.cpp läuft in k3s (playbooks/gpu-llamacpp-k3s.yml). Compose nur mit -e llamacpp_compose=true."
when: not (llamacpp_compose | default(false) | bool)
roles: [gpu-llamacpp]
@@ -0,0 +1,27 @@
# k3s-Cluster (D6): Server auf der Dienst-VM "k3s", Agents in den GPU-VMs, danach Runtime, Device-Plugin,
# Portainer-Agent. Aufruf auf ops (vorher bash ../scripts/refresh-vm-access.sh, Adressen per DHCP):
# ansible-playbook playbooks/k3s.yml
- name: k3s-Server
hosts: k3s_server
pre_tasks:
- name: DHCP-Client-ID auf MAC umstellen (Reservierung am DC, wie bei den GPU-VMs)
ansible.builtin.copy:
dest: /etc/netplan/99-knaster-dhcp-id.yaml
content: |
network:
version: 2
ethernets:
eth0:
dhcp-identifier: mac
mode: "0600"
roles: [k3s-server]
- name: k3s-Agents (GPU-VMs)
hosts: k3s_agents
roles: [k3s-agent]
- name: Grundausstattung im Cluster (von ops)
hosts: localhost
connection: local
gather_facts: false
roles: [k3s-apps]
@@ -0,0 +1,74 @@
# Pod-Monitoring (Task 6.5): kube-state-metrics und Lesezugang für Prometheus in k3s, danach der Infra-Stack
# (Prometheus-Jobs kubelet/cadvisor/kube-state-metrics, file_sd-Ziele, Dashboard „k3s – Pods“). Läuft auf ops:
# ansible-playbook playbooks/monitoring-k3s.yml
# Token und CA landen auf ops unter /root/.config/knaster (0600), die Rolle infra-stack kopiert sie nach infra.
- name: k3s – kube-state-metrics und Zugang für Prometheus
hosts: localhost
connection: local
gather_facts: false
vars:
manifests: "{{ playbook_dir }}/../../../../deploy/k8s/monitoring"
token_file: /root/.config/knaster/k3s-prometheus.token
ca_file: /root/.config/knaster/k3s-ca.crt
tasks:
- name: Manifeste anwenden
ansible.builtin.command: kubectl apply -f {{ manifests }}/{{ item }}
loop: [prometheus-access.yaml, kube-state-metrics.yaml]
register: apply
changed_when: "'configured' in apply.stdout or 'created' in apply.stdout"
- name: Warten, bis kube-state-metrics bereit ist
ansible.builtin.command: kubectl -n kube-system rollout status deployment/kube-state-metrics --timeout=180s
changed_when: false
- name: Token des ServiceAccounts prometheus-infra lesen (Secret wird vom Controller befüllt)
ansible.builtin.shell: >
set -o pipefail;
kubectl -n kube-system get secret prometheus-infra-token -o go-template='{{ "{{" }}.data.token{{ "}}" }}' | base64 -d
args: {executable: /bin/bash}
register: token
until: token.stdout | length > 0
retries: 10
delay: 2
changed_when: false
no_log: true
- name: Token als Datei auf ops ablegen (600, nie ausgeben)
ansible.builtin.copy:
content: "{{ token.stdout }}"
dest: "{{ token_file }}"
mode: "0600"
no_log: true
# Die kubelet-Zertifikate sind von k3s-server-ca signiert und tragen die Knoten-IP (19.10. geprüft).
- name: CA des Clusters lesen
ansible.builtin.shell: >
set -o pipefail;
kubectl -n kube-system get secret prometheus-infra-token -o go-template='{{ "{{" }}index .data "ca.crt"{{ "}}" }}' | base64 -d
args: {executable: /bin/bash}
register: ca
changed_when: false
- name: CA als Datei auf ops ablegen
ansible.builtin.copy:
content: "{{ ca.stdout }}\n"
dest: "{{ ca_file }}"
mode: "0600"
- name: Zugang prüfen (cAdvisor eines Knotens mit Token und CA)
ansible.builtin.shell: >
set -o pipefail;
ip=$(kubectl get node k3s -o jsonpath='{.status.addresses[?(@.type=="InternalIP")].address}');
curl -fsS -m 10 --cacert {{ ca_file }} -H "Authorization: Bearer $(cat {{ token_file }})"
https://$ip:10250/metrics/cadvisor | grep -c '^container_memory_working_set_bytes'
args: {executable: /bin/bash}
register: probe
changed_when: false
- name: Ergebnis
ansible.builtin.debug:
msg: "cAdvisor auf k3s erreichbar, {{ probe.stdout }} Reihen container_memory_working_set_bytes"
- name: Infra-LXC – Prometheus, Ziele und Dashboard
hosts: infra
roles: [infra-stack]
@@ -0,0 +1,10 @@
# file_sd-Ziele der GPU-VMs auffrischen, wenn sich DHCP-Adressen geändert haben. Auf ops:
# cd /root/cluster-knaster/platform/proxmox/tofu && . /root/.config/knaster/tofu.env && bash ../scripts/refresh-vm-access.sh
# cd ../ansible && ansible-playbook playbooks/monitoring-targets.yml
- name: Infra-LXC – file_sd-Ziele
hosts: infra
tasks:
- name: Ziele erzeugen und kopieren
ansible.builtin.include_role:
name: infra-stack
tasks_from: targets
@@ -0,0 +1,10 @@
# Nur Monitoring (Task 5.2), ohne Treiber-/Docker-Rollen der GPU-VMs (kein Docker-Neustart, kein Reboot).
# Aufruf auf ops (Verzeichnis platform/proxmox/ansible), vorher Adressen auffrischen (refresh-vm-access.sh):
# ansible-playbook playbooks/monitoring.yml
- name: GPU-VMs – Exporter (node_exporter, nvidia_gpu_exporter)
hosts: gpu_vms
roles: [gpu-exporters]
- name: Infra-LXC – Compose-Stack mit Monitoring
hosts: infra
roles: [infra-stack]
@@ -0,0 +1,86 @@
# Namespace nex für Agenten im Cluster (nex-Spec 2026-10-19 Cluster-Agenten): Brücke zu appsrv08 (D259), Zugang für
# nex-admin (D265), Pull-Secret für die Gitea-Registry (D263). Läuft auf ops: ansible-playbook playbooks/nex-k3s.yml
#
# Voraussetzung für das Pull-Secret: /root/.config/knaster/gitea-registry.env mit REGISTRY_USER=… und
# REGISTRY_TOKEN=… (Gitea-Token mit read:package). Fehlt die Datei, wird dieser Schritt übersprungen.
# Ausgabe: Token-Datei für nex-admin (nur Pfad), CA des Clusters als base64 (für NEX_ADMIN_K8S_CA_B64) und API-Adresse.
- name: Namespace nex für Cluster-Agenten
hosts: localhost
connection: local
gather_facts: false
vars:
manifests: "{{ playbook_dir }}/../../../../deploy/k8s/nex"
registry_env: /root/.config/knaster/gitea-registry.env
token_file: /root/.config/knaster/nex-admin-k8s.token
api_url: "https://{{ hostvars['k3s'].ansible_host }}:6443"
tasks:
- name: Namespace nex
ansible.builtin.command: kubectl apply -f {{ playbook_dir }}/../../../../deploy/k8s/nex-speech/namespace.yaml
register: ns
changed_when: "'created' in ns.stdout"
- name: Brücke zu appsrv08 (Services ohne Selector + Endpoints)
ansible.builtin.command: kubectl apply -f {{ manifests }}/bridge.yaml
register: bridge
changed_when: "'configured' in bridge.stdout or 'created' in bridge.stdout"
- name: Zugang für nex-admin (ServiceAccount, Role, RoleBinding, Token-Secret)
ansible.builtin.command: kubectl apply -f {{ manifests }}/admin-access.yaml
register: access
changed_when: "'configured' in access.stdout or 'created' in access.stdout"
- name: Token des ServiceAccounts lesen (Secret wird vom Controller befüllt)
ansible.builtin.shell: >
set -o pipefail;
kubectl -n nex get secret nex-admin-token -o go-template='{{ "{{" }}.data.token{{ "}}" }}' | base64 -d
args: {executable: /bin/bash}
register: token
until: token.stdout | length > 0
retries: 10
delay: 2
changed_when: false
no_log: true
- name: Token als Datei auf ops ablegen (600, nie ausgeben)
ansible.builtin.copy:
content: "{{ token.stdout }}"
dest: "{{ token_file }}"
mode: "0600"
no_log: true
- name: Registry-Zugang vorhanden?
ansible.builtin.stat:
path: "{{ registry_env }}"
register: reg
- name: Pull-Secret gitea-registry (aus {{ registry_env }})
ansible.builtin.shell: >
set -o pipefail; set -a; . {{ registry_env }}; set +a;
kubectl -n nex create secret docker-registry gitea-registry --docker-server=gitea.mindboost.team
--docker-username="$REGISTRY_USER" --docker-password="$REGISTRY_TOKEN" --dry-run=client -o yaml | kubectl apply -f -
args: {executable: /bin/bash}
register: pull
changed_when: "'configured' in pull.stdout or 'created' in pull.stdout"
when: reg.stat.exists
no_log: true
- name: CA des Clusters (base64, eine Zeile)
ansible.builtin.command: kubectl config view --raw --minify -o jsonpath='{.clusters[0].cluster.certificate-authority-data}'
register: ca
changed_when: false
- name: Brücke prüfen (Pod im Namespace nex löst die Namen auf)
ansible.builtin.shell: >
kubectl -n nex run bridge-check --rm -i --restart=Never --image=alpine:3.20 --
sh -c 'for h in nex-router nex-know nex-access grp-mcp-intern; do getent hosts $h >/dev/null && echo "$h dns ok" || echo "$h dns FEHLT"; done' 2>/dev/null
register: check
changed_when: false
- name: Ergebnis
ansible.builtin.debug:
msg:
- "Token für nex-admin: {{ token_file }} (NEX_ADMIN_K8S_TOKEN)"
- "NEX_ADMIN_K8S_URL={{ api_url }}"
- "NEX_ADMIN_K8S_CA_B64={{ ca.stdout }}"
- "Pull-Secret gitea-registry: {{ 'angelegt/aktuell' if reg.stat.exists else 'übersprungen (' + registry_env + ' fehlt)' }}"
- "{{ check.stdout_lines }}"
@@ -0,0 +1,57 @@
# NEX-Websuche (SearXNG) in k3s (D6-Umzugstest). Läuft auf ops: ansible-playbook playbooks/nex-searxng-k3s.yml
# Secret SEARXNG_SECRET wird einmal erzeugt und bleibt im Cluster (nicht im Repo, nicht auf ops).
- name: SearXNG nach k3s
hosts: localhost
connection: local
gather_facts: false
vars:
manifests: "{{ playbook_dir }}/../../../../deploy/k8s/nex-searxng"
tasks:
- name: Namespace nex
ansible.builtin.command: kubectl apply -f {{ playbook_dir }}/../../../../deploy/k8s/nex-speech/namespace.yaml
register: ns
changed_when: "'created' in ns.stdout"
- name: Secret vorhanden?
ansible.builtin.command: kubectl -n nex get secret nex-searxng
register: secret
failed_when: false
changed_when: false
- name: Secret anlegen (zufällig, 64 Hex-Zeichen)
ansible.builtin.shell: kubectl -n nex create secret generic nex-searxng --from-literal=SEARXNG_SECRET=$(openssl rand -hex 32)
when: secret.rc != 0
no_log: true
- name: ConfigMap aus settings.yml
ansible.builtin.shell: >
kubectl -n nex create configmap nex-searxng-settings --from-file=settings.yml={{ manifests }}/settings.yml
--dry-run=client -o yaml | kubectl apply -f -
register: cm
changed_when: "'configured' in cm.stdout or 'created' in cm.stdout"
- name: Deployment und Service
ansible.builtin.command: kubectl apply -f {{ manifests }}/deployment.yaml
register: dep
changed_when: "'configured' in dep.stdout or 'created' in dep.stdout"
- name: Neustart, wenn sich die Einstellungen geändert haben
ansible.builtin.command: kubectl -n nex rollout restart deployment/nex-searxng
when: cm.changed and not dep.changed
- name: Warten, bis bereit
ansible.builtin.command: kubectl -n nex rollout status deployment/nex-searxng --timeout=300s
changed_when: false
- name: Suchtest über NodePort (Knoten k3s)
ansible.builtin.uri:
url: "http://{{ hostvars['k3s'].ansible_host }}:30880/search?q=Proxmox&format=json"
return_content: true
register: probe
until: probe.status == 200 and (probe.json.results | length) > 0
retries: 6
delay: 5
- name: Ergebnis
ansible.builtin.debug:
msg: "{{ probe.json.results | length }} Treffer, erster: {{ probe.json.results[0].url }}"
@@ -0,0 +1,5 @@
# NEX-Sprachdienste von Docker Compose nach k3s umziehen (Test für D6). Rückweg: Deployments löschen
# (kubectl -n nex delete deploy nex-stt nex-tts) und auf gpu-pve52 `docker compose -f /opt/nex-speech start`.
- name: NEX-Sprachdienste nach k3s
hosts: "{{ nex_speech_hosts | default('gpu-pve52') }}"
roles: [nex-speech-k3s]

Some files were not shown because too many files have changed in this diff Show More