Compare commits

...
81 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
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
Robert RappandClaude Opus 5.5 bc0f3d8f91 docs(plan): D2 entschieden, D5 zurückgestellt (Gäste vorerst DHCP)
ci / test (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:25:03 +02:00
Robert RappandClaude Opus 5.5 6876e63983 docs(plan): Abhängigkeitsübersicht (D1 blockiert wenig), tank nach dem Cluster, ops auf pve46
ci / test (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:18:49 +02:00
122 changed files with 29431 additions and 484 deletions
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+2
View File
@@ -14,3 +14,5 @@ data/
*.tfstate* *.tfstate*
.DS_Store .DS_Store
deploy/compose/*/secrets/ 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 -`.
+1 -1
View File
@@ -20,7 +20,7 @@ validate:
$(VENV)/bin/knaster placement --inventory sites/knaster/inventory.yaml --catalog catalog > /dev/null $(VENV)/bin/knaster placement --inventory sites/knaster/inventory.yaml --catalog catalog > /dev/null
compose-check: 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 && \ (cd deploy/compose/$$d && cp .env.example .env.ci && \
POSTGRES_PASSWORD=ci VLLM_MODEL=ci SLOT_ALIAS=ci docker compose --env-file .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; \ --profile embed --profile chat --profile dashboards --profile proxmox config -q; \
+4
View File
@@ -15,6 +15,10 @@ Anfrage → n8n → Router-Service ──▶ Stufe 3 Embedding-Classifier (CPU)
| Dokument | Inhalt | | 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/PLAN.md](docs/PLAN.md) | Architektur und Betrieb: Proxmox, Pflege, Autohealing, Lifecycle, Finetuning, Problemerkennung, Portabilität |
| [docs/adr/](docs/adr/) | Architekturentscheidungen | | [docs/adr/](docs/adr/) | Architekturentscheidungen |
| [docs/spec/](docs/spec/) | Spezifikationen der Komponenten | | [docs/spec/](docs/spec/) | Spezifikationen der Komponenten |
+9 -4
View File
@@ -1,4 +1,6 @@
# Modell-Katalog (SPEC-inventory §2). vram_gb = Gewichte + Mindest-KV-Cache (Richtwerte, per Kalibrierung prüfen). # 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. # 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). # Engine-Grenzen gelten für alle Modelle der Engine (ADR-0006).
engines: engines:
@@ -24,24 +26,27 @@ models:
lora: true lora: true
qwen3-8b-q4km: qwen3-8b-q4km:
kind: llm 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 min_cc: 5.0
engines: [llamacpp] engines: [llamacpp]
context: 16384 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: qwen3-4b-q4km:
kind: llm kind: llm
vram_gb: 4 vram_gb: 4.1 # gemessen: 4153 MiB bei 8192 Kontext
min_cc: 5.0 min_cc: 5.0
engines: [llamacpp] engines: [llamacpp]
context: 8192 context: 8192
cpu_ok: true # langsam, aber als Notfall-Verifier nutzbar 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: qwen3-embedding-0.6b-q8:
kind: embedding 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 min_cc: 5.0
engines: [llamacpp] engines: [llamacpp]
context: 8192 context: 8192
cpu_ok: true cpu_ok: true
note: "llama-bench 12.10. pp512 (Embedding): P2000 1009 t/s, M4000 929 t/s"
bge-m3-f16: bge-m3-f16:
kind: embedding kind: embedding
vram_gb: 2.5 vram_gb: 2.5
@@ -74,4 +79,4 @@ models:
vram_gb: 0.2 vram_gb: 0.2
engines: [onnx] engines: [onnx]
cpu_ok: true 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} match: {alertname: DiskAlmostFull}
ladder: ladder:
- {action: notify, params: {text: "Platte auf {instance} ({mountpoint}) fast voll", severity: warning}, attempts: 1} - {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}
+120 -24
View File
@@ -24,13 +24,103 @@ def test_healer_labels_available_on_alerts():
"""Labels, die Healer-Regeln brauchen, müssen von Metrik oder Scrape-Target kommen.""" """Labels, die Healer-Regeln brauchen, müssen von Metrik oder Scrape-Target kommen."""
prom = yaml.safe_load((DEPLOY / "prometheus/prometheus.yml").read_text()) prom = yaml.safe_load((DEPLOY / "prometheus/prometheus.yml").read_text())
jobs = {j["job_name"]: j for j in prom["scrape_configs"]} jobs = {j["job_name"]: j for j in prom["scrape_configs"]}
for job in ("node", "gpu", "llamacpp"): 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) 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 from knaster.canary import OPS_LABELS
assert set(OPS_LABELS) == {"docker_host", "container", "pve_node", "vmid"} 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(): def test_alertmanager_targets_router_endpoint():
am = yaml.safe_load((DEPLOY / "alertmanager/alertmanager.yml").read_text()) am = yaml.safe_load((DEPLOY / "alertmanager/alertmanager.yml").read_text())
url = am["receivers"][0]["webhook_configs"][0]["url"] url = am["receivers"][0]["webhook_configs"][0]["url"]
@@ -46,17 +136,15 @@ def test_compose_files_parse(role):
def test_ops_labels_consistent_between_registry_and_prometheus(): def test_ops_labels_consistent_between_registry_and_prometheus():
"""Das erste Scrape-Ziel eines Slots trägt dieselben ops-Labels wie die Registry (Healer-Ziel)."""
reg = yaml.safe_load((REPO_ROOT / "sites/knaster/registry.yaml").read_text()) reg = yaml.safe_load((REPO_ROOT / "sites/knaster/registry.yaml").read_text())
prom = yaml.safe_load((DEPLOY / "prometheus/prometheus.yml").read_text()) prom = yaml.safe_load((DEPLOY / "prometheus/prometheus.yml").read_text())
first: dict[str, dict] = {} vllm = {sc["labels"]["slot"]: sc["labels"] for sc in
for sc in next(j for j in prom["scrape_configs"] if j["job_name"] == "llamacpp")["static_configs"]: next(j for j in prom["scrape_configs"] if j["job_name"] == "vllm")["static_configs"]}
first.setdefault(sc["labels"]["slot"], sc["labels"]) for slot, labels in vllm.items():
assert first
for slot, labels in first.items():
ops = reg["slots"][slot]["ops"] ops = reg["slots"][slot]["ops"]
assert {k: ops[k] for k in ("docker_host", "container", "pve_node", "vmid")} == \ if "vmid" not in ops:
{k: labels[k] for k in ("docker_host", "container", "pve_node", "vmid")}, slot 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"]
WORKFLOWS = sorted((REPO_ROOT / "n8n/workflows").glob("*.json")) WORKFLOWS = sorted((REPO_ROOT / "n8n/workflows").glob("*.json"))
@@ -164,30 +252,38 @@ def test_proxmox_site_consistent_with_registry_and_prometheus():
reg = yaml.safe_load((REPO_ROOT / "sites/knaster/registry.yaml").read_text()) reg = yaml.safe_load((REPO_ROOT / "sites/knaster/registry.yaml").read_text())
vms = site["vms"] vms = site["vms"]
for vm in vms.values(): for vm in vms.values():
assert vm["gpus"], vm # eine GPU-VM pro Knoten mit allen Karten des Knotens (D2)
for gpu in vm["gpus"]: for gpu in vm["gpus"]:
assert gpu in site["pci_mappings"] assert gpu in site["pci_mappings"]
assert site["pci_mappings"][gpu]["node"] == vm["node"] # Mapping liegt auf dem Host der VM assert site["pci_mappings"][gpu]["node"] == vm["node"] # Mapping liegt auf dem Host der VM
assert vm["driver_generation"] in {"legacy", "modern"} assert vm["driver_generation"] in {"legacy", "modern"}
assert vm["node"] in site["nodes"] and site["nodes"][vm["node"]]["gpu_host"] gpus = [gpu for vm in vms.values() for gpu in vm["gpus"]]
gpus = [g for vm in vms.values() for g in vm["gpus"]]
assert len(gpus) == len(set(gpus)) # keine GPU doppelt durchgereicht assert len(gpus) == len(set(gpus)) # keine GPU doppelt durchgereicht
assert set(gpus) == set(site["pci_mappings"]) # jede gemappte Karte steckt in einer VM 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()} by_vmid = {str(vm["vmid"]): (name, vm) for name, vm in {**vms, **site["containers"]}.items()}
for slot, spec in reg["slots"].items(): for slot, spec in reg["slots"].items():
ops = spec.get("ops") or {} ops = spec.get("ops")
if "vmid" not in ops: # z. B. Ada-Worker unter Windows (D3), kein Proxmox-Gast if not ops or "vmid" not in ops:
assert "pve_node" not in ops, slot continue # Ada-Slots ohne Proxmox-VM (D3, Worker-Registry v2)
continue
name, vm = by_vmid[ops["vmid"]] name, vm = by_vmid[ops["vmid"]]
assert vm["node"] == ops["pve_node"], slot assert vm["node"] == ops["pve_node"], slot
assert ops["docker_host"] == name, slot assert ops["docker_host"] == name, slot
for c in site["containers"].values(): # vLLM und PyTorch nur auf modernen Karten (ADR-0006)
if c.get("ha"): if spec["engine"] in {"vllm", "pytorch"} and name in vms:
assert site["nodes"][c["node"]]["zfs_tank"] and c["storage"] == "tank" assert vm["driver_generation"] == "modern", slot
assert all(site["nodes"][t]["zfs_tank"] for t in c["replicate_to"])
vmids = [str(v["vmid"]) for v in vms.values()] + [str(c["vmid"]) for c in site["containers"].values()] vmids = [str(v["vmid"]) for v in vms.values()] + [str(c["vmid"]) for c in site["containers"].values()]
assert len(vmids) == len(set(vmids)) assert len(vmids) == len(set(vmids))
# vLLM nur auf modernen Karten
for vm in vms.values():
if vm["role"] == "gpu-vllm": def test_ops_admin_keys_in_site_file():
assert vm["driver_generation"] == "modern" """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}
+22 -29
View File
@@ -1,4 +1,8 @@
import ipaddress
import json import json
import re
import yaml
from knaster.cli import main from knaster.cli import main
from knaster.inventory.placement import load_catalog, load_nodes, plan from knaster.inventory.placement import load_catalog, load_nodes, plan
@@ -31,34 +35,6 @@ def test_parsers():
PVE_GPU_HOSTS = {"pve44", "pve46", "pve48", "pve52"} PVE_GPU_HOSTS = {"pve44", "pve46", "pve48", "pve52"}
def test_site_knaster_matches_real_nodes():
"""Aufbauplan Task 0.4: Site-Dateien beschreiben die echten Knoten, nicht mehr die Annahmen pve-ada/pve1–4."""
import ipaddress
from knaster.util import load_yaml
nodes = {n.name: n for n in load_nodes(REPO_ROOT / "sites/knaster/inventory.yaml")}
assert set(nodes) == {"pve44", "pve46", "pve48", "pve52", "pve61", "gode-pc-05", "gode-pc-06"}
pve = load_yaml(REPO_ROOT / "sites/knaster/proxmox.yaml")
old_net = ipaddress.ip_network("10.20.0.0/24")
ips = []
for section in ("vms", "containers"):
ips += [ipaddress.ip_interface(g["ip"]).ip for g in pve[section].values()]
ips += [ipaddress.ip_address(pve["cluster"][k]) for k in ("gateway", "dns")]
assert not [ip for ip in ips if ip in old_net]
assert {m["node"] for m in pve["pci_mappings"].values()} <= PVE_GPU_HOSTS
assert {v["node"] for v in pve["vms"].values()} <= PVE_GPU_HOSTS
# eine GPU-VM pro GPU-Knoten mit allen Karten des Knotens (D2)
assert sorted(v["node"] for v in pve["vms"].values()) == sorted(PVE_GPU_HOSTS)
for vm in pve["vms"].values():
mapped = {name for name, m in pve["pci_mappings"].items() if m["node"] == vm["node"]}
assert set(vm["gpus"]) == mapped
gpus_inv = [g for g in nodes[vm["node"]].gpus]
assert len(vm["gpus"]) == len(gpus_inv)
# Ada-Rechner bleiben Windows-Arbeitsplätze (D3): keine Infra-/CPU-Rollen
assert not nodes["gode-pc-05"].cpu_roles and not nodes["pve61"].cpu_roles
def test_placement_knaster(): def test_placement_knaster():
roles, models = load_catalog(REPO_ROOT / "catalog") roles, models = load_catalog(REPO_ROOT / "catalog")
p = plan(load_nodes(REPO_ROOT / "sites/knaster/inventory.yaml"), roles, models) p = plan(load_nodes(REPO_ROOT / "sites/knaster/inventory.yaml"), roles, models)
@@ -77,12 +53,29 @@ def test_placement_knaster():
assert p.free_gpus == [] assert p.free_gpus == []
assert len(p.disabled_gpus) == 4 and all("M4000" in g for g in p.disabled_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")} 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 == by_role["gpu-flex"][0].node 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 "qwen3-tts-1.7b" in tts["tts-hq"].models_fitting
assert tts["tts-cpu"].node == by_role["infra"][0].node assert tts["tts-cpu"].node == by_role["infra"][0].node
assert tts["tts-cpu"].models_fitting == ["piper-de-thorsten-medium"] 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(): def test_nodes_without_cpu_roles_get_no_infra():
"""Arbeitsplätze und Kiosk nehmen keine CPU-Rollen, auch wenn sie den meisten RAM haben.""" """Arbeitsplätze und Kiosk nehmen keine CPU-Rollen, auch wenn sie den meisten RAM haben."""
roles, models = load_catalog(REPO_ROOT / "catalog") roles, models = load_catalog(REPO_ROOT / "catalog")
+19 -1
View File
@@ -1,11 +1,20 @@
# Alertmanager → Router-Service (Healer). Kurzes repeat_interval treibt die Eskalationsleiter voran; # 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: route:
receiver: knaster-healer receiver: knaster-healer
group_by: [alertname, slot, instance] group_by: [alertname, slot, instance]
group_wait: 30s group_wait: 30s
group_interval: 1m group_interval: 1m
repeat_interval: 5m 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: receivers:
- name: knaster-healer - name: knaster-healer
@@ -16,3 +25,12 @@ receivers:
authorization: authorization:
type: Bearer type: Bearer
credentials_file: /etc/alertmanager/router_token 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_REF=b6000
LLAMACPP_COMMIT=4762ad7316dcdec20016ab5985fb46a27902204d
CUDA_VERSION=12.4.0 CUDA_VERSION=12.4.0
CUDA_DOCKER_ARCH=52;61 CUDA_DOCKER_ARCH=52;61
MODELS_DIR=/srv/models MODELS_DIR=/srv/models
@@ -6,3 +8,7 @@ EMBED_GGUF=Qwen3-Embedding-0.6B-Q8_0.gguf
EMBED_POOLING=last EMBED_POOLING=last
RERANK_GGUF=bge-reranker-v2-m3-Q8_0.gguf RERANK_GGUF=bge-reranker-v2-m3-Q8_0.gguf
CHAT_GGUF=Qwen3-4B-Q4_K_M.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. # 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. # 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` # 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 name: knaster-gpu-llamacpp
x-logging: &logging
driver: local
options: {max-size: "5m", max-file: "2"}
x-llamacpp: &llamacpp x-llamacpp: &llamacpp
build: 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 dockerfile: .devops/cuda.Dockerfile
target: server target: server
args: args:
CUDA_VERSION: ${CUDA_VERSION:-12.4.0} CUDA_VERSION: ${CUDA_VERSION:-12.4.0}
CUDA_DOCKER_ARCH: ${CUDA_DOCKER_ARCH:-52;61} 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 restart: unless-stopped
network_mode: host
read_only: true
tmpfs: [/tmp:size=64m]
labels: {autoheal: "true"} labels: {autoheal: "true"}
volumes: volumes:
- ${MODELS_DIR:-/srv/models}:/models:ro - ${MODELS_DIR:-/srv/models}:/models:ro
deploy: logging: *logging
resources:
reservations:
devices: [{driver: nvidia, count: 1, capabilities: [gpu]}]
services: services:
llamacpp-embed: llamacpp-embed:
@@ -27,60 +39,61 @@ services:
container_name: llamacpp-embed container_name: llamacpp-embed
profiles: ["embed"] profiles: ["embed"]
command: ["-m", "/models/${EMBED_GGUF:-Qwen3-Embedding-0.6B-Q8_0.gguf}", "--embedding", command: ["-m", "/models/${EMBED_GGUF:-Qwen3-Embedding-0.6B-Q8_0.gguf}", "--embedding",
"--pooling", "${EMBED_POOLING:-last}", "-c", "8192", "-ub", "8192", "-ngl", "99", "--pooling", "${EMBED_POOLING:-last}", "-c", "8192", "-b", "4096", "-ub", "4096", "-np", "1", # ub 8192 sprengt die P2000 (Task 6.1)
"--alias", "embed-default", "--metrics", "--host", "0.0.0.0", "--port", "8081"] "-ngl", "99", "--alias", "embed-default", "--metrics", "--no-slots", "--no-webui",
ports: ["8081:8081"] "--host", "0.0.0.0", "--port", "8081"]
deploy:
resources:
reservations:
devices: [{driver: nvidia, device_ids: ["${EMBED_GPU:-1}"], capabilities: [gpu]}]
healthcheck: &hc healthcheck: &hc
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8081/health"] test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8081/health"]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
start_period: 60s start_period: 120s
llamacpp-rerank: llamacpp-rerank:
<<: *llamacpp <<: *llamacpp
container_name: llamacpp-rerank container_name: llamacpp-rerank
profiles: ["embed"] profiles: ["embed"]
command: ["-m", "/models/${RERANK_GGUF:-bge-reranker-v2-m3-Q8_0.gguf}", "--reranking", "-c", "8192", command: ["-m", "/models/${RERANK_GGUF:-bge-reranker-v2-m3-Q8_0.gguf}", "--reranking",
"-ub", "8192", "-ngl", "99", "--alias", "rerank-default", "--metrics", "--host", "0.0.0.0", "-c", "8192", "-b", "8192", "-ub", "8192", "-np", "1", "-ngl", "99", "--alias", "rerank-default",
"--port", "8082"] "--metrics", "--no-slots", "--no-webui", "--host", "0.0.0.0", "--port", "8082"]
ports: ["8082:8082"] deploy:
resources:
reservations:
devices: [{driver: nvidia, device_ids: ["${RERANK_GPU:-1}"], capabilities: [gpu]}]
healthcheck: healthcheck:
<<: *hc <<: *hc
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8082/health"] 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-chat:
<<: *llamacpp <<: *llamacpp
container_name: llamacpp-chat container_name: llamacpp-chat
profiles: ["chat"] profiles: ["chat"]
command: ["-m", "/models/${CHAT_GGUF:-Qwen3-4B-Q4_K_M.gguf}", "-c", "8192", "-ngl", "99", command: ["-m", "/models/${CHAT_GGUF:-Qwen3-4B-Q4_K_M.gguf}", "-c", "8192", "-np", "1", "-ngl", "99",
"--alias", "verifier-small", "--metrics", "--host", "0.0.0.0", "--port", "8080"] "--jinja", "--chat-template-kwargs", "{\"enable_thinking\": false}",
ports: ["8080:8080"] "--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: healthcheck:
<<: *hc <<: *hc
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"] test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"]
gpu-exporter: # Startet Container mit Label autoheal=true neu, wenn ihr Healthcheck dauerhaft scheitert
image: utkuozdemir/nvidia_gpu_exporter:1.2.1 # (restart: unless-stopped greift nur beim Absturz). Läuft in jedem Profil mit.
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"]
autoheal: autoheal:
image: willfarrell/autoheal:1.2.0 image: willfarrell/autoheal:1.2.0
container_name: llamacpp-autoheal
restart: unless-stopped restart: unless-stopped
environment: {AUTOHEAL_CONTAINER_LABEL: autoheal} environment: {AUTOHEAL_CONTAINER_LABEL: autoheal}
volumes: ["/var/run/docker.sock:/var/run/docker.sock"] 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 -8
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_VERSION=dev
KNASTER_SITE=knaster KNASTER_SITE=knaster
KNASTER_STATE_DIR=.
KNASTER_API_TOKEN=change-me KNASTER_API_TOKEN=change-me
KNASTER_DRY_RUN=true KNASTER_DRY_RUN=true
KNASTER_CANARY_INTERVAL=60 KNASTER_CANARY_INTERVAL=60
POSTGRES_PASSWORD=change-me POSTGRES_PASSWORD=change-me
LITELLM_API_KEY=change-me GRAFANA_ADMIN_PASSWORD=change-me
LITELLM_MASTER_KEY=change-me GRAFANA_KIOSK_PASSWORD=change-me
OPENROUTER_API_KEY= # D4: LiteLLM mit PSE auf appsrv08, kein eigener LiteLLM im Stack.
RERANK_URL=http://gpu-embed-1:8082 # 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= NOTIFY_WEBHOOK_URL=
KNASTER_JOB_WEBHOOK_URL= KNASTER_JOB_WEBHOOK_URL=
PORTAINER_HOST= PORTAINER_HOST=
@@ -15,7 +23,4 @@ PORTAINER_KEY=
PROXMOX_URL= PROXMOX_URL=
PROXMOX_TOKEN_ID= PROXMOX_TOKEN_ID=
PROXMOX_TOKEN_SECRET= PROXMOX_TOKEN_SECRET=
KNASTER_WEBHOOK_AUTH=Bearer change-me KNASTER_WEBHOOK_AUTH=
# Ontologie-Abgleich (nur lesend: GET /api/v1/workflows); leer = Abgleich aus
N8N_HOST=
N8N_API_KEY=
+54 -30
View File
@@ -1,5 +1,10 @@
# Rolle "infra" (LXC/VM auf einem i7-Node): Router, LiteLLM, Postgres, Monitoring. # Rolle "infra" (LXC 101 auf pve44, HA nach pve48/pve52): Router, TTS, Postgres und Monitoring in einem Stack.
# Start: cp .env.example .env && docker compose up -d # 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 name: knaster-infra
x-restart: &restart x-restart: &restart
@@ -13,29 +18,15 @@ services:
context: ../../.. context: ../../..
dockerfile: deploy/docker/router.Dockerfile dockerfile: deploy/docker/router.Dockerfile
image: knaster/router:${KNASTER_VERSION:-dev} image: knaster/router:${KNASTER_VERSION:-dev}
env_file: [{path: .env, required: false}] env_file: [{path: "${KNASTER_STATE_DIR:-.}/.env", required: false}]
environment: environment:
KNASTER_DB_URL: postgresql+psycopg://knaster:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD fehlt}@postgres:5432/knaster KNASTER_DB_URL: postgresql+psycopg://knaster:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD fehlt}@postgres:5432/knaster
KNASTER_SITE_DIR: /app/sites/${KNASTER_SITE:-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"] ports: ["8080:8080"]
depends_on: depends_on:
postgres: {condition: service_healthy} 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: tts-piper:
<<: *restart <<: *restart
@@ -53,7 +44,7 @@ services:
postgres: postgres:
<<: *restart <<: *restart
image: pgvector/pgvector:pg16 image: pgvector/pgvector:0.8.7-pg16-trixie
environment: environment:
POSTGRES_USER: knaster POSTGRES_USER: knaster
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD fehlt} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD fehlt}
@@ -66,42 +57,74 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
# --- Monitoring ---------------------------------------------------------------------------------
prometheus: prometheus:
<<: *restart <<: *restart
image: prom/prometheus:v2.53.0 image: prom/prometheus:v3.13.4
command: command:
- --config.file=/etc/prometheus/prometheus.yml - --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=15d - --storage.tsdb.retention.time=15d
- --web.enable-lifecycle - --web.enable-lifecycle
volumes: volumes:
- ../../prometheus:/etc/prometheus:ro - ../../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 - prometheus:/prometheus
extra_hosts: ["host.docker.internal:host-gateway"] # node-exporter des LXC läuft im Host-Netz
ports: ["9090:9090"] ports: ["9090:9090"]
alertmanager: alertmanager:
<<: *restart <<: *restart
image: prom/alertmanager:v0.27.0 image: prom/alertmanager:v0.34.1
command: ["--config.file=/etc/alertmanager/alertmanager.yml"] command:
- --config.file=/etc/alertmanager/alertmanager.yml
- --storage.path=/alertmanager
volumes: volumes:
- ../../alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro - ../../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"] ports: ["9093:9093"]
grafana: grafana:
<<: *restart <<: *restart
image: grafana/grafana:11.1.0 image: grafana/grafana:12.4.12
profiles: ["dashboards"] 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: volumes:
- ../../grafana/provisioning:/etc/grafana/provisioning:ro
- ../../grafana/dashboards:/var/lib/grafana/dashboards:ro
- grafana:/var/lib/grafana - grafana:/var/lib/grafana
ports: ["3000:3000"] ports: ["3000:3000"]
depends_on: [prometheus]
pve-exporter: pve-exporter:
<<: *restart <<: *restart
image: prompve/prometheus-pve-exporter:${PVE_EXPORTER_TAG:-latest} # vor Deployment pinnen image: prompve/prometheus-pve-exporter:3.10.1
profiles: ["proxmox"] # 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: volumes:
- ./secrets/pve.yml:/etc/prometheus/pve.yml:ro - /:/host:ro # ohne rslave: im unprivilegierten LXC nicht erlaubt
ports: ["9221:9221"]
autoheal: autoheal:
restart: unless-stopped restart: unless-stopped
@@ -115,4 +138,5 @@ services:
volumes: volumes:
postgres: {} postgres: {}
prometheus: {} prometheus: {}
alertmanager: {}
grafana: {} 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 "$@"
+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 for: 15m
labels: {severity: warning} labels: {severity: warning}
annotations: {summary: "{{ $labels.instance }} {{ $labels.mountpoint }} < 10 % frei"} 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"}
+76 -35
View File
@@ -1,8 +1,8 @@
# Scrape-Ziele. Die Labels an den GPU-Zielen (slot, docker_host, container, pve_node, vmid) landen in den # Scrape-Ziele. Die Labels an den GPU-Zielen (slot, docker_host, container, pve_node, vmid) landen in den
# Alerts und damit beim Healer. IP-Literale aus sites/knaster/proxmox.yaml (Global Constraints, Aufbauplan). # Alerts und damit beim Healer.
# ANNAHME bis Task 0.2 (Gast-IPs) und Task 3.3 (welcher Slot auf welcher P2000 läuft). # GPU-VMs (job node, gpu, llamacpp): file_sd, weil die DHCP-Adressen wechseln (D5). Die Dateien erzeugt
# Ada-Worker (GODE-PC-05/06, Windows, D3) werden nicht gescrapt: ihre Verfügbarkeit meldet die # platform/proxmox/scripts/monitoring-targets.py auf ops aus `tofu output -json gpu_vms` und der Registry;
# Worker-Registry v2 (Task 6.4), der Canary-Prober des Routers prüft die Slots zusätzlich. # die Ansible-Rolle infra-stack kopiert sie nach /opt/knaster/infra/file_sd (im Container /etc/knaster-sd).
global: global:
scrape_interval: 30s scrape_interval: 30s
evaluation_interval: 30s evaluation_interval: 30s
@@ -14,50 +14,91 @@ alerting:
- static_configs: [{targets: ["alertmanager:9093"]}] - static_configs: [{targets: ["alertmanager:9093"]}]
scrape_configs: scrape_configs:
- job_name: prometheus
static_configs: [{targets: ["localhost:9090"]}]
- job_name: alertmanager
static_configs: [{targets: ["alertmanager:9093"]}]
- job_name: knaster-router - job_name: knaster-router
metrics_path: /metrics metrics_path: /metrics
static_configs: [{targets: ["router:8080"]}] static_configs: [{targets: ["router:8080"]}]
# Erstes Ziel je Slot = Healer-Ziel in sites/knaster/registry.yaml (ops) # 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"]
labels: {slot: tier1-general, docker_host: gpu-main, container: vllm, pve_node: pve-ada, vmid: "201"}
- 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 - job_name: llamacpp
static_configs: file_sd_configs: [{files: [/etc/knaster-sd/llamacpp.json]}]
- targets: ["192.168.100.224:8081"]
labels: {slot: embed-default, docker_host: gpu-pve44, container: llamacpp-embed, pve_node: pve44, vmid: "244"}
- targets: ["192.168.100.226:8081"]
labels: {slot: embed-default, docker_host: gpu-pve46, container: llamacpp-embed, pve_node: pve46, vmid: "246"}
- targets: ["192.168.100.224:8082"]
labels: {slot: rerank-default, docker_host: gpu-pve44, container: llamacpp-rerank, pve_node: pve44, vmid: "244"}
- targets: ["192.168.100.228:8080"]
labels: {slot: verifier-small, docker_host: gpu-pve48, container: llamacpp-chat, pve_node: pve48, vmid: "248"}
# nvidia_gpu_exporter (nvidia-smi, auch für Maxwell/Pascal; DCGM kann Maxwell nicht) in jeder GPU-VM
- job_name: gpu - job_name: gpu
static_configs: file_sd_configs: [{files: [/etc/knaster-sd/gpu.json]}]
- targets: ["192.168.100.224:9835"]
labels: {slot: embed-default, pve_node: pve44, vmid: "244"}
- targets: ["192.168.100.226:9835"]
labels: {slot: embed-default, pve_node: pve46, vmid: "246"}
- targets: ["192.168.100.228:9835"]
labels: {slot: verifier-small, pve_node: pve48, vmid: "248"}
- targets: ["192.168.100.232:9835"]
labels: {slot: "", pve_node: pve52, vmid: "252"} # nur M4000, außer Betrieb bis Task 3.3
- job_name: node # Labels werden für GpuXidError → Healer (VM-Neustart) gebraucht # node_exporter in jeder GPU-VM; Labels werden für GpuXidError → Healer (VM-Neustart) gebraucht
static_configs: - job_name: node
- targets: ["192.168.100.224:9100"] file_sd_configs: [{files: [/etc/knaster-sd/node.json]}]
labels: {slot: embed-default, pve_node: pve44, vmid: "244"}
- targets: ["192.168.100.226:9100"]
labels: {slot: embed-default, pve_node: pve46, vmid: "246"}
- targets: ["192.168.100.228:9100"]
labels: {slot: verifier-small, pve_node: pve48, vmid: "248"}
- targets: ["192.168.100.232:9100"]
labels: {slot: "", pve_node: pve52, vmid: "252"}
# 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 - job_name: proxmox
metrics_path: /pve metrics_path: /pve
params: {module: [default]} params: {module: [default], cluster: ["1"], node: ["1"]}
static_configs: static_configs:
- targets: ["192.168.100.210", "192.168.100.209", "192.168.100.212", "192.168.100.207", "192.168.100.214"] - {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: relabel_configs:
- {source_labels: [__address__], target_label: __param_target} - {source_labels: [__address__], target_label: __param_target}
- {source_labels: [__param_target], target_label: instance} - {source_labels: [__param_target], target_label: instance}
- {target_label: __address__, replacement: "pve-exporter:9221"} - {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`.
+205 -22
View File
@@ -1,4 +1,4 @@
# Betrieb: Skill-Lebenszyklus, Hermes, Pflege, Freischaltung # Betrieb: Skill-Lebenszyklus, Hermes, Pflege, Freischaltung, Proxmox-Cluster
## 1. Lebenszyklus eines Skills ## 1. Lebenszyklus eines Skills
@@ -72,32 +72,215 @@ Freitext, der keinem Befehl entspricht, bildet ein kleines Modell auf einen Befe
| Treiber/Engine | Proxmox-Snapshot der GPU-VM (vor jedem Update anlegen) | | Treiber/Engine | Proxmox-Snapshot der GPU-VM (vor jedem Update anlegen) |
| Konfiguration | Git-Revert und Neustart des Router-Containers | | Konfiguration | Git-Revert und Neustart des Router-Containers |
## 6. Ontologie pflegen ## 6. Proxmox: Updates und Neustarts (rollierend, Knoten für Knoten)
Die Ontologie (`ontology/know.yaml`) beschreibt die Begriffe der Datenhaltung. Details in `docs/spec/SPEC-ontology.md`. Stand 12.10.2026, getestet mit dem Wartungsmodus auf pve44. Gilt für pve44, pve46, pve48, pve52 und pve61.
- **Kern** (Klassen, Beziehungen) ändert sich nur per Commit. **Wichtig vorab**
- **Vokabulare** wachsen über die Service-Routine. Sie gleicht n8n nur lesend mit der Ontologie ab und schlägt neue Werte vor: - 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
``` ```
> routine Prometheus liest die Dateien in `/opt/knaster/infra/file_sd` selbst neu, ein Neustart ist nicht nötig.
Befunde:
- Ontologie [high]: KAN - Kanbau API Gateway ruft `get_kanbau_project_patterns` auf, die Operation gibt es in KNOW nicht
Offene Vorschläge:
- P-21 [low] Mail-Kategorie: neuer Wert „bauvorhaben“
- P-22 [low] Mail-Kategorie: neuer Wert „MPA“
> genehmige P-21 → neues Konzept **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.
> ordne P-21 sonstiges zu → „bauvorhaben“ wird Schreibweise von „sonstiges“
> lehne P-22 ab: Workflow-Name → Wert wird verworfen und nicht wieder vorgeschlagen
```
Danach im Repo übernehmen, z. B. wöchentlich: **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).
knaster ontology apply # Entscheidungen → know.yaml, Version +1
knaster ontology export --ttl ontology/know.ttl **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).
git commit -am "ontology: Entscheidungen übernommen"
``` **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.
Ohne `N8N_HOST`/`N8N_API_KEY` (oder `KNASTER_ONTOLOGY_SNAPSHOT`) ist der Abgleich aus. Fällt n8n aus, läuft die Routine weiter und meldet das.
+1 -1
View File
@@ -41,7 +41,7 @@ Vorgehen pro Baustein: **Spec → Implementierung → Tests → Commit**. Bis zu
## Offene Punkte vor dem Deployment ## 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. - LiteLLM-Wildcard-Routen (`tier1-general/*`) gegen die eingesetzte Version testen.
- Den n8n Chat-Trigger nach dem Import prüfen (Parameter der Knotenversion). - 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. - `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 -31
View File
@@ -33,7 +33,6 @@ Ziel: Ein starkes LLM (OpenRouter) erarbeitet neue Aufgaben einmalig und bricht
- **GPU-Nodes als VM.** Grund: Die alten Karten (Maxwell M4000, Pascal P2000) brauchen fest gepinnte Treiber mit CUDA 12.x, die Ada-Karten profitieren von aktuellen Treibern. Mit VMs lebt beides sauber getrennt auf einem Cluster. Treiber-Updates werden per Snapshot rückrollbar. - **GPU-Nodes als VM.** Grund: Die alten Karten (Maxwell M4000, Pascal P2000) brauchen fest gepinnte Treiber mit CUDA 12.x, die Ada-Karten profitieren von aktuellen Treibern. Mit VMs lebt beides sauber getrennt auf einem Cluster. Treiber-Updates werden per Snapshot rückrollbar.
- **CPU-Infrastruktur als LXC oder leichte VM** (n8n, Postgres, LiteLLM, Monitoring). Das spart RAM auf den i7-Nodes. - **CPU-Infrastruktur als LXC oder leichte VM** (n8n, Postgres, LiteLLM, Monitoring). Das spart RAM auf den i7-Nodes.
- Innerhalb der VMs bzw. LXCs läuft Docker Compose, ein Compose-Projekt pro Rolle. - Innerhalb der VMs bzw. LXCs läuft Docker Compose, ein Compose-Projekt pro Rolle.
- **Stand Aufbauplan (D2):** eine GPU-VM pro Knoten mit allen Karten des Knotens statt einer VM pro Rolle. Im Cluster stecken nur noch Maxwell und Pascal, die Ada-Rechner bleiben Windows-Arbeitsplätze (ADR-0001 und ADR-0006, jeweils Nachtrag).
### 1.2 Was Proxmox zusätzlich ermöglicht ### 1.2 Was Proxmox zusätzlich ermöglicht
@@ -48,35 +47,45 @@ Ziel: Ein starkes LLM (OpenRouter) erarbeitet neue Aufgaben einmalig und bricht
### 1.3 Rollenverteilung (aktuelle Hardware) ### 1.3 Rollenverteilung (aktuelle Hardware)
Stand: Aufbauplan vom 12.10.2026 (`docs/superpowers/plans/2026-10-12-aufbau-cluster-knaster.md`). Die Knoten stehen in `sites/knaster/inventory.yaml`. Welche P2000 welche Rolle bekommt, legt erst der Benchmark-Tag fest (Task 3.3). Stand 12.10.2026, per SSH geprüft. Proxmox-Cluster `knaster` mit fünf Knoten:
| Rolle | Hardware | Engine | Aufgabe | | Knoten | IP | Hardware (Kerne/Threads, RAM) | GPUs |
|---|---|---|---| |---|---|---|---|
| `gpu-main` | RTX 4000 Ada (20 GB) in GODE-PC-05, Windows | llama-server über Worker-Registry v2 (D3); vLLM/Multi-LoRA erst ab Phase 9 | Slot `tier1-general`, wenn der Worker online ist, sonst tier0 | | pve44 | 192.168.100.210 | ThinkStation P310, i7-6700 (4/8), 32 GB | Quadro M4000 (8 GB) + Quadro P2000 (5 GB) |
| `gpu-flex` | RTX 4000 Ada (20 GB) in GODE-PC-06, Windows | wie `gpu-main` | `tier1-flex`; Training erst ab Phase 9 | | pve46 | 192.168.100.209 | ThinkStation P310, i7-6700 (4/8), 32 GB | M4000 + P2000 |
| `gpu-embed` | 2× Quadro P2000 (5 GB) in `gpu-pve44` und `gpu-pve46` | llama.cpp (Embedding/Rerank) | `embed-default`, `rerank-default`, redundant auf zwei Knoten | | pve48 | 192.168.100.212 | ThinkStation P310, i7-6700 (4/8), 32 GB | M4000 + P2000 |
| `gpu-small` | Quadro P2000 (5 GB) in `gpu-pve48` | llama.cpp | `verifier-small`, `signature-extractor` (3–4B) | | pve52 | 192.168.100.207 | ThinkStation P320, i7-7700 (4/8), 32 GB | nur M4000 |
| – | 4× Quadro M4000 (8 GB), je eine in pve44/46/48/52 | – | **vorerst außer Betrieb** (ADR-0006), werden aber an die GPU-VM durchgereicht; nach dem Benchmark nur für llama.cpp-Rollen | | pve61 | 192.168.100.214 | Surface Book, i5-8350U (4/8), 8 GB | keine NVIDIA (Intel UHD 620, Kiosk) |
| `tts-hq` | teilt die Ada in GODE-PC-06 | PyTorch | `tts-de-hq`: Qwen3-TTS 1.7B, erst ab Phase 9 (WSL2, D3) |
| `tts-cpu` | Infra-LXC (CPU) | Piper (ONNX) | `tts-de`: Sprachausgabe, vorerst einziger TTS-Slot |
| `infra` | LXC `infra` auf pve44 (`tank`, HA nach pve52/pve48) | Docker | Router, Postgres+pgvector, Healer, Prometheus/Grafana/Alertmanager; **kein eigener LiteLLM** (D4) |
| `ops` | LXC auf pve46 | – | Ansible, OpenTofu, Repo-Clone |
| `strong` | extern | OpenRouter **über den LiteLLM mit PSE auf appsrv08** | `tier0-strong`, Skill-Autor (Hermes) |
pve61 (Surface Book, 8 GB) nimmt keine Gäste auf und zeigt nur das Grafana-Dashboard als Kiosk. n8n läuft weiter auf n8n.mindboost.app, LiteLLM auf appsrv08 (D4). 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). 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 mit Nachtrag D1) ### 1.4 Netzwerk und Storage (entschieden, siehe ADR-0002)
Ausgangslage: **1 GbE**, flaches Netz `192.168.100.0/24`. Die Knoten sind mit ext4 und LVM-thin auf einer System-SSD installiert, kein ZFS. 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. - **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.
- **Variante B (D1, entschieden 12.10.):** Die System-SSD bleibt (`local`, `local-lvm` für die GPU-VMs). Auf pve44, pve48 und pve52 wird die zweite SSD nach Freigabe zur Einzel-SSD `tank`. pve46 und pve61 bleiben ohne ZFS. Der ARC ist auf 4 GiB begrenzt. - **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.) für HA-Gäste (`infra`) zwischen den `tank`-Knoten. Zusammen mit Proxmox HA startet der Gast nach einem Knotenausfall mit höchstens 15 Min. altem Stand. Für Postgres wird das zusätzlich durch PBS-Backups und WAL-Archivierung abgefedert. - **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, in einem eigenen Disk-Image (`models`, 60 GB, 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. - **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 `pbs` auf pve52, Datastore auf einem externen Datenträger oder NAS (Task 5.1). Gesichert werden Postgres, Konfiguration und Datensätze, keine Modellgewichte. - **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.
- **Netz (D5):** Die Knoten behalten .207–.214. Gäste bekommen feste IPs aus einem Block außerhalb des DHCP-Bereichs, vorgeschlagen ist `.220–.239` (Prüfung in Task 0.2). Konfigurationen nutzen IP-Literale, keine DNS-Namen. **VLANs** (`mgmt`, `svc`, `ext`) kommen erst später, wenn der Switch das zulässt. - **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):** **Speicherbudget pro Rolle (Richtwerte):**
@@ -86,7 +95,7 @@ Ausgangslage: **1 GbE**, flaches Netz `192.168.100.0/24`. Die Knoten sind mit ex
| `gpu-embed` / `gpu-small` | 15–25 GB | Embedding-, Rerank- und kleine GGUF-Modelle (je 0,5–3 GB), Images | | `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 | | `infra` | 40–80 GB | Postgres (Fälle, Traces), Prometheus (15 Tage Retention), Langfuse |
Die SSD-Verteilung steht in `sites/knaster/inventory.yaml` (Bestandsaufnahme vom 12.10.). `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`.
--- ---
@@ -309,14 +318,15 @@ Die Portabilität beruht also auf den **Daten und der Pipeline**, nicht auf Gewi
- Infrastruktur heilt deterministisch, ohne LLM-Entscheidungen (ADR-0005) - 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) - Alte GPUs nur llama.cpp, M4000 vorerst aus, TTS mit Piper (CPU) und Qwen3-TTS (Ada) (ADR-0006)
### Entscheidungen 2026-10 (Aufbauplan) ### Entscheidungen 2026-10
| # | Thema | Stand |
|---|---|---| Aus dem [Aufbauplan](superpowers/plans/2026-10-12-aufbau-cluster-knaster.md), Abschnitt „Entscheidungen“:
| D1 | Speicher: Variante B, keine Neuinstallation, `tank` auf pve44/48/52 | **entschieden** 12.10. Gelöscht wird erst nach ausdrücklicher Freigabe der alten Windows-Platten |
| D2 | Eine Ubuntu-VM pro GPU-Knoten mit allen Karten (ADR-0001, Nachtrag) | empfohlen, Bestätigung offen (Task 0.3) | - **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).
| D3 | Ada-Rechner bleiben Windows, Anbindung über die Worker-Registry v2 (ADR-0006, Nachtrag) | empfohlen, Bestätigung offen | - **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).
| D4 | Kein eigener LiteLLM, der Router nutzt den LiteLLM mit PSE auf appsrv08 | empfohlen, Bestätigung offen | - **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).
| D5 | Knoten-IPs bleiben, Gäste aus `.220–.239`, kein VLAN | empfohlen, Bestätigung offen. Der IP-Block wird in Task 0.2 geprüft | - **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 ### Offen
1. Erstes Basismodell für `tier1-general` (8–14B, gut quantisierbar, LoRA-freundlich, gutes Deutsch). Entscheidung per Kalibrierungslauf. 1. Erstes Basismodell für `tier1-general` (8–14B, gut quantisierbar, LoRA-freundlich, gutes Deutsch). Entscheidung per Kalibrierungslauf.
@@ -324,4 +334,4 @@ Die Portabilität beruht also auf den **Daten und der Pipeline**, nicht auf Gewi
3. Verifier-Strategie pro Aufgabentyp: Code-Checks, Schema, kleines Judge-Modell, Stichproben durch das starke Modell 3. Verifier-Strategie pro Aufgabentyp: Code-Checks, Schema, kleines Judge-Modell, Stichproben durch das starke Modell
4. Schwellwerte: Graduierung (Start 90 %), Fallzahl fürs LoRA-Training (Start 300), Canary-Anteil (Start 10 %) 4. Schwellwerte: Graduierung (Start 90 %), Fallzahl fürs LoRA-Training (Start 300), Canary-Anteil (Start 10 %)
5. Kanal für den Pflege-Agenten: n8n Chat, Telegram oder Matrix 5. Kanal für den Pflege-Agenten: n8n Chat, Telegram oder Matrix
6. Ziel-Speicher des Proxmox Backup Servers (Aufbauplan Task 5.1, Empfehlung: externer Datenträger oder NAS) 6. Standort des Proxmox Backup Servers (intern auf SSD oder extern)
+9 -12
View File
@@ -1,6 +1,6 @@
# ADR-0001: GPU-Nodes als VM, Infrastruktur als LXC # ADR-0001: GPU-Nodes als VM, Infrastruktur als LXC
- Status: angenommen, ergänzt am 2026-10-13 (Nachtrag D2, Bestätigung offen) - Status: angenommen
- Datum: 2026-10-11 - Datum: 2026-10-11
## Kontext ## Kontext
@@ -16,14 +16,11 @@ Im Cluster stecken GPUs aus drei Generationen: Maxwell (M4000), Pascal (P2000) u
- Keine Live-Migration für GPU-VMs. Bei einem Ausfall springt der Router auf einen anderen Slot oder auf Stufe 0. - 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. - Treiber-Updates laufen pro VM, abgesichert durch einen Snapshot.
## Nachtrag 2026-10-13: eine GPU-VM pro Knoten (Aufbauplan D2, Bestätigung offen) ## Nachtrag 2026-10-12: eine GPU-VM pro Knoten (D2)
Die echte Hardware (Aufbauplan, Ausgangslage) ändert die Voraussetzungen: - 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.
- Im Proxmox-Cluster stecken nur Maxwell und Pascal (pve44, pve46, pve48: M4000 + P2000, pve52: nur M4000). Die beiden RTX 4000 Ada sitzen in Windows-Arbeitsplätzen außerhalb des Clusters (D3, ADR-0006). Das Argument „gemischte Generationen in einem Host“ entfällt. - Gründe:
- Die Knoten haben 32 GB RAM. Eine VM pro Rolle würde den RAM mehrfach fest reservieren. - **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.
Deshalb gilt, sobald D2 bestätigt ist: - **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.
- **Pro GPU-Knoten eine Ubuntu-24.04-VM** (`gpu-pve44` … `gpu-pve52`, vmid 244–252) mit **allen** Karten des Knotens, 12 GB RAM fest, R580 (`driver_generation: legacy`). Darin laufen die Rollen als Docker-Compose-Dienste. - 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).
- Resource Mappings heißen `gpu-<typ>-<knoten>` (z. B. `gpu-p2000-pve44`). - 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.
- Ausgeschaltete Karten (M4000) werden trotzdem durchgereicht. So braucht ihr Wiedereinsatz nach dem Benchmark keine Änderung an der VM.
- Infra bleibt LXC (`infra`, vmid 101), dazu kommt ein kleiner `ops`-LXC (vmid 100) für Ansible und OpenTofu.
- Die oben genannten RAM-Richtwerte für vLLM-VMs entfallen. Das RAM-Budget je Knoten steht im Aufbauplan.
+7 -10
View File
@@ -1,6 +1,6 @@
# ADR-0002: ZFS lokal + Replikation statt Ceph # ADR-0002: ZFS lokal + Replikation statt Ceph
- Status: angenommen, ergänzt am 2026-10-13 (Nachtrag D1, entschieden) - Status: angenommen
- Datum: 2026-10-11 - Datum: 2026-10-11
## Kontext ## Kontext
@@ -17,12 +17,9 @@ Die Nodes sind mit **1 GbE** verbunden. Jede physische Maschine hat 2–3 SSDs m
- Modellwechsel verursachen Downloads (10 GB ≈ 90 s über 1 GbE). Das ist akzeptabel. - Modellwechsel verursachen Downloads (10 GB ≈ 90 s über 1 GbE). Das ist akzeptabel.
- Die Speicherbudgets pro Rolle stehen in PLAN.md, Abschnitt 1.4. - Die Speicherbudgets pro Rolle stehen in PLAN.md, Abschnitt 1.4.
## Nachtrag 2026-10-13: Variante B ohne Neuinstallation (Aufbauplan D1, entschieden am 12.10.) ## Nachtrag 2026-10-12: Umsetzung als Variante B (D1)
Die Knoten sind bereits mit ext4 und LVM-thin auf der System-SSD installiert. Entschieden ist **Variante B**: - 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 System-SSD bleibt, wie sie ist (`local` und `local-lvm`). Die GPU-VMs liegen auf `local-lvm`. - 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.
- Auf **pve44, pve48 und pve52** wird die zweite SSD (bisher altes Windows) nach ausdrücklicher Freigabe remote zur Einzel-SSD `tank` (`ashift=12`, `lz4`). Auf pve48 bleibt `sdc` unberührt. - 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.
- **pve46** (eine Platte) und **pve61** (Kiosk) bekommen kein ZFS. Dort laufen nur Gäste ohne HA (GPU-VM, `ops`). - Die GPU-VMs liegen auf `local-lvm` des eigenen Knotens. Sie werden nicht repliziert, Modelle lassen sich neu laden.
- HA-Gäste (`infra`) liegen auf `tank` und werden alle 15 Min. auf die anderen `tank`-Knoten repliziert. - 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.
- Der ZFS-ARC wird auf 4 GiB begrenzt.
- Einen Spiegel für die Systemplatte gibt es nicht. Fällt eine System-SSD aus, wird der Knoten neu installiert, die HA-Gäste laufen repliziert weiter.
- Variante A (Neuinstallation mit ZFS-RAID1 vor Ort) lässt sich später pro Knoten nachholen.
@@ -1,6 +1,6 @@
# ADR-0006: Alte GPUs nur für llama.cpp, M4000 vorerst außer Betrieb, TTS auf Ada und CPU # ADR-0006: Alte GPUs nur für llama.cpp, M4000 vorerst außer Betrieb, TTS auf Ada und CPU
- Status: angenommen, ergänzt am 2026-10-13 (Nachtrag D3, Bestätigung offen) - Status: angenommen
- Datum: 2026-10-12 - Datum: 2026-10-12
## Kontext ## Kontext
@@ -21,8 +21,57 @@
- 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. - 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. - Die tatsächlichen RTF-Werte misst `knaster bench tts` beim Deployment. Sie ersetzen die Schätzungen im Katalog.
## Nachtrag 2026-10-13: Ada-Rechner bleiben Windows (Aufbauplan D3, Bestätigung offen) ## Nachtrag 2026-10-12: Ada-Rechner bleiben Windows (D3, vorgeschlagen, Bestätigung offen)
Die beiden RTX 4000 Ada stecken in **GODE-PC-05 und GODE-PC-06** (je eine Karte, 64 GB RAM). Das sind Windows-Arbeitsplätze, tagsüber haben Mitarbeitende Vorrang. Sie laufen schon heute als LLM-Worker (`LocalLLMWorker.ps1`, llama-server mit CUDA). - 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.
- Die Slots `tier1-general` und `tier1-flex` lösen künftig über die **Worker-Registry v2** auf einen Worker auf, der gerade online ist. Ist keiner online, geht die Anfrage an tier0 über den PSE-LiteLLM (Aufbauplan Task 6.4). Bis dahin behalten sie in der Registry ihre Engine. - 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.
- **vLLM, Multi-LoRA, QLoRA und Qwen3-TTS (`tts-de-hq`)** laufen unter Windows nicht nativ. Sie kommen erst mit einer späteren Entscheidung über WSL2 bzw. nächtliches vLLM (Phase 9). Bis dahin ist `tts-de` (Piper, CPU) der einzige aktive TTS-Slot. - 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.
- Im Inventar stehen die Rechner mit `cpu_roles: false`. Das Placement verplant dort nur die GPU und nie Infra- oder CPU-Fallback-Rollen. - 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.
@@ -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 | | 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 | | 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 | | 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: altes Windows, **BitLocker „GODEL55“**) | nur M4000 (keine P2000 verbaut) | – | | 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 | | 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. 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. 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: 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). - **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. - **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. - **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\`. - **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). - **Planner-Webhook `tdo-codex-update`:** pro Aufruf nur **eine** Aktualisierung, vorhandene Titel nie umbenennen (Abgleich läuft über den Titel, Schwelle 0.8).
@@ -71,8 +73,19 @@ 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. | | **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. | | **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. | | **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. | | **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) 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 |
|---|---|---|
| 1.3 pve61-Netz am Dock · 5.1 Backup-Ziel entscheiden · 6.4 Worker-Registry v2 (nex) · NEX P1–P8 | 0.4 Site-Dateien/ADRs · 1.1 Erst-Update aller Knoten · 2.1 Cluster · Phase 4 (Benutzer, 2FA, Benachrichtigungen, Firewall, Pools, Zertifikat, Update-Routine) · 3.1/3.2 ops-LXC und GPU-VMs auf `local-lvm` (vorerst DHCP) · 3.3 Benchmark | 1.2 `tank` anlegen · 2.2 Replikation/HA · 5.2 Infra-LXC mit HA (alternativ zuerst auf `local-lvm`, später verschieben) · 5.3 Kiosk-Anzeige · Phase 8 GRP-Test |
Task 1.2 läuft am besten **nach** dem Cluster (2.1): Dann wird `tank` einmal für alle drei Knoten angelegt (`pvesm add … --nodes pve44,pve48,pve52`), statt die Speicherdefinition beim Beitritt zu überschreiben.
## RAM-Budget je Knoten ## RAM-Budget je Knoten
Bei einem Knoten mit 32 GB sind 6 GB fest belegt: 2 GB für den Host und 4 GB ZFS-ARC. Eine GPU-VM reserviert ihre 12 GB vollständig, Passthrough erlaubt kein Ballooning. Damit bleiben auf einem GPU-Knoten **14 GB** für Container und als Reserve für den Failover. Bei einem Knoten mit 32 GB sind 6 GB fest belegt: 2 GB für den Host und 4 GB ZFS-ARC. Eine GPU-VM reserviert ihre 12 GB vollständig, Passthrough erlaubt kein Ballooning. Damit bleiben auf einem GPU-Knoten **14 GB** für Container und als Reserve für den Failover.
@@ -83,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 | | `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 | | `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 | | `pbs` (VM) | 4 GB | pve52 |
| `grp-test` (LXC) | 4 GB | pve48 auf `tank`, Replikation nach pve44 | | `grp-test` (LXC) | 4 GB | pve48 auf `tank`, Replikation nach pve44 |
| pve61 | 8 GB gesamt | keine Gäste, nur Kiosk | | pve61 | 8 GB gesamt | keine Gäste, nur Kiosk |
@@ -116,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“ **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. - [ ] 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. Prüfung: `ping` auf jede Adresse des Blocks → keine Antwort. DHCP-Ausschluss sichtbar.
@@ -125,8 +144,10 @@ Ersetzt PLAN §8 „0 Fundament“ (Teil Inventar) und IMPLEMENTATION „Gemeins
**Wo:** Gespräch mit dem Nutzer **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. - [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).
- [ ] D2–D5 bestätigen. Ergebnis als Abschnitt „Entscheidungen 2026-10“ in `docs/PLAN.md` §9 eintragen. - [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). 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 ### Task 0.4: Site-Dateien, Ansible-Inventar und ADRs an die echte Hardware anpassen
@@ -138,15 +159,14 @@ Ersetzt PLAN §8 „0 Fundament“ (Teil Inventar) und IMPLEMENTATION „Gemeins
**Interfaces:** **Interfaces:**
- Produces: Proxmox-Knoten `pve44`, `pve46`, `pve48`, `pve52`, `pve61`, dazu die Inventar-Knoten `gode-pc-05` und `gode-pc-06` (Ada, ohne Proxmox, nur im Inventar). PCI-Mappings `gpu-m4000-pve44`, `gpu-p2000-pve44`, `gpu-m4000-pve46`, `gpu-p2000-pve46`, `gpu-m4000-pve52`, `gpu-m4000-pve48`, `gpu-p2000-pve48`. GPU-VMs `gpu-pve44` (vmid 244), `gpu-pve46` (246), `gpu-pve48` (248), `gpu-pve52` (252). Infra-LXC `infra` (vmid 101). Ansible-Gruppen `pve_all`, `pve_gpu_hosts`, `pve_kiosk`. - Produces: Proxmox-Knoten `pve44`, `pve46`, `pve48`, `pve52`, `pve61`, dazu die Inventar-Knoten `gode-pc-05` und `gode-pc-06` (Ada, ohne Proxmox, nur im Inventar). PCI-Mappings `gpu-m4000-pve44`, `gpu-p2000-pve44`, `gpu-m4000-pve46`, `gpu-p2000-pve46`, `gpu-m4000-pve52`, `gpu-m4000-pve48`, `gpu-p2000-pve48`. GPU-VMs `gpu-pve44` (vmid 244), `gpu-pve46` (246), `gpu-pve48` (248), `gpu-pve52` (252). Infra-LXC `infra` (vmid 101). Ansible-Gruppen `pve_all`, `pve_gpu_hosts`, `pve_kiosk`.
- [x] **Step 1: Failing test.** In `test_inventory_validate.py` den Test `test_site_knaster_matches_real_nodes` anlegen: - [ ] **Step 1: Failing test.** In `test_inventory_validate.py` den Test `test_site_knaster_matches_real_nodes` anlegen:
- Die Inventar-Knoten sind genau `{pve44, pve46, pve48, pve52, pve61, gode-pc-05, gode-pc-06}`. - Die Inventar-Knoten sind genau `{pve44, pve46, pve48, pve52, pve61, gode-pc-05, gode-pc-06}`.
- `proxmox.yaml` enthält keine IP aus `10.20.0.0/24`. - `proxmox.yaml` enthält keine IP aus `10.20.0.0/24`.
- Jedes `pci_mappings[*].node` und jedes `vms[*].node` liegt in `{pve44, pve46, pve48, pve52}`. - Jedes `pci_mappings[*].node` und jedes `vms[*].node` liegt in `{pve44, pve46, pve48, pve52}`.
- [x] **Step 2:** `make test` → FAIL (alte Annahmen `pve-ada`, `pve1`–`pve4`). - [ ] **Step 2:** `make test` → FAIL (alte Annahmen `pve-ada`, `pve1`–`pve4`).
- [x] **Step 3:** Site-Dateien umschreiben. Werte für IDs und Pfade aus `lspci -nn` und dem Healthcheck: M4000 `10de:13f1` auf `0000:01:00`, P2000 `10de:1c30` auf `0000:02:00`. Gateway `192.168.100.1`, DNS `192.168.100.120`, keine `vlan_svc`, Gast-IPs aus D5. Die M4000 bleiben `disabled: true` bis zum Benchmark-Tag (Task 3.3). Die Ada-Slots (`tier1-general`, `tier1-flex`, `tts-de-hq`) behalten vorerst ihre Engine. In `registry.yaml` entfallen bei ihnen nur `pve_node` und `vmid`. Die Umstellung auf die Worker-Registry folgt in Task 6.4. - [ ] **Step 3:** Site-Dateien umschreiben. Werte für IDs und Pfade aus `lspci -nn` und dem Healthcheck: M4000 `10de:13f1` auf `0000:01:00`, P2000 `10de:1c30` auf `0000:02:00`. Gateway `192.168.100.1`, DNS `192.168.100.120`, keine `vlan_svc`, Gast-IPs aus D5. Die M4000 bleiben `disabled: true` bis zum Benchmark-Tag (Task 3.3). Die Ada-Slots (`tier1-general`, `tier1-flex`, `tts-de-hq`) behalten vorerst ihre Engine. In `registry.yaml` entfallen bei ihnen nur `pve_node` und `vmid`. Die Umstellung auf die Worker-Registry folgt in Task 6.4.
- [x] **Step 4:** `make test && make validate` → PASS. `knaster placement plan --inventory sites/knaster/inventory.yaml` liefert eine Zuordnung ohne Fehler. - [ ] **Step 4:** `make test && make validate` → PASS. `knaster placement plan --inventory sites/knaster/inventory.yaml` liefert eine Zuordnung ohne Fehler.
- [x] **Step 5:** Commit `sites: Inventar und Zielbild an echte Knoten (pve44–pve61) angepasst; ADR-0001/0002/0006 ergänzt`. - [ ] **Step 5:** Commit `sites: Inventar und Zielbild an echte Knoten (pve44–pve61) angepasst; ADR-0001/0002/0006 ergänzt`.
- Ergebnis (13.10.): Zusätzlich angepasst wurden `platform/proxmox/tofu` (mehrere Karten pro VM, Speicher je Gast, `vlan_svc: null`), `playbooks/site.yml` (`pve-host` nur für `pve_gpu_hosts`), die Replikation in `pve-host` (mehrere Ziele) und `deploy/prometheus/prometheus.yml` (IP-Literale, keine Ada-Ziele). Neues Inventarfeld `cpu_roles: false` für die Ada-Rechner und pve61. Damit verplant das Placement dort keine Infra- und keine CPU-Rollen. Hinweis: Das Placement schlägt `infra` auf pve52 vor, weil dort keine Karte aktiv ist. Laut RAM-Budget steht `infra` auf pve44, so ist es in `proxmox.yaml` eingetragen. Nicht mit `tofu validate` und `ansible-lint` geprüft, beides ist lokal nicht installiert. Das wird im ops-LXC nachgeholt (Task 3.1).
--- ---
@@ -159,15 +179,16 @@ Ersetzt PLAN §8 „0 Fundament“ (Teil Inventar) und IMPLEMENTATION „Gemeins
**Interfaces:** **Interfaces:**
- Produces: `bootstrap-node.sh <knoten>`, idempotent. Ausgabe endet mit `BOOTSTRAP OK <hostname>` oder Exit ≠ 0. - 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 - 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`) - 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 - Enterprise-Quellen auf `Enabled: no` setzen, `proxmox.sources` mit `pve-no-subscription` anlegen, vorher sichern
- `apt-get update && apt-get -y full-upgrade` - `apt-get update && apt-get -y full-upgrade`
- Pakete `ethtool lm-sensors` installieren - Pakete `ethtool lm-sensors` installieren
- Prüfen: `timedatectl show -p NTPSynchronized` = `yes` - Prüfen: `timedatectl show -p NTPSynchronized` = `yes`
- [ ] **Step 2:** Zweiter Lauf gegen denselben Knoten → keine Änderung, wieder `BOOTSTRAP OK`. - [x] **Step 2:** Zweiter Lauf gegen denselben Knoten → keine Änderung, wieder `BOOTSTRAP OK` (pve44, 12.10., 17 s).
- [ ] **Step 3:** Commit. - [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) ### Task 1.2: Speicher (D1)
@@ -175,10 +196,20 @@ Ersetzt PLAN §8 „0 Fundament“ (Teil Inventar) und IMPLEMENTATION „Gemeins
**Variante B (Empfehlung) – remote, ohne Neuinstallation** **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. - [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:
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). | Knoten | `tank` auf | Systemplatte (unberührt) |
Prüfung: `zpool status tank` → `ONLINE`. `pvesm status` → `tank active` auf beiden Knoten. |---|---|---|
| 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** **Variante A – Neuinstallation mit ZFS**
@@ -214,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) **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`. - [x] Auf pve44: `pvecm create knaster --link0 192.168.100.210` (12.10.).
- [ ] 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] 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. 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 ### Task 2.2: HA-Regeln und Replikation
**Wo:** remote **Wo:** remote
- [ ] Node-Affinity-Regeln: Infra-Dienste nur auf pve44, pve48, pve52. **pve61** bekommt keine HA-Gäste, GPU-VMs bleiben an ihrem Knoten. - [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.
- [ ] Replikationspartner festlegen: Infra-LXC von seinem Knoten auf einen zweiten, `*/15`. - [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. Prüfung: `ha-manager rules config` bzw. `ha-manager groupconfig` zeigt die Regeln.
--- ---
@@ -235,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) **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. - [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`.
- [ ] `site.yml`: `pve-host` nur für `pve_gpu_hosts` (pve44, pve46, pve48, pve52). pve61 steht in `pve_kiosk` ohne VFIO und Blacklist. - [x] `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`. - [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`.
- [ ] `ansible-playbook playbooks/site.yml --limit pve_gpu_hosts --check`, danach der echte Lauf. Knoten **einzeln** neu starten. - [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`. 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 ### Task 3.2: Resource Mappings, Template und GPU-VMs
**Wo:** remote (ops-LXC) · **Planner:** „GPU-Knoten 3 …“ (Teil NVIDIA, GPU-Container) **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). - [x] PCI-Resource-Mappings aus `sites/knaster/proxmox.yaml` anlegen (Namen aus Task 0.4) – per tofu (`proxmox_hardware_mapping_pci`), 12.10.
- [ ] Ubuntu-24.04-Cloud-Template vmid 9000 auf jedem GPU-Knoten. - [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).
- [ ] `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] 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.
- [ ] `ansible-playbook … --limit gpu_vms` (Rollen `docker-host` und `gpu-node`, `driver_generation: legacy` = R580). - [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. 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) ### 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 **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. - [ ] 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). - [ ] 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. - [ ] 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. Prüfung: `make validate` → PASS, Placement ordnet jede aktive Rolle einer gemessenen Karte zu.
--- ---
@@ -268,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. **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`. - [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`.
- [ ] **4.2 2FA** („PVE-Config: Zwei-Faktor-Authentifizierung für Admin-Konten aktivieren“): TOTP für root@pam und Admin. Recovery-Keys offline ablegen. - [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.
- [ ] **4.3 Benachrichtigungen** („PVE-Config: Benachrichtigungen (Notification Center) einrichten“): SMTP-Ziel Büro-Postfach, Matcher für Backup, Fencing und Updates. Testnachricht kommt an. - [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.
- [ ] **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. - [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.
- [ ] **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. - [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.
- [ ] **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. - [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.
- [ ] **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.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).
--- ---
@@ -284,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) **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. - [ ] 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. - [ ] **Restore-Test:** Infra-LXC als Kopie zurückspielen.
Prüfung: Die Kopie startet, `docker ps` zeigt die Dienste. Prüfung: Die Kopie startet, `docker ps` zeigt die Dienste.
@@ -293,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` **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). - [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).
- [ ] 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`. - [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`).
- [ ] Prometheus-Ziele: `pve-exporter` (Token aus 4.1), `node-exporter` und `nvidia_gpu_exporter` in den GPU-VMs, Router. - [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).
- [ ] Grafana: Dashboards für Proxmox, Knoten, GPU (Community-JSON) und knaster. Konto `kiosk` mit Rolle Viewer. - [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.
- [ ] Alertmanager an den Router (Healer, Dry-Run) und an n8n `knaster-notify`. Der Workflow wird inaktiv importiert, dann aktiviert. - [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).
Prüfung: Grafana zeigt alle 5 Knoten und alle GPUs. Ein Test-Alert (`amtool alert add test`) erscheint im Kanal. - [ ] 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 ### Task 5.3: Kiosk auf pve61
@@ -314,8 +359,24 @@ Ersetzt GPU-Knoten 3 (Teil Cluster). Mit 5 Stimmen ist **kein QDevice** nötig.
Ersetzt PLAN §8 „1 Data Plane“. 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). - [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.
- [ ] **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.) 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.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“ - [ ] **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. - Offene Schritte der Worker-Registry v2 im Repo `mygodel/nex` abschließen.
@@ -326,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_to_tier0_when_list_empty`
- `test_worker_registry_slot_falls_back_when_list_unreachable` - `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. - 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.
--- ---
@@ -353,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). - „5 Lifecycle“: QLoRA-Training. Voraussetzung ist die Entscheidung über WSL2 bzw. vLLM auf den Ada-Rechnern nachts (D3, Folgeentscheidung).
- „6 Portabilität“ - „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. 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.
--- ---
@@ -377,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 | | 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 | | 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 | | 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 | | PVE-Config: Erst-Update aller Knoten (apt full-upgrade) | Task 1.1 | bleibt |
| Proxmox: Speicher aller Knoten richtig konfigurieren | D1, Task 1.2 | bleibt | | Proxmox: Speicher aller Knoten richtig konfigurieren | D1, Task 1.2 | bleibt |
@@ -398,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 | | 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 | | 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 | | 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 | | P1–P8 (NEX-Aufgaben) | Parallelspur | bleiben |
**Neu angelegt** (nur Schritte ohne bestehende Aufgabe, keine Duplikate): **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.
+5 -2
View File
@@ -1,5 +1,8 @@
[defaults] [defaults]
inventory = inventory.yml inventory = inventory.yml,inventory.generated.yml
roles_path = roles roles_path = roles
host_key_checking = True host_key_checking = True
stdout_callback = yaml callback_result_format = yaml
[inventory]
unparsed_is_failed = False
+29 -14
View File
@@ -1,5 +1,6 @@
# Hosts und IPs aus sites/knaster/proxmox.yaml (Aufbauplan Task 0.4). Ansible läuft im ops-LXC (Task 3.1). # Knoten und Gäste von knaster (Stand 12.10.2026). Läuft auf dem Steuerrechner ops.
# Gast-IPs: ANNAHME bis Task 0.2 (DHCP-Ausschluss .220–.239). # 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: all:
vars: vars:
knaster_site: knaster knaster_site: knaster
@@ -12,29 +13,43 @@ all:
pve46: {ansible_host: 192.168.100.209} pve46: {ansible_host: 192.168.100.209}
pve48: {ansible_host: 192.168.100.212} pve48: {ansible_host: 192.168.100.212}
pve52: {ansible_host: 192.168.100.207} pve52: {ansible_host: 192.168.100.207}
pve_kiosk: # keine VFIO-/NVIDIA-Sperre, iGPU bleibt beim Host (Kiosk) pve_kiosk:
hosts: hosts:
pve61: {ansible_host: 192.168.100.214} pve61: {ansible_host: 192.168.100.214}
vars: vars:
ansible_user: root ansible_user: root
gpu_vms: gpu_vms:
hosts: hosts: # k3s_node_labels: Knoten-Labels für die Platzierung von GPU-Pods (D6)
gpu-pve44: {ansible_host: 192.168.100.224} gpu-pve44: {driver_generation: legacy, k3s_node_labels: [knaster.gpu=true, knaster.gpu.m4000=1, knaster.gpu.p2000=1]}
gpu-pve46: {ansible_host: 192.168.100.226} gpu-pve46: {driver_generation: legacy, k3s_node_labels: [knaster.gpu=true, knaster.gpu.m4000=1, knaster.gpu.p2000=1]}
gpu-pve48: {ansible_host: 192.168.100.228} gpu-pve48: {driver_generation: legacy, k3s_node_labels: [knaster.gpu=true, knaster.gpu.m4000=1, knaster.gpu.p2000=1]}
gpu-pve52: {ansible_host: 192.168.100.232} gpu-pve52: {driver_generation: legacy, k3s_node_labels: [knaster.gpu=true, knaster.gpu.m4000=1]}
vars: vars:
ansible_user: knaster ansible_user: knaster
ansible_become: true ansible_become: true
driver_generation: legacy # R580, Maxwell/Pascal ansible_python_interpreter: /usr/bin/python3
knaster_role: gpu-llamacpp # 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: infra:
hosts: hosts:
infra: {ansible_host: 192.168.100.221} infra: {}
vars: vars:
ansible_user: root ansible_user: root
ops: # Dienst-VMs ohne GPU (D6): k3s-Steuerung. Adresse aus inventory.generated.yml (Gruppe service_vms).
service_vms:
hosts: hosts:
ops: {ansible_host: 192.168.100.220} k3s: {}
vars: vars:
ansible_user: root 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]
@@ -0,0 +1,13 @@
# NEX-Sprachdienste (Task 6.2): Erkennung (whisper.cpp, M4000) und Ausgabe (Piper, CPU) in gpu-pve52.
# Aufruf auf ops: ansible-playbook playbooks/nex-speech.yml
# Vorher Zugang auffrischen (DHCP): bash ../scripts/refresh-vm-access.sh
# Seit 15.10.2026 laufen die Dienste in k3s (playbooks/nex-speech-k3s.yml, D6). Dieses Playbook würde
# den Compose-Stack wieder starten und die Host-Ports 8178/8179 belegen. Nur mit -e nex_speech_compose=true.
- name: NEX-Sprachdienste (Docker Compose, abgelöst)
hosts: "{{ nex_speech_hosts | default('gpu-pve52') }}"
pre_tasks:
- name: Abgelöst durch k3s
ansible.builtin.fail:
msg: "Die NEX-Sprachdienste laufen in k3s (playbooks/nex-speech-k3s.yml). Compose nur mit -e nex_speech_compose=true."
when: not (nex_speech_compose | default(false) | bool)
roles: [nex-speech]
@@ -0,0 +1,108 @@
# Portainer CE in k3s mit der Datenbank von appsrv08 (Portainer EE 2.45.1). Läuft auf ops:
# ansible-playbook playbooks/portainer-k3s.yml
#
# Voraussetzung: Sicherung des Docker-Volumes portainer_data von appsrv08 als /root/.config/knaster/portainer_data.tgz
# (Inhalt: data/portainer.db, data/portainer.key, …; erzeugt mit
# docker run --rm -v portainer_data:/data:ro alpine tar -C / -czf - data > portainer_data.tgz ).
# Beim ersten Lauf wird das Volume befüllt und die Datenbank mit dem offiziellen Werkzeug der EE
# (`--rollback-to-ce`) auf Community Edition zurückgesetzt; danach bleibt der Datenstand im Cluster.
# Benutzer, API-Schlüssel und Umgebungen bleiben erhalten (getestet 15.10.2026). Die Umgebung 4 (Docker-Socket
# von appsrv08) funktioniert im Cluster nicht mehr; appsrv08 wird als Agent-Umgebung neu angebunden.
- name: Portainer CE nach k3s
hosts: localhost
connection: local
gather_facts: false
vars:
manifest: "{{ playbook_dir }}/../../../../deploy/k8s/portainer/portainer.yaml"
backup: /root/.config/knaster/portainer_data.tgz
ns: portainer-ce
version: 2.45.1
tasks:
- name: Namespace, PVC, RBAC (noch ohne Deployment, damit das Volume zuerst befüllt wird)
ansible.builtin.shell: >
set -o pipefail;
python3 -c "import sys,yaml; docs=[d for d in yaml.safe_load_all(open('{{ manifest }}')) if d and d['kind']!='Deployment' and d['kind']!='Service']; yaml.safe_dump_all(docs, sys.stdout)"
| kubectl apply -f -
args: {executable: /bin/bash}
register: base
changed_when: "'created' in base.stdout"
- name: Ist das Volume schon befüllt?
ansible.builtin.command: kubectl -n {{ ns }} get configmap portainer-seeded
register: seeded
failed_when: false
changed_when: false
- name: Sicherung vorhanden?
ansible.builtin.stat:
path: "{{ backup }}"
register: bk
when: seeded.rc != 0
failed_when: not bk.stat.exists
- name: Volume aus der Sicherung befüllen (Hilfs-Pod auf dem Knoten k3s)
ansible.builtin.shell: |
set -e
kubectl -n {{ ns }} delete pod seed --ignore-not-found >/dev/null
cat <<EOF | kubectl apply -f - >/dev/null
apiVersion: v1
kind: Pod
metadata: {name: seed, namespace: {{ ns }}}
spec:
nodeSelector: {knaster.role: control}
restartPolicy: Never
containers:
- name: seed
image: alpine:3.20
command: ["sleep", "600"]
volumeMounts: [{name: data, mountPath: /data}]
volumes: [{name: data, persistentVolumeClaim: {claimName: portainer-data}}]
EOF
kubectl -n {{ ns }} wait --for=condition=Ready pod/seed --timeout=180s >/dev/null
kubectl -n {{ ns }} cp {{ backup }} seed:/tmp/pd.tgz
kubectl -n {{ ns }} exec seed -- sh -c "cd /tmp && tar xzf pd.tgz && cp -a data/. /data/ && test -s /data/portainer.db"
kubectl -n {{ ns }} delete pod seed --wait=true >/dev/null
when: seeded.rc != 0
- name: Datenbank auf Community Edition zurücksetzen (offizielles Werkzeug der EE, fragt per stdin)
ansible.builtin.shell: |
set -e
kubectl -n {{ ns }} delete pod rollback --ignore-not-found >/dev/null
echo y | kubectl -n {{ ns }} run rollback -i --restart=Never --image=portainer/portainer-ee:{{ version }} \
--overrides='{"spec":{"nodeSelector":{"knaster.role":"control"},"containers":[{"name":"rollback","image":"portainer/portainer-ee:{{ version }}","args":["--rollback-to-ce"],"stdin":true,"volumeMounts":[{"name":"data","mountPath":"/data"}]}],"volumes":[{"name":"data","persistentVolumeClaim":{"claimName":"portainer-data"}}]}}' >/dev/null 2>&1 || true
sleep 5
kubectl -n {{ ns }} logs rollback | grep -q "rolled back to CE Edition"
kubectl -n {{ ns }} delete pod rollback --wait=false >/dev/null
kubectl -n {{ ns }} create configmap portainer-seeded --from-literal=source={{ backup | basename }} --from-literal=date="$(date -Is)"
when: seeded.rc != 0
- name: Deployment und Service
ansible.builtin.command: kubectl apply -f {{ manifest }}
register: dep
changed_when: "'configured' in dep.stdout or 'created' in dep.stdout"
- name: Warten, bis bereit
ansible.builtin.command: kubectl -n {{ ns }} rollout status deployment/portainer --timeout=300s
changed_when: false
- name: Version über NodePort (Knoten k3s, ohne Anmeldung)
ansible.builtin.uri:
url: "https://{{ hostvars['k3s'].ansible_host }}:30779/api/system/status"
validate_certs: false
return_content: true
register: ver
until: ver.status == 200
retries: 6
delay: 5
- name: Edition aus dem Startprotokoll (CE-Binary meldet sich als portainer/portainer, EE als portainer-ee)
ansible.builtin.shell: kubectl -n {{ ns }} logs deployment/portainer | grep -o 'image_tag=[^ ]*' | tail -1
register: tag
changed_when: false
- name: Ergebnis
ansible.builtin.assert:
that:
- ver.json.Version == version
- "'ee' not in tag.stdout"
success_msg: "Portainer CE {{ ver.json.Version }} ({{ tag.stdout }}) unter https://{{ hostvars['k3s'].ansible_host }}:30779"
@@ -0,0 +1,71 @@
# RustDesk-Server (hbbs/hbbr) in k3s, Umzug vom Portainer-Stack „rustdesk“ auf appsrv08 (15.10.2026).
# Läuft auf ops: ansible-playbook playbooks/rustdesk-k3s.yml [-e rustdesk_public_host=<Adresse für Clients>]
#
# Voraussetzung: Schlüsselpaar des bisherigen Servers unter /root/.config/knaster/rustdesk/{id_ed25519,id_ed25519.pub}
# auf ops (aus dem Docker-Volume rustdesk_rustdesk-data von appsrv08, Pfad /root). Damit bleiben die in den
# Clients hinterlegten Public Keys gültig. Die Clients selbst zeigen weiter auf appsrv08, bis ihre
# Server-Adresse umgestellt wird (Installer-Name im Stack rustdesk-updater und bestehende Installationen).
- name: RustDesk nach k3s
hosts: localhost
connection: local
gather_facts: false
vars:
manifest: "{{ playbook_dir }}/../../../../deploy/k8s/rustdesk/rustdesk.yaml"
key_dir: /root/.config/knaster/rustdesk
# Adresse, die hbbs den Clients als Relay nennt (Port 21117). Standard: die k3s-VM; ServiceLB stellt die Ports
# auf jeder Knoten-Adresse bereit. Nach DHCP-Reservierung/DNS-Name hier bzw. per -e anpassen.
rustdesk_public_host: "{{ hostvars['k3s'].ansible_host }}"
tasks:
- name: Schlüsseldateien vorhanden?
ansible.builtin.stat:
path: "{{ key_dir }}/{{ item }}"
loop: [id_ed25519, id_ed25519.pub]
register: keys
failed_when: not keys.stat.exists
- name: Namespace
ansible.builtin.shell: >
kubectl create namespace rustdesk --dry-run=client -o yaml | kubectl apply -f -
register: ns
changed_when: "'created' in ns.stdout"
- name: Secret rustdesk-keys aus den Schlüsseldateien
ansible.builtin.shell: >
kubectl -n rustdesk create secret generic rustdesk-keys
--from-file=id_ed25519={{ key_dir }}/id_ed25519 --from-file=id_ed25519.pub={{ key_dir }}/id_ed25519.pub
--dry-run=client -o yaml | kubectl apply -f -
register: secret
changed_when: "'configured' in secret.stdout or 'created' in secret.stdout"
no_log: true
- name: Manifest mit Relay-Adresse anwenden
ansible.builtin.shell: >
sed 's/__RELAY_HOST__/{{ rustdesk_public_host }}/' {{ manifest }} | kubectl apply -f -
register: dep
changed_when: "'configured' in dep.stdout or 'created' in dep.stdout"
- name: Neustart, wenn sich nur das Secret geändert hat
ansible.builtin.command: kubectl -n rustdesk rollout restart deployment/rustdesk
when: secret.changed and not dep.changed
- name: Warten, bis bereit
ansible.builtin.command: kubectl -n rustdesk rollout status deployment/rustdesk --timeout=300s
changed_when: false
- name: Public Key im Cluster entspricht der Datei (Image hat keine Shell, daher über das Secret)
ansible.builtin.shell: >
set -o pipefail;
kubectl -n rustdesk get secret rustdesk-keys -o go-template='{{ "{{" }}index .data "id_ed25519.pub"{{ "}}" }}' | base64 -d | cmp - {{ key_dir }}/id_ed25519.pub
args: {executable: /bin/bash}
changed_when: false
- name: Ports von außen erreichbar (ID-Server 21116/tcp, Relay 21117/tcp auf {{ rustdesk_public_host }})
ansible.builtin.wait_for:
host: "{{ rustdesk_public_host }}"
port: "{{ item }}"
timeout: 30
loop: [21115, 21116, 21117, 21118, 21119]
- name: Ergebnis
ansible.builtin.debug:
msg: "RustDesk in k3s bereit: ID-Server/Relay {{ rustdesk_public_host }}, Public Key {{ lookup('file', key_dir + '/id_ed25519.pub') }}"
+20 -6
View File
@@ -1,13 +1,27 @@
# Reihenfolge: GPU-Knoten vorbereiten → (tofu apply) → GPU-VMs → Infra # Reihenfolge: GPU-Knoten vorbereiten → Knoten einzeln neu starten → (tofu apply) → GPU-VMs → Infra
# pve61 (pve_kiosk) bekommt bewusst keine Host-Rolle: keine VFIO-Bindung, keine NVIDIA-Sperre (Review Focus 5). # pve61 (pve_kiosk) bekommt kein VFIO und keine NVIDIA-Sperre, seine iGPU bleibt für den Kiosk.
- name: Proxmox-GPU-Knoten (IOMMU, VFIO, ZFS-Replikation) - name: GPU-Knoten (IOMMU, VFIO, NVIDIA auf dem Host gesperrt, Replikation)
hosts: pve_gpu_hosts hosts: pve_gpu_hosts
roles: [pve-host] roles: [pve-host]
- name: GPU-VMs (Treiber gepinnt, Container-Toolkit, Docker, Xid-Exporter) - name: GPU-VMs (Treiber gepinnt, Container-Toolkit, Docker, Xid-Exporter)
hosts: gpu_vms hosts: gpu_vms
roles: [docker-host, gpu-node] pre_tasks:
# DHCP-Client-ID = MAC statt DUID: sonst greifen Reservierungen über die MAC nicht und die
# Adressen wechseln beim Neustart (12.10. beobachtet). Wirkt ab dem nächsten Neustart der VM.
- name: DHCP-Client-ID auf MAC umstellen
ansible.builtin.copy:
dest: /etc/netplan/99-knaster-dhcp-id.yaml
content: |
network:
version: 2
ethernets:
eth0:
dhcp-identifier: mac
mode: "0600"
roles: [docker-host, gpu-node, gpu-exporters]
- name: Infra-LXC (Docker) # Nur Monitoring ohne Treiber/Neustart der GPU-VMs: playbooks/monitoring.yml
- name: Infra-LXC (Docker, Compose-Stack mit Monitoring)
hosts: infra hosts: infra
roles: [docker-host] roles: [docker-host, infra-stack]
@@ -1,3 +1,4 @@
collections: collections:
- name: ansible.posix - name: ansible.posix
- name: community.general - name: community.general
- name: community.docker
@@ -1,6 +1,7 @@
- name: Docker aus Distribution installieren - name: Docker aus Distribution installieren
ansible.builtin.apt: ansible.builtin.apt:
name: [docker.io, docker-compose-v2] # Compose v2 heißt unter Ubuntu docker-compose-v2, unter Debian 13 docker-compose
name: [docker.io, "{{ 'docker-compose-v2' if ansible_distribution == 'Ubuntu' else 'docker-compose' }}"]
state: present state: present
update_cache: true update_cache: true
@@ -0,0 +1,10 @@
# Monitoring-Exporter in den GPU-VMs (Task 5.2). Ziele erzeugt monitoring-targets.py als file_sd.
# node_exporter aus Ubuntu (24.04: 1.7.x), liest die Xid-Zähler aus dem Textfile-Verzeichnis (Rolle gpu-node)
gpu_exporters_textfile_dir: /var/lib/node_exporter/textfile
# nvidia_gpu_exporter (nvidia-smi statt DCGM/NVML: läuft auch mit Maxwell/Pascal), Port 9835
nvidia_gpu_exporter_version: "1.15.1"
# sha256 aus checksums.txt des Releases v1.15.1 (am 12.10.2026 gegen die Datei geprüft)
nvidia_gpu_exporter_sha256: 0dc8c2756c6853ec7e6a7c404b229d8f764443bef89f33e13b3c4278b309acd4
# ufw: nur eigene allow-Regeln hinzufügen (Kommentar knaster-monitoring), ufw nie ein- oder ausschalten
gpu_exporters_allow_from: 192.168.100.0/24
gpu_exporters_ports: [9100, 9835]
@@ -0,0 +1,4 @@
- name: restart node-exporter
ansible.builtin.service:
name: prometheus-node-exporter
state: restarted
@@ -0,0 +1,60 @@
# Bewusst getrennt von gpu-node: kein Treiber, kein Docker-Neustart, kein Reboot.
- name: node_exporter installieren (ohne Zusatz-Collectors)
ansible.builtin.apt:
name: prometheus-node-exporter
state: present
install_recommends: false
update_cache: true
cache_valid_time: 3600
- name: Textfile-Verzeichnis (Xid-Zähler aus gpu-xid-textfile.sh)
ansible.builtin.file:
path: "{{ gpu_exporters_textfile_dir }}"
state: directory
mode: "0755"
- name: node_exporter liest das Textfile-Verzeichnis
ansible.builtin.lineinfile:
path: /etc/default/prometheus-node-exporter
regexp: '^ARGS='
line: 'ARGS="--collector.textfile.directory={{ gpu_exporters_textfile_dir }}"'
notify: restart node-exporter
- name: node_exporter aktiv
ansible.builtin.service:
name: prometheus-node-exporter
state: started
enabled: true
- name: nvidia_gpu_exporter herunterladen (Prüfsumme fest)
ansible.builtin.get_url:
url: "https://github.com/utkuozdemir/nvidia_gpu_exporter/releases/download/v{{ nvidia_gpu_exporter_version }}/nvidia-gpu-exporter_{{ nvidia_gpu_exporter_version }}_linux_amd64.deb"
dest: "/var/cache/apt/archives/nvidia-gpu-exporter_{{ nvidia_gpu_exporter_version }}_linux_amd64.deb"
checksum: "sha256:{{ nvidia_gpu_exporter_sha256 }}"
mode: "0644"
- name: nvidia_gpu_exporter installieren
ansible.builtin.apt:
deb: "/var/cache/apt/archives/nvidia-gpu-exporter_{{ nvidia_gpu_exporter_version }}_linux_amd64.deb"
- name: nvidia_gpu_exporter aktiv
ansible.builtin.service:
name: nvidia_gpu_exporter
state: started
enabled: true
- name: Ist ufw vorhanden?
ansible.builtin.stat:
path: /usr/sbin/ufw
register: ufw_bin
# Regeln wirken auch bei inaktivem ufw (werden vorgemerkt). Fremde Regeln bleiben unberührt.
- name: ufw – Exporter-Ports aus dem LAN erlauben
community.general.ufw:
rule: allow
src: "{{ gpu_exporters_allow_from }}"
port: "{{ item | string }}"
proto: tcp
comment: knaster-monitoring
loop: "{{ gpu_exporters_ports }}"
when: ufw_bin.stat.exists
@@ -0,0 +1,83 @@
# llama.cpp-Rollen in den GPU-VMs (Task 6.1): Compose aus deploy/compose/gpu-llamacpp.
# Profile je VM kommen aus dem Inventar (Gruppe gpu_llamacpp, Variable llamacpp_profiles).
llamacpp_dir: /opt/knaster/gpu-llamacpp
llamacpp_models_dir: /srv/models
llamacpp_profiles: []
# Karte je Dienst, gewählt über den Namen aus `nvidia-smi` (ADR-0006: alles auf die P2000, M4000 bleibt frei).
# Passt Rerank nicht neben Embedding auf die P2000, hier "Quadro M4000" eintragen.
llamacpp_cards:
EMBED_GPU: Quadro P2000
RERANK_GPU: Quadro P2000
CHAT_GPU: Quadro P2000
# Dienste und Ports je Profil (feste Ports im Compose, Host-Netz)
llamacpp_services:
embed:
- {name: llamacpp-embed, port: 8081}
- {name: llamacpp-rerank, port: 8082}
chat:
- {name: llamacpp-chat, port: 8080}
# ufw: nur Regeln vormerken (Kommentar knaster-llamacpp), ufw nie ein- oder ausschalten (Firewall: Task 4.4)
llamacpp_allow_from: 192.168.100.0/24
# Werte für .env (keine Geheimnisse); Build-Stand wie im Benchmark (ADR-0006)
llamacpp_env:
LLAMACPP_REF: b6000
LLAMACPP_COMMIT: 4762ad7316dcdec20016ab5985fb46a27902204d
CUDA_VERSION: 12.4.0
CUDA_DOCKER_ARCH: "52;61"
MODELS_DIR: "{{ llamacpp_models_dir }}"
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
# Modelle je Profil mit fester Revision und Prüfsumme (sha256 = LFS-OID laut Hugging Face, ADR-0006)
_hf: https://huggingface.co
llamacpp_models:
embed:
- dest: Qwen3-Embedding-0.6B-Q8_0.gguf
url: "{{ _hf }}/Qwen/Qwen3-Embedding-0.6B-GGUF/resolve/370f27d7550e0def9b39c1f16d3fbaa13aa67728/Qwen3-Embedding-0.6B-Q8_0.gguf"
checksum: sha256:06507c7b42688469c4e7298b0a1e16deff06caf291cf0a5b278c308249c3e439
- dest: bge-reranker-v2-m3-Q8_0.gguf
url: "{{ _hf }}/gpustack/bge-reranker-v2-m3-GGUF/resolve/3093af03b1a635e67b084b1d8c03c5f5e020fd05/bge-reranker-v2-m3-Q8_0.gguf"
checksum: sha256:a43c7c9b11a4c1517e5bf95151960e1621d1b72f7a493364b01e386cf1aaa1d3
chat:
- dest: Qwen3-4B-Q4_K_M.gguf
url: "{{ _hf }}/Qwen/Qwen3-4B-GGUF/resolve/bc640142c66e1fdd12af0bd68f40445458f3869b/Qwen3-4B-Q4_K_M.gguf"
checksum: sha256:7485fe6f11af29433bc51cab58009521f205840f5b4ae3a32fa7f92e8534fdf5
# --- k3s (Task 6.1, D6): tasks/k3s.yml, Playbook playbooks/gpu-llamacpp-k3s.yml ---
llamacpp_image: "knaster/llamacpp-server:{{ llamacpp_env.LLAMACPP_REF }}-cu{{ llamacpp_env.CUDA_VERSION }}"
llamacpp_k3s_template: "{{ playbook_dir }}/../../../../deploy/k8s/llamacpp/llamacpp.yaml.j2"
llamacpp_k3s_namespace: "{{ playbook_dir }}/../../../../deploy/k8s/llamacpp/namespace.yaml"
# Argumente wie im Compose (deploy/compose/gpu-llamacpp); gpu_var wählt die Karte aus llamacpp_cards
llamacpp_k3s_services:
embed:
- name: llamacpp-embed
port: 8081
gpu_var: EMBED_GPU
memory_limit: 3Gi
# -ub 4096 statt 8192: Der Rechenpuffer wächst mit ub × Vokabular (151k) und bräuchte bei 8192 allein 5 GB
# (19.10. gemessen). Mit 4096: 2,5 GB Puffer, P2000 mit Rerank 4,4–4,6 von 5 GB, 5.467 Tokens in einem Aufruf ok.
args: ["-m", "/models/{{ llamacpp_env.EMBED_GGUF }}", "--embedding", "--pooling", "{{ llamacpp_env.EMBED_POOLING }}",
"-c", "8192", "-b", "4096", "-ub", "4096", "-np", "1", "-ngl", "99", "--alias", "embed-default",
"--metrics", "--no-slots", "--no-webui", "--host", "0.0.0.0", "--port", "8081"]
- name: llamacpp-rerank
port: 8082
gpu_var: RERANK_GPU
memory_limit: 3Gi
args: ["-m", "/models/{{ llamacpp_env.RERANK_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"]
chat:
- name: llamacpp-chat
port: 8080
gpu_var: CHAT_GPU
memory_limit: 6Gi
args: ["-m", "/models/{{ llamacpp_env.CHAT_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"]
@@ -0,0 +1,74 @@
# llama.cpp in k3s statt Compose (Task 6.1, D6). Aufruf über playbooks/gpu-llamacpp-k3s.yml (je VM nacheinander).
# Rückweg je VM: kubectl -n knaster delete deploy llamacpp-<vm>, dann in der VM
# docker compose --project-directory /opt/knaster/gpu-llamacpp start
- name: Profile, Karten, Modelle
ansible.builtin.include_tasks: prepare.yml
# 1. Image aus Docker in das containerd von k3s übernehmen (kein Neubau, kein Registry-Umweg)
- name: Image in k3s vorhanden?
ansible.builtin.command: /usr/local/bin/k3s ctr images ls -q
register: llamacpp_k3s_images
changed_when: false
- name: Image aus Docker importieren
ansible.builtin.shell: set -o pipefail; docker save {{ llamacpp_image }} | /usr/local/bin/k3s ctr images import -
args:
executable: /bin/bash
when: ("docker.io/" ~ llamacpp_image) not in llamacpp_k3s_images.stdout
async: 1800
poll: 15
# 2. Compose-Stack anhalten (Host-Ports und VRAM werden frei), Dateien bleiben für den Rückweg
- name: Compose-Projekt vorhanden?
ansible.builtin.stat:
path: "{{ llamacpp_dir }}/docker-compose.yml"
register: llamacpp_compose_file
- name: Compose-Stack anhalten
community.docker.docker_compose_v2:
project_src: "{{ llamacpp_dir }}"
state: stopped
when: llamacpp_compose_file.stat.exists
# 3. Manifest je VM rendern (UUID der P2000) und anwenden
- name: Namespace knaster
ansible.builtin.command: kubectl apply -f {{ llamacpp_k3s_namespace }}
register: llamacpp_ns
changed_when: "'created' in llamacpp_ns.stdout"
delegate_to: localhost
become: false
- name: Deployment anwenden
ansible.builtin.command:
cmd: kubectl apply -f -
stdin: "{{ lookup('template', llamacpp_k3s_template) }}"
vars:
llamacpp_k3s_containers: "{{ llamacpp_profiles | map('extract', llamacpp_k3s_services) | flatten }}"
register: llamacpp_apply
changed_when: "'configured' in llamacpp_apply.stdout or 'created' in llamacpp_apply.stdout"
delegate_to: localhost
become: false
- name: Warten, bis der Pod bereit ist
ansible.builtin.command: kubectl -n knaster rollout status deployment/llamacpp-{{ inventory_hostname }} --timeout=600s
changed_when: false
delegate_to: localhost
become: false
# 4. Prüfen in der VM: Dienste gesund, Prozesse nur auf der P2000
- name: Gesundheit in der VM prüfen
ansible.builtin.uri:
url: "http://127.0.0.1:{{ item.port }}/health"
status_code: 200
loop: "{{ llamacpp_profiles | map('extract', llamacpp_k3s_services) | flatten }}"
loop_control:
label: "{{ item.name }}:{{ item.port }}"
- name: Belegung der Karten
ansible.builtin.command: nvidia-smi --query-gpu=index,name,memory.used,memory.total --format=csv,noheader
register: llamacpp_vram
changed_when: false
- name: Ergebnis
ansible.builtin.debug:
msg: "{{ llamacpp_vram.stdout_lines }}"
@@ -0,0 +1,76 @@
# llama.cpp-Server je Profil (Task 6.1). Idempotent: Modelle per Prüfsumme, Image nur bauen, wenn es fehlt
# (Image-Tag enthält Stand und CUDA-Version), compose ändert nur, was sich geändert hat.
- name: Profile, Karten, Modelle
ansible.builtin.include_tasks: prepare.yml
# Compose baut nur mit buildx (BuildKit, auch für den Git-Kontext mit Commit-Pin)
- name: docker-buildx installieren
ansible.builtin.apt:
name: docker-buildx
state: present
# --- Compose-Projekt ---
- name: Projektverzeichnis
ansible.builtin.file:
path: "{{ llamacpp_dir }}"
state: directory
mode: "0755"
- name: Compose-Datei kopieren
ansible.builtin.copy:
src: "{{ playbook_dir }}/../../../../deploy/compose/gpu-llamacpp/docker-compose.yml"
dest: "{{ llamacpp_dir }}/docker-compose.yml"
mode: "0644"
- name: .env schreiben (keine Geheimnisse)
ansible.builtin.copy:
dest: "{{ llamacpp_dir }}/.env"
mode: "0644"
content: |
# Verwaltet von Ansible (Rolle gpu-llamacpp)
COMPOSE_PROFILES={{ llamacpp_profiles | join(',') }}
{% for k, v in (llamacpp_env | combine(llamacpp_gpu_env)).items() %}
{{ k }}={{ v }}
{% endfor %}
# ufw-Regeln wirken auch bei inaktivem ufw (werden vorgemerkt). Fremde Regeln bleiben unberührt.
- name: Ist ufw vorhanden?
ansible.builtin.stat:
path: /usr/sbin/ufw
register: llamacpp_ufw
- name: ufw – llama.cpp-Ports aus dem LAN vormerken
community.general.ufw:
rule: allow
src: "{{ llamacpp_allow_from }}"
port: "{{ item.port | string }}"
proto: tcp
comment: knaster-llamacpp
loop: "{{ llamacpp_profiles | map('extract', llamacpp_services) | flatten }}"
loop_control:
label: "{{ item.name }}:{{ item.port }}"
when: llamacpp_ufw.stat.exists
# Der erste Build dauert lange (llama.cpp mit CUDA, sm_52/61, alle CPU-Varianten auf 4 vCPU)
- name: Image bauen (falls es fehlt) und Dienste starten
community.docker.docker_compose_v2:
project_src: "{{ llamacpp_dir }}"
profiles: "{{ llamacpp_profiles }}"
build: policy
state: present
remove_orphans: true
wait: false
async: 7200
poll: 30
- name: Warten, bis alle Dienste gesund sind
ansible.builtin.uri:
url: "http://127.0.0.1:{{ item.port }}/health"
status_code: 200
register: llamacpp_health
until: llamacpp_health.status == 200
retries: 60
delay: 10
loop: "{{ llamacpp_profiles | map('extract', llamacpp_services) | flatten }}"
loop_control:
label: "{{ item.name }}:{{ item.port }}"
@@ -0,0 +1,38 @@
# Gemeinsam für Compose (main.yml) und k3s (k3s.yml): Profile prüfen, P2000 per Name finden, Modelle laden.
- name: Profile prüfen
ansible.builtin.assert:
that:
- llamacpp_profiles | length > 0
- llamacpp_profiles | difference(llamacpp_services.keys() | list) | length == 0
fail_msg: "llamacpp_profiles muss aus {{ llamacpp_services.keys() | list }} bestehen (Inventar, Gruppe gpu_llamacpp)"
# --- Kartenwahl: UUID statt Index, geprüft über den Kartennamen ---
- name: Karten in der VM lesen
ansible.builtin.command: nvidia-smi --query-gpu=index,uuid,name --format=csv,noheader
register: llamacpp_smi
changed_when: false
- name: UUID je Dienst aus dem Kartennamen bestimmen
ansible.builtin.set_fact:
llamacpp_gpu_env: >-
{{ llamacpp_gpu_env | default({}) | combine({item.key: (llamacpp_smi.stdout_lines
| map('split', ', ') | selectattr(2, 'equalto', item.value) | map(attribute=1) | first | default(''))}) }}
loop: "{{ llamacpp_cards | dict2items }}"
- name: Jede gewünschte Karte ist vorhanden
ansible.builtin.assert:
that: llamacpp_gpu_env[item.key] is match('GPU-')
fail_msg: "{{ item.value }} für {{ item.key }} nicht gefunden: {{ llamacpp_smi.stdout_lines }}"
loop: "{{ llamacpp_cards | dict2items }}"
# --- Modelle ---
- name: Modelle laden (Prüfsumme gegen Hugging Face)
ansible.builtin.get_url:
url: "{{ item.url }}"
dest: "{{ llamacpp_models_dir }}/{{ item.dest }}"
checksum: "{{ item.checksum }}"
mode: "0644"
timeout: 120
loop: "{{ llamacpp_profiles | map('extract', llamacpp_models) | flatten }}"
loop_control:
label: "{{ item.dest }}"
@@ -1,7 +1,3 @@
- name: configure nvidia runtime
ansible.builtin.command: nvidia-ctk runtime configure --runtime=docker
notify: restart docker
- name: restart docker - name: restart docker
ansible.builtin.service: ansible.builtin.service:
name: docker name: docker
@@ -30,12 +30,20 @@
creates: /etc/apt/sources.list.d/nvidia-container-toolkit.list creates: /etc/apt/sources.list.d/nvidia-container-toolkit.list
executable: /bin/bash executable: /bin/bash
- name: Container-Toolkit installieren und für Docker konfigurieren - name: Container-Toolkit installieren
ansible.builtin.apt: ansible.builtin.apt:
name: nvidia-container-toolkit name: nvidia-container-toolkit
state: present state: present
update_cache: true update_cache: true
notify: configure nvidia runtime
# Als Aufgabe statt Handler: Ein Handler ging verloren, wenn der Lauf beim Neustart abbrach
- name: NVIDIA-Runtime in Docker eintragen
ansible.builtin.shell: |
grep -q '"nvidia"' /etc/docker/daemon.json 2>/dev/null && exit 0
nvidia-ctk runtime configure --runtime=docker && echo geändert
register: nvidia_runtime
changed_when: "'geändert' in nvidia_runtime.stdout"
notify: restart docker
- name: Modell-Disk (scsi1) formatieren, falls leer (formatiert nie eine vorhandene Partition um) - name: Modell-Disk (scsi1) formatieren, falls leer (formatiert nie eine vorhandene Partition um)
community.general.filesystem: community.general.filesystem:
@@ -79,6 +87,9 @@
state: started state: started
daemon_reload: true daemon_reload: true
- name: Handler vor dem Neustart ausführen
ansible.builtin.meta: flush_handlers
- name: Neustart nach Treiberinstallation - name: Neustart nach Treiberinstallation
ansible.builtin.reboot: ansible.builtin.reboot:
when: driver.changed when: driver.changed
@@ -0,0 +1,33 @@
# Compose-Stack im LXC infra (Task 5.2). Repo-Auszug und Zustand liegen getrennt:
# infra_repo_dir Kopie der nötigen Repo-Teile von ops (wird bei jedem Lauf abgeglichen, --delete)
# infra_state_dir .env (0600, Geheimnisse), secrets/, file_sd/ – nie im Repo, nie überschrieben
infra_repo_src: "{{ playbook_dir }}/../../../.."
infra_repo_dir: /opt/knaster/repo
infra_state_dir: /opt/knaster/infra
infra_compose_project: knaster-infra
infra_compose_file: "{{ infra_repo_dir }}/deploy/compose/infra/docker-compose.yml"
# Einstellungen in .env (keine Geheimnisse). Änderungen hier, nicht im Container.
infra_settings:
KNASTER_VERSION: dev
KNASTER_SITE: "{{ knaster_site }}"
KNASTER_STATE_DIR: "{{ infra_state_dir }}"
KNASTER_DRY_RUN: "true" # Global Constraint: erst nach einer Woche Beobachtung einzeln freischalten
KNASTER_CANARY_INTERVAL: "60"
# D4: LiteLLM mit PSE auf appsrv08. TODO (Nutzer, Task 6.3): Port ist dort noch nicht im LAN veröffentlicht.
LITELLM_BASE_URL: http://192.168.100.29:4000
# Geheimnisse: werden im Container einmalig mit `openssl rand -hex 24` erzeugt und danach nie geändert
# (Postgres- und Grafana-Admin-Passwort wirken nur bei der Erstinitialisierung).
infra_secrets: [POSTGRES_PASSWORD, KNASTER_API_TOKEN, GRAFANA_ADMIN_PASSWORD, GRAFANA_KIOSK_PASSWORD]
# Lese-Token monitoring@pve!prom (PVEAuditor) auf ops
infra_pve_exporter_env: /root/.config/knaster/pve-exporter.env
# Lesezugang zum kubelet für Prometheus (Task 6.5), auf ops von playbooks/monitoring-k3s.yml abgelegt
infra_k3s_token: /root/.config/knaster/k3s-prometheus.token
infra_k3s_ca: /root/.config/knaster/k3s-ca.crt
# file_sd: auf ops aus `tofu output -json gpu_vms` erzeugt (nicht im Repo)
infra_tofu_dir: "{{ playbook_dir }}/../../tofu"
infra_targets_dir: "{{ playbook_dir }}/../monitoring-targets.generated"
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Veralteten Seiten-Cache des Wirts für alle Dateien dieses Containers verwerfen (vor Docker-Start).
Hintergrund (12.10.2026, Task 5.2, HA-Probe): Nach `zfs receive` (Proxmox-Replikation) liefert der Wirt
für Dateien, die auf ihm früher per mmap geöffnet waren, weiter den ALTEN Inhalt aus dem Seiten-Cache
(pve44: `zfs diff` leer, `written` 0, Datei-md5 trotzdem ungleich Snapshot; ZFS 2.4.4, Kernel 7.0.14).
Zieht der Container per HA auf so einen Knoten, liest dockerd veraltete bbolt-Datenbanken
(Panic „invalid freelist page“, Netzwerke/Volumes „not found“); Prometheus-TSDB wäre ebenso betroffen.
POSIX_FADV_DONTNEED wirft saubere, nicht gemappte Seiten aus dem Cache; danach liest der Kernel wieder
von ZFS. Läuft vor containerd/docker, scheitert nie (Exit 0), protokolliert geänderte *.db-Prüfsummen.
"""
import hashlib
import os
import stat
import sys
SKIP = {"/proc", "/sys", "/dev", "/run", "/tmp"}
WATCH = ("/var/lib/docker/", "/var/lib/containerd/")
def md5(path: str) -> str:
h = hashlib.md5()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def main() -> int:
root_dev = os.stat("/").st_dev
files = dropped = errors = 0
changed = []
for dirpath, dirnames, filenames in os.walk("/"):
dirnames[:] = [d for d in dirnames if os.path.join(dirpath, d) not in SKIP]
try:
if os.lstat(dirpath).st_dev != root_dev:
dirnames[:] = []
continue
except OSError:
continue
for name in filenames:
path = os.path.join(dirpath, name)
try:
st = os.lstat(path)
if not stat.S_ISREG(st.st_mode) or st.st_dev != root_dev or st.st_size == 0:
continue
files += 1
watch = path.endswith(".db") and path.startswith(WATCH)
before = md5(path) if watch else None
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
try:
os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED)
dropped += 1
finally:
os.close(fd)
if watch:
after = md5(path)
if after != before:
changed.append(f"{path}: {before[:8]} -> {after[:8]}")
except OSError:
errors += 1
for line in changed:
print(f"veralteter Cache verworfen: {line}")
print(f"{files} Dateien, Cache verworfen für {dropped}, Fehler {errors}, geänderte Datenbanken {len(changed)}")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as exc: # nie den Docker-Start blockieren
print(f"Fehler: {exc}", file=sys.stderr)
sys.exit(0)
@@ -0,0 +1,15 @@
[Unit]
Description=Veralteten Wirts-Seiten-Cache nach ZFS-Replikation verwerfen (vor Docker)
Documentation=file:///usr/local/sbin/knaster-drop-stale-cache
DefaultDependencies=no
After=local-fs.target
Before=containerd.service docker.socket docker.service
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /usr/local/sbin/knaster-drop-stale-cache
TimeoutStartSec=300
RemainAfterExit=yes
[Install]
WantedBy=containerd.service docker.service
@@ -0,0 +1,183 @@
# Compose-Stack infra: Router, TTS, Postgres, Prometheus, Alertmanager, Grafana, pve-exporter, node-exporter.
# Idempotent: Geheimnisse nur anlegen, wenn sie fehlen; compose up ändert nur, was sich geändert hat.
- name: Werkzeuge
ansible.builtin.apt:
name: [rsync, openssl, curl]
state: present
update_cache: true
cache_valid_time: 3600
- name: Verzeichnisse
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
mode: "{{ item.mode }}"
loop:
- {path: /opt/knaster, mode: "0755"}
- {path: "{{ infra_repo_dir }}", mode: "0755"}
- {path: "{{ infra_state_dir }}", mode: "0700"}
- {path: "{{ infra_state_dir }}/secrets", mode: "0700"}
- {path: "{{ infra_state_dir }}/secrets/k3s", mode: "0755"}
# Nur die Teile, die Build und Konfiguration brauchen. Nie .git, .venv, tofu-State oder .env-Dateien.
- name: Repo-Auszug von ops abgleichen
ansible.posix.synchronize:
src: "{{ infra_repo_src }}/"
dest: "{{ infra_repo_dir }}/"
delete: true
rsync_opts:
# Der Clone auf ops ist teils 0600/0700; Container-Benutzer (nobody, grafana, knaster) müssen lesen können
- "--chmod=go+rX,go-w"
- "--exclude=__pycache__"
- "--exclude=.pytest_cache"
- "--exclude=*.egg-info"
- "--exclude=.env"
- "--exclude=secrets/"
- "--include=/core/***"
- "--include=/config/***"
- "--include=/catalog/***"
- "--include=/skills/***"
- "--include=/ontology/***" # Router-Image kopiert ontology/ (KNASTER_ONTOLOGY)
- "--include=/sites/***"
- "--include=/deploy/***"
- "--exclude=*"
- name: Einstellungen in .env
ansible.builtin.lineinfile:
path: "{{ infra_state_dir }}/.env"
regexp: "^{{ item.key }}="
line: "{{ item.key }}={{ item.value }}"
create: true
mode: "0600"
loop: "{{ infra_settings | dict2items }}"
# Gibt nur Namen aus, nie Werte
- name: Fehlende Geheimnisse im Container erzeugen
ansible.builtin.shell: |
set -eu
umask 077
f={{ infra_state_dir }}/.env
for k in {{ infra_secrets | join(' ') }}; do
grep -q "^$k=" "$f" || { echo "$k=$(openssl rand -hex 24)" >> "$f"; echo "neu: $k"; }
done
chmod 0600 "$f"
args:
executable: /bin/bash
register: secrets_gen
changed_when: "'neu:' in secrets_gen.stdout"
- name: Router-Token für Alertmanager (= KNASTER_API_TOKEN, lesbar für nobody im Container)
ansible.builtin.shell: |
set -eu
umask 077
cd {{ infra_state_dir }}
v=$(sed -n 's/^KNASTER_API_TOKEN=//p' .env)
t=secrets/router_token
if [ ! -f "$t" ] || [ "$(cat "$t")" != "$v" ]; then printf '%s' "$v" > "$t"; echo geändert; fi
chown 65534:65534 "$t"
chmod 0400 "$t"
args:
executable: /bin/bash
register: router_token
changed_when: "'geändert' in router_token.stdout"
no_log: true
- name: Proxmox-Lese-Token für den pve-exporter
ansible.builtin.copy:
src: "{{ infra_pve_exporter_env }}"
dest: "{{ infra_state_dir }}/secrets/pve-exporter.env"
mode: "0600"
no_log: true
# Task 6.5: Token des ServiceAccounts prometheus-infra und CA des Clusters (von playbooks/monitoring-k3s.yml
# auf ops abgelegt). Lesbar für nobody (Prometheus im Container). Fehlen sie, bleiben die k3s-Jobs down.
- name: k3s-Zugang für Prometheus vorhanden? (auf ops)
ansible.builtin.stat:
path: "{{ item }}"
loop: ["{{ infra_k3s_token }}", "{{ infra_k3s_ca }}"]
delegate_to: localhost
register: k3s_access
- name: k3s-Zugang für Prometheus kopieren
ansible.builtin.copy:
src: "{{ item.src }}"
dest: "{{ infra_state_dir }}/secrets/k3s/{{ item.dest }}"
owner: "65534"
group: "65534"
mode: "0400"
loop:
- {src: "{{ infra_k3s_token }}", dest: token}
- {src: "{{ infra_k3s_ca }}", dest: ca.crt}
loop_control:
label: "{{ item.dest }}"
when: k3s_access.results | map(attribute='stat.exists') | min
no_log: true
- name: file_sd-Ziele
ansible.builtin.include_tasks: targets.yml
# HA-Umzug: Nach zfs receive liefert der Zielknoten für früher gemappte Dateien veralteten Seiten-Cache
# (12.10.2026 belegt). Vor jedem Docker-Start den Cache dieses Containers verwerfen. BETRIEB.md Abschnitt 7.
- name: Schutz gegen veralteten Seiten-Cache – Skript
ansible.builtin.copy:
src: knaster-drop-stale-cache.py
dest: /usr/local/sbin/knaster-drop-stale-cache
mode: "0755"
- name: Schutz gegen veralteten Seiten-Cache – systemd-Dienst vor containerd/docker
ansible.builtin.copy:
src: knaster-drop-stale-cache.service
dest: /etc/systemd/system/knaster-drop-stale-cache.service
mode: "0644"
register: dropcache_unit
- name: Schutz gegen veralteten Seiten-Cache – aktivieren
ansible.builtin.systemd:
name: knaster-drop-stale-cache.service
enabled: true
daemon_reload: "{{ dropcache_unit.changed }}"
- name: Stack bauen und starten
ansible.builtin.command: >
docker compose -p {{ infra_compose_project }} --env-file {{ infra_state_dir }}/.env
-f {{ infra_compose_file }} up -d --build --remove-orphans
register: compose_up
changed_when: compose_up.stderr is search('Container \S+\s+(Created|Recreated|Started)')
- name: Prometheus und Alertmanager laden geänderte Konfiguration (bind-mount, kein Neustart nötig)
ansible.builtin.command: "curl -fsS -m 10 -X POST http://127.0.0.1:{{ item }}/-/reload"
loop: [9090, 9093]
register: reload
retries: 15
delay: 4
until: reload.rc == 0
changed_when: false
- name: Grafana-Konto kiosk (Viewer) anlegen, Passwort aus .env
ansible.builtin.shell: |
set -eu
cd {{ infra_state_dir }}
A=$(sed -n 's/^GRAFANA_ADMIN_PASSWORD=//p' .env)
K=$(sed -n 's/^GRAFANA_KIOSK_PASSWORD=//p' .env)
umask 077
cfg=$(mktemp)
trap 'rm -f "$cfg"' EXIT
printf 'user = "admin:%s"\n' "$A" > "$cfg" # Passwort nicht in der Prozessliste
g() { curl -sS -K "$cfg" -H 'Content-Type: application/json' "$@"; }
for i in $(seq 60); do curl -fsS -m 3 http://127.0.0.1:3000/api/health >/dev/null 2>&1 && break; sleep 2; done
code=$(g -o /dev/null -w '%{http_code}' 'http://127.0.0.1:3000/api/users/lookup?loginOrEmail=kiosk')
if [ "$code" = 404 ]; then
printf '{"name":"Kiosk","login":"kiosk","password":"%s"}' "$K" \
| g -f -X POST --data @- http://127.0.0.1:3000/api/admin/users >/dev/null
echo angelegt
elif [ "$code" != 200 ]; then
echo "Grafana-API antwortet $code" >&2
exit 1
fi
id=$(g -f 'http://127.0.0.1:3000/api/users/lookup?loginOrEmail=kiosk' | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
g -f -X PATCH --data '{"role":"Viewer"}' "http://127.0.0.1:3000/api/org/users/$id" >/dev/null
args:
executable: /bin/bash
register: kiosk
changed_when: "'angelegt' in kiosk.stdout"
no_log: true
@@ -0,0 +1,43 @@
# file_sd-Ziele der GPU-VMs (node, gpu, llamacpp). Auch einzeln aufrufbar: playbooks/monitoring-targets.yml
# (nach refresh-vm-access.sh, wenn sich DHCP-Adressen geändert haben). Prometheus liest die Dateien selbst neu.
# k3s (Task 6.5): kubelet und kube-state-metrics aus `kubectl get nodes` auf ops. Ist der Cluster nicht
# erreichbar, bleiben diese Listen leer und der Rest läuft weiter.
- name: k3s-Knoten lesen (auf ops)
ansible.builtin.shell: mkdir -p {{ infra_targets_dir }} && kubectl get nodes -o json > {{ infra_targets_dir }}/k3s-nodes.json
args:
executable: /bin/bash
delegate_to: localhost
run_once: true
register: k3s_nodes
failed_when: false
changed_when: false
- name: file_sd-Ziele aus tofu output erzeugen (auf ops)
ansible.builtin.shell: |
set -euo pipefail
tofu output -json gpu_vms | python3 {{ infra_repo_src }}/platform/proxmox/scripts/monitoring-targets.py \
--registry {{ infra_repo_src }}/sites/{{ knaster_site }}/registry.yaml --out {{ infra_targets_dir }} \
--llamacpp '{{ dict(_llamacpp_hosts | zip(_llamacpp_hosts | map('extract', hostvars, 'llamacpp_profiles'))) | to_json }}' \
{{ ('--k3s-nodes ' ~ infra_targets_dir ~ '/k3s-nodes.json') if k3s_nodes.rc == 0 else '' }}
args:
chdir: "{{ infra_tofu_dir }}"
executable: /bin/bash
delegate_to: localhost
run_once: true
vars:
# Profile je VM aus dem Inventar (Gruppe gpu_llamacpp, Task 6.1)
_llamacpp_hosts: "{{ groups['gpu_llamacpp'] | default([]) }}"
changed_when: false
- name: file_sd-Verzeichnis
ansible.builtin.file:
path: "{{ infra_state_dir }}/file_sd"
state: directory
mode: "0755"
- name: file_sd-Ziele in den Container kopieren
ansible.builtin.copy:
src: "{{ infra_targets_dir }}/{{ item }}.json"
dest: "{{ infra_state_dir }}/file_sd/{{ item }}.json"
mode: "0644"
loop: [node, gpu, llamacpp, kubelet, kube-state-metrics]
@@ -0,0 +1,10 @@
# k3s-Agent in den GPU-VMs (D6). Der Server heißt k3s.knaster (/etc/hosts), Token aus der Datei auf ops.
k3s_version: v1.36.5+k3s1
k3s_server_name: k3s.knaster
k3s_server_host: "{{ hostvars['k3s'].ansible_host }}"
k3s_env_file: /root/.config/knaster/k3s.env
k3s_token: "{{ lookup('ini', 'K3S_TOKEN', type='properties', file=k3s_env_file) }}"
k3s_install_script: /usr/local/src/k3s-install.sh
k3s_node_labels: []
k3s_lan: 192.168.100.0/24
k3s_pod_cidr: 10.42.0.0/16
@@ -0,0 +1,98 @@
# k3s-Agent neben Docker: k3s bringt sein eigenes containerd mit und erkennt nvidia-container-runtime
# von selbst (Runtime "nvidia"). Docker bleibt für die bestehenden Compose-Stacks, bis sie umgezogen sind.
- name: Server-Name in /etc/hosts (Adresse per DHCP, D5)
ansible.builtin.lineinfile:
path: /etc/hosts
regexp: '\s{{ k3s_server_name | regex_escape }}$'
line: "{{ k3s_server_host }} {{ k3s_server_name }}"
- name: curl vorhanden
ansible.builtin.apt:
name: curl
state: present
- name: Installationsskript laden
ansible.builtin.get_url:
url: https://get.k3s.io
dest: "{{ k3s_install_script }}"
mode: "0755"
- name: Verzeichnis /etc/rancher/k3s
ansible.builtin.file:
path: /etc/rancher/k3s
state: directory
mode: "0755"
- name: Konfiguration /etc/rancher/k3s/config.yaml
ansible.builtin.copy:
dest: /etc/rancher/k3s/config.yaml
mode: "0600"
content: |
# Verwaltet von Ansible (Rolle k3s-agent)
server: https://{{ k3s_server_name }}:6443
token: {{ k3s_token }}
node-label: {{ k3s_node_labels | to_json }}
register: k3s_agent_config
no_log: true
# ufw (nur in VMs mit aktiver Firewall): Kubelet und NodePorts aus dem LAN, Flannel-VXLAN zwischen den
# Knoten, alles aus dem Pod-Netz (CoreDNS, Device-Plugin, Portainer-Agent sprechen mit dem Knoten).
- name: Ist ufw vorhanden?
ansible.builtin.stat:
path: /usr/sbin/ufw
register: k3s_ufw
- name: ufw – k3s-Ports
community.general.ufw:
rule: allow
from_ip: "{{ item.from }}"
port: "{{ item.port | default(omit) }}"
proto: "{{ item.proto | default(omit) }}"
comment: "{{ item.comment }}"
loop:
- {from: "{{ k3s_lan }}", port: "10250", proto: tcp, comment: k3s-kubelet}
- {from: "{{ k3s_lan }}", port: "8472", proto: udp, comment: k3s-flannel}
- {from: "{{ k3s_lan }}", port: "30000:32767", proto: tcp, comment: k3s-nodeport}
- {from: "{{ k3s_pod_cidr }}", comment: k3s-pods}
loop_control:
label: "{{ item.comment }}"
when: k3s_ufw.stat.exists
- name: Installierte Version lesen
ansible.builtin.command: /usr/local/bin/k3s --version
register: k3s_installed
failed_when: false
changed_when: false
- name: k3s-Agent installieren oder auf die gepinnte Version bringen
ansible.builtin.command: "{{ k3s_install_script }} agent"
environment:
INSTALL_K3S_VERSION: "{{ k3s_version }}"
when: k3s_version not in (k3s_installed.stdout | default(''))
- name: Agent neu starten, wenn sich die Konfiguration geändert hat
ansible.builtin.service:
name: k3s-agent
state: restarted
when: k3s_agent_config.changed and k3s_version in (k3s_installed.stdout | default(''))
- name: Warten, bis containerd von k3s läuft
ansible.builtin.wait_for:
path: /var/lib/rancher/k3s/agent/etc/containerd/config.toml
timeout: 120
- name: NVIDIA-Runtime von k3s erkannt?
ansible.builtin.command: grep -c nvidia /var/lib/rancher/k3s/agent/etc/containerd/config.toml
register: k3s_nvidia
changed_when: false
failed_when: k3s_nvidia.stdout | int == 0
- name: Warten, bis der Knoten im Cluster bereit ist (von ops aus)
ansible.builtin.command: kubectl get node {{ inventory_hostname }} -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}'
register: k3s_node_ready
until: k3s_node_ready.stdout == "True"
retries: 36
delay: 5
changed_when: false
delegate_to: localhost
become: false
@@ -0,0 +1,6 @@
# Grundausstattung im k3s-Cluster (von ops per kubectl): NVIDIA-Runtime und Device-Plugin, Portainer-Agent.
k3s_manifest_dir: "{{ playbook_dir }}/../../../../deploy/k8s"
k3s_base_manifests:
- runtimeclass-nvidia.yaml
- nvidia-device-plugin.yaml
- portainer-agent.yaml
@@ -0,0 +1,28 @@
- name: Grund-Manifeste anwenden
ansible.builtin.command: kubectl apply -f {{ k3s_manifest_dir }}/{{ item }}
loop: "{{ k3s_base_manifests }}"
register: k3s_apply
changed_when: "'configured' in k3s_apply.stdout or 'created' in k3s_apply.stdout"
- name: Warten, bis jede GPU-VM ihre GPUs meldet (nvidia.com/gpu)
ansible.builtin.command: >
kubectl get node {{ item }} -o jsonpath='{.status.allocatable.nvidia\.com/gpu}'
loop: "{{ groups['k3s_agents'] }}"
register: k3s_gpus
until: (k3s_gpus.stdout | default('0') | int) > 0
retries: 24
delay: 5
changed_when: false
- name: Warten, bis der Portainer-Agent läuft
ansible.builtin.command: kubectl -n portainer rollout status deployment/portainer-agent --timeout=180s
changed_when: false
- name: Übersicht
ansible.builtin.command: kubectl get nodes -o custom-columns=NAME:.metadata.name,STATUS:.status.conditions[-1].type,GPU:.status.allocatable.nvidia\.com/gpu,VERSION:.status.nodeInfo.kubeletVersion
register: k3s_nodes
changed_when: false
- name: Knoten
ansible.builtin.debug:
msg: "{{ k3s_nodes.stdout_lines }}"
@@ -0,0 +1,8 @@
# k3s-Steuerung (D6) auf der Dienst-VM "k3s". Version gepinnt (Kanal stable, 15.10.2026).
k3s_version: v1.36.5+k3s1
k3s_server_name: k3s.knaster # Name, unter dem die Agents den Server finden (/etc/hosts, Rolle k3s-agent)
k3s_install_script: /usr/local/src/k3s-install.sh
# Geheimnisse und Zugang landen auf ops, nicht im Repo
k3s_env_file: /root/.config/knaster/k3s.env # K3S_URL und K3S_TOKEN für die Agents
k3s_kubeconfig: /root/.kube/config # kubectl auf ops
k3s_kubectl_version: "{{ k3s_version.split('+') | first }}"
@@ -0,0 +1,102 @@
# k3s-Server (Steuerung, Datenbank, API) auf der Dienst-VM. Ohne traefik (kein Ingress nötig),
# servicelb bleibt für Dienste vom Typ LoadBalancer. Die Agents (GPU-VMs) kommen über die Rolle k3s-agent.
- name: curl vorhanden
ansible.builtin.apt:
name: curl
state: present
update_cache: true
- name: Installationsskript laden
ansible.builtin.get_url:
url: https://get.k3s.io
dest: "{{ k3s_install_script }}"
mode: "0755"
- name: Verzeichnis /etc/rancher/k3s
ansible.builtin.file:
path: /etc/rancher/k3s
state: directory
mode: "0755"
- name: Konfiguration /etc/rancher/k3s/config.yaml
ansible.builtin.copy:
dest: /etc/rancher/k3s/config.yaml
mode: "0600"
content: |
# Verwaltet von Ansible (Rolle k3s-server)
disable: [traefik]
write-kubeconfig-mode: "0600"
tls-san: [{{ k3s_server_name }}, {{ ansible_host }}]
node-label: [knaster.role=control]
register: k3s_server_config
- name: Installierte Version lesen
ansible.builtin.command: /usr/local/bin/k3s --version
register: k3s_installed
failed_when: false
changed_when: false
- name: k3s-Server installieren oder auf die gepinnte Version bringen
ansible.builtin.command: "{{ k3s_install_script }} server"
environment:
INSTALL_K3S_VERSION: "{{ k3s_version }}"
when: k3s_version not in (k3s_installed.stdout | default(''))
- name: k3s neu starten, wenn sich die Konfiguration geändert hat
ansible.builtin.service:
name: k3s
state: restarted
when: k3s_server_config.changed and k3s_version in (k3s_installed.stdout | default(''))
- name: Warten, bis der Knoten bereit ist
ansible.builtin.command: /usr/local/bin/k3s kubectl get node {{ inventory_hostname }} -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}'
register: k3s_ready
until: k3s_ready.stdout == "True"
retries: 30
delay: 5
changed_when: false
- name: Token und kubeconfig lesen
ansible.builtin.slurp:
src: "{{ item }}"
loop: [/var/lib/rancher/k3s/server/node-token, /etc/rancher/k3s/k3s.yaml]
register: k3s_secrets
no_log: true
- name: Zugang nach ops schreiben
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0700"
loop: ["{{ k3s_env_file | dirname }}", "{{ k3s_kubeconfig | dirname }}"]
delegate_to: localhost
become: false
- name: K3S_URL und K3S_TOKEN auf ops ({{ k3s_env_file }})
ansible.builtin.copy:
dest: "{{ k3s_env_file }}"
mode: "0600"
content: |
K3S_URL=https://{{ k3s_server_name }}:6443
K3S_TOKEN={{ k3s_secrets.results[0].content | b64decode | trim }}
delegate_to: localhost
become: false
no_log: true
- name: kubeconfig auf ops ({{ k3s_kubeconfig }}, Server = VM-Adresse)
ansible.builtin.copy:
dest: "{{ k3s_kubeconfig }}"
mode: "0600"
content: "{{ k3s_secrets.results[1].content | b64decode | replace('https://127.0.0.1:6443', 'https://' ~ ansible_host ~ ':6443') }}"
delegate_to: localhost
become: false
no_log: true
- name: kubectl auf ops (Version passend zum Server)
ansible.builtin.get_url:
url: "https://dl.k8s.io/release/{{ k3s_kubectl_version }}/bin/linux/amd64/kubectl"
checksum: "sha256:https://dl.k8s.io/release/{{ k3s_kubectl_version }}/bin/linux/amd64/kubectl.sha256"
dest: /usr/local/bin/kubectl
mode: "0755"
delegate_to: localhost
become: false
@@ -0,0 +1,6 @@
# Umzug der NEX-Sprachdienste von Docker Compose nach k3s (gleiche VM, gleiche Ports, gleiche Images).
nex_speech_dir: /opt/nex-speech
nex_speech_images:
- knaster/nex-whisper:v1.9.4-cu12.6
- knaster/nex-tts-piper:1.8.0
nex_speech_k3s_manifests: "{{ playbook_dir }}/../../../../deploy/k8s/nex-speech"
@@ -0,0 +1,40 @@
# 1. Images aus Docker in das containerd von k3s übernehmen (kein Neubau, kein Registry-Umweg)
- name: Images in k3s vorhanden?
ansible.builtin.command: /usr/local/bin/k3s ctr images ls -q
register: k3s_images
changed_when: false
- name: Images aus Docker importieren
ansible.builtin.shell: docker save {{ item }} | /usr/local/bin/k3s ctr images import -
loop: "{{ nex_speech_images }}"
when: ("docker.io/" ~ item) not in k3s_images.stdout
async: 1800
poll: 15
# 2. Compose-Stack anhalten (Host-Ports 8178/8179 werden frei), Dateien bleiben für den Rückweg
- name: Compose-Stack nex-speech anhalten
community.docker.docker_compose_v2:
project_src: "{{ nex_speech_dir }}"
state: stopped
when: nex_speech_compose_stop | default(true)
# 3. Manifeste anwenden und auf Bereitschaft warten (Probes prüfen /health in der VM)
- name: Manifeste anwenden
ansible.builtin.command: kubectl apply -f {{ nex_speech_k3s_manifests }}/
register: nex_apply
changed_when: "'configured' in nex_apply.stdout or 'created' in nex_apply.stdout"
delegate_to: localhost
become: false
- name: Warten, bis beide Deployments bereit sind
ansible.builtin.command: kubectl -n nex rollout status deployment/{{ item }} --timeout=600s
loop: [nex-stt, nex-tts]
changed_when: false
delegate_to: localhost
become: false
- name: Gesundheit in der VM prüfen
ansible.builtin.uri:
url: "http://127.0.0.1:{{ item }}/health"
status_code: 200
loop: [8178, 8179]

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