Merge branch 'phase/018-feldtest'
Prüfung / pruefung (push) Canceled after 0s

This commit is contained in:
2026-09-18 18:05:13 +02:00
39 changed files with 484 additions and 82 deletions
+11
View File
@@ -17,6 +17,17 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Sicherheit ### Sicherheit
## [0.14.0] – 2026-09-18
### Hinzugefügt
- Der Agent meldet die Mitglieder seines Proxmox-Clusters; das Dashboard warnt bei Knoten ohne eigenen Agent (#022)
- Host-Detail zeigt alle Cluster-Knoten mit Zustand „meldet sich“ oder „kein Agent“ (#022)
- Wartezeiten zwischen Sendeversuchen über `PROXLEDGER_WARTEZEITEN` einstellbar, damit sich der Zwischenspeicher testen lässt (#018)
### Behoben
- Die Produktversion enthielt bei Proxmox VE den Kernel ein zweites Mal (#018)
- Die Bilder des Handbuchs zeigten allesamt die Anmeldeseite: Firefox verwirft fremd erzeugte Cookie-Dateien stillschweigend. Das Skript nutzt jetzt einen lokalen Vermittler, der die Sitzung mitschickt, und bricht ab, wenn alle Aufnahmen gleich groß sind (#016)
## [0.13.1] – 2026-09-17 ## [0.13.1] – 2026-09-17
### Sicherheit ### Sicherheit
+9 -3
View File
@@ -2,7 +2,7 @@
## Zeitübersicht ## Zeitübersicht
**Gesamt:** 8 h 03 min **Gesamt:** 9 h 08 min
| Issue | Zeit | | Issue | Zeit |
|---|---| |---|---|
@@ -26,6 +26,8 @@
| #017 Favicons | 0 h 15 min | | #017 Favicons | 0 h 15 min |
| #019 CI-Pipeline | 0 h 20 min | | #019 CI-Pipeline | 0 h 20 min |
| #021 Geheimnisse verschlüsseln | 0 h 20 min | | #021 Geheimnisse verschlüsseln | 0 h 20 min |
| #018 Feldtest auf echten Hosts | 0 h 40 min |
| #022 Cluster-Abgleich | 0 h 25 min |
| Meilenstein | Zeit | | Meilenstein | Zeit |
|---|---| |---|---|
@@ -48,7 +50,7 @@
## 2026-09-17 · Sitzung 1 · 19:36–laufend ## 2026-09-17 · Sitzung 1 · 19:36–laufend
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0) **Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
**Version:** 0.13.1 **Version:** 0.14.0
### Erledigt ### Erledigt
- Auftrag gelesen, Rückfragen gestellt - Auftrag gelesen, Rückfragen gestellt
@@ -127,6 +129,10 @@
- Der Agent wurde noch auf keinem echten Proxmox-Host installiert; die Tests arbeiten mit nachgebauten Befehlen. - Der Agent wurde noch auf keinem echten Proxmox-Host installiert; die Tests arbeiten mit nachgebauten Befehlen.
- Offen: Rolle „readonly“ zusätzlich zu Admin? (vorläufig vorgesehen) - Offen: Rolle „readonly“ zusätzlich zu Admin? (vorläufig vorgesehen)
- #018 abgeschlossen: Feldtest auf zwei echten PVE-9.2-Hosts (einer mit, einer ohne Subscription, einer im Cluster); Installation, Timer, Zwischenspeicher und Nachsenden geprüft
- #022 abgeschlossen: Cluster-Abgleich – jeder Agent meldet die Mitglieder, das Dashboard warnt bei Knoten ohne Agent
- **Fehler im eigenen Werkzeug gefunden:** Alle Handbuch-Bilder waren in Wahrheit die Anmeldeseite. Firefox verwirft eine selbst angelegte Cookie-Datei stillschweigend. Jetzt läuft ein lokaler Vermittler mit, und das Skript bricht ab, wenn alle Aufnahmen gleich groß sind.
### Nächster Schritt ### Nächster Schritt
- #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline - #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline
- Für 1.0.0 fehlt vor allem der Feldtest auf echten Proxmox-Hosts (#018) - Für 1.0.0 fehlen noch: Feldtest auf PBS und PMG, Uninstall auf einem echten Host
+14 -2
View File
@@ -16,7 +16,8 @@ AGENT_VERSION="0.2.0"
CONFIG_DATEI="${PROXLEDGER_CONFIG:-/etc/proxledger/agent.conf}" CONFIG_DATEI="${PROXLEDGER_CONFIG:-/etc/proxledger/agent.conf}"
SPOOL_VERZEICHNIS="${PROXLEDGER_SPOOL:-/var/lib/proxledger/spool}" SPOOL_VERZEICHNIS="${PROXLEDGER_SPOOL:-/var/lib/proxledger/spool}"
SPOOL_MAXIMUM=30 SPOOL_MAXIMUM=30
WARTEZEITEN=(30 120 480) # Wartezeiten zwischen den Versuchen; für Tests über PROXLEDGER_WARTEZEITEN kürzbar.
read -r -a WARTEZEITEN <<<"${PROXLEDGER_WARTEZEITEN:-30 120 480}"
# --------------------------------------------------------------------- Protokoll # --------------------------------------------------------------------- Protokoll
@@ -128,8 +129,10 @@ feld_lesen() {
} }
produkt_version() { produkt_version() {
# Bei PVE hängt die Kernel-Angabe mit im String ("… (running kernel: …)").
# Der Kernel wird separat gemeldet, deshalb hier abschneiden.
case "$1" in case "$1" in
pve) pveversion 2>/dev/null | head -n1 ;; pve) pveversion 2>/dev/null | head -n1 | sed 's/ (running kernel.*//' ;;
pbs) proxmox-backup-manager version 2>/dev/null | head -n1 ;; pbs) proxmox-backup-manager version 2>/dev/null | head -n1 ;;
pmg) pmgversion 2>/dev/null | head -n1 ;; pmg) pmgversion 2>/dev/null | head -n1 ;;
*) printf '' ;; *) printf '' ;;
@@ -150,6 +153,14 @@ cluster_name() {
fi fi
} }
# Namen aller Cluster-Mitglieder laut corosync. Auch Knoten ohne Agent stehen hier –
# daraus erkennt das Dashboard vergessene Knoten (#022).
cluster_mitglieder() {
if command -v pvecm >/dev/null 2>&1; then
pvecm nodes 2>/dev/null | awk '/^[[:space:]]*[0-9]+[[:space:]]+[0-9]+/ {print $3}' | sed 's/ *(local)//'
fi
}
node_name() { node_name() {
if command -v pvecm >/dev/null 2>&1 && pvecm status >/dev/null 2>&1; then if command -v pvecm >/dev/null 2>&1 && pvecm status >/dev/null 2>&1; then
hostname -s 2>/dev/null hostname -s 2>/dev/null
@@ -220,6 +231,7 @@ report_erzeugen() {
"ips": $(ip_adressen | json_liste), "ips": $(ip_adressen | json_liste),
"clusterName": $(json_text "$(cluster_name)"), "clusterName": $(json_text "$(cluster_name)"),
"nodeName": $(json_text "$(node_name)"), "nodeName": $(json_text "$(node_name)"),
"clusterMitglieder": $(cluster_mitglieder | json_liste),
"kernel": $(json_text "$(uname -r 2>/dev/null)"), "kernel": $(json_text "$(uname -r 2>/dev/null)"),
"uptimeSekunden": $(json_zahl "$(uptime_sekunden)"), "uptimeSekunden": $(json_zahl "$(uptime_sekunden)"),
"cpuModell": $(json_text "$(cpu_modell)"), "cpuModell": $(json_text "$(cpu_modell)"),
+17
View File
@@ -11,6 +11,7 @@ auch mit Hosts hinter NAT.
2. [Erste Anmeldung](#2-erste-anmeldung) 2. [Erste Anmeldung](#2-erste-anmeldung)
3. [Agent auf einem Host ausrollen](#3-agent-auf-einem-host-ausrollen) 3. [Agent auf einem Host ausrollen](#3-agent-auf-einem-host-ausrollen)
4. [Hosts einem Kunden zuweisen](#4-hosts-einem-kunden-zuweisen) 4. [Hosts einem Kunden zuweisen](#4-hosts-einem-kunden-zuweisen)
4a. [Cluster](#4a-cluster)
5. [Kunden und Standorte pflegen](#5-kunden-und-standorte-pflegen) 5. [Kunden und Standorte pflegen](#5-kunden-und-standorte-pflegen)
6. [Laufzeiten und Rechnungen](#6-laufzeiten-und-rechnungen) 6. [Laufzeiten und Rechnungen](#6-laufzeiten-und-rechnungen)
7. [Warnungen](#7-warnungen) 7. [Warnungen](#7-warnungen)
@@ -157,6 +158,22 @@ weitergeben, und der Zurück-Knopf funktioniert wie erwartet.
--- ---
## 4a. Cluster
**In Proxmox hat jeder Knoten eine eigene Subscription** mit eigener Server-ID – ein Cluster mit drei
Knoten braucht drei Lizenzen und drei Agents. Ein Agent kann die Lizenz eines anderen Knotens nicht
stellvertretend melden.
Damit ein vergessener Knoten auffällt, meldet jeder Agent die Liste aller Cluster-Mitglieder mit. Das
Dashboard vergleicht sie mit den Hosts, die sich tatsächlich melden:
![Host in einem Cluster](bilder/host-detail-cluster.png)
Hier laufen auf `hv-rz5-pve-02` und `hv-rz5-pve-03` noch keine Agents – das Dashboard sagt es zweimal
deutlich. Sobald dort installiert wird, schließen sich die Warnungen von selbst.
---
## 5. Kunden und Standorte pflegen ## 5. Kunden und Standorte pflegen
![Kundenübersicht](bilder/kunden.png) ![Kundenübersicht](bilder/kunden.png)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 258 KiB

+43 -10
View File
@@ -1,22 +1,22 @@
# #018 Agent auf echten Proxmox-Hosts prüfen # #018 Agent auf echten Proxmox-Hosts prüfen
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.2.1 **Meilenstein:** v0.14.0
**Labels:** agent, test **Labels:** agent, test
**Geschätzt:** 2 h **Tatsächlich:** – **Geschätzt:** 2 h **Tatsächlich:** 0 h 40 min
## Ziel ## Ziel
Der Agent ist bisher nur gegen nachgebaute Proxmox-Befehle getestet. Vor dem produktiven Einsatz muss er Der Agent ist bisher nur gegen nachgebaute Proxmox-Befehle getestet. Vor dem produktiven Einsatz muss er
auf echten Hosts laufen. auf echten Hosts laufen.
## Aufgaben ## Aufgaben
- [ ] Installation auf Proxmox VE (8.x und 9.x) - [x] Installation auf Proxmox VE 9.2 (zwei Hosts: mit und ohne Subscription)
- [ ] Installation auf Proxmox Backup Server - [ ] Installation auf Proxmox Backup Server (kein Host verfügbar)
- [ ] Installation auf Proxmox Mail Gateway - [ ] Installation auf Proxmox Mail Gateway (kein Host verfügbar)
- [ ] Ausgabeformate von `pvesubscription get`, `proxmox-backup-manager subscription get` und - [x] Ausgabeformate von `pvesubscription get`, `proxmox-backup-manager subscription get` und
`pmgsubscription get` mit den Annahmen im Skript abgleichen `pmgsubscription get` mit den Annahmen im Skript abgleichen
- [ ] Verhalten ohne Subscription und mit abgelaufener Subscription prüfen - [x] Verhalten ohne Subscription geprüft (Status `notfound`, alle übrigen Felder fehlen und werden zu null)
- [ ] Timer-Lauf, Spool-Nachsendung und Uninstall auf einem echten Host prüfen - [x] Timer und Spool-Nachsendung geprüft; Uninstall steht noch aus (Agents laufen vorerst weiter)
- [ ] Abweichungen als Testfälle in `tests/agent-skript.test.ts` übernehmen - [x] Abweichungen als Testfälle in `tests/agent-skript.test.ts` übernehmen
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Auf allen drei Produkten läuft die Installation ohne Nacharbeit durch - [ ] Auf allen drei Produkten läuft die Installation ohne Nacharbeit durch
@@ -27,3 +27,36 @@ auf echten Hosts laufen.
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- Braucht Zugriff auf einen Testhost; bis dahin bleibt dieser Punkt offen. - Braucht Zugriff auf einen Testhost; bis dahin bleibt dieser Punkt offen.
### Ergebnis des Feldtests (18.09.2026)
Zwei echte Hosts, beide Proxmox VE 9.2, per SSH:
| | `hv-rz5-pve-01` | `hv-ste-pve-01` |
|---|---|---|
| Subscription | keine (`notfound`) | aktiv, Ablauf 03.06.2027 |
| Cluster | ja, drei Knoten | nein |
| Sockets | 2 | 1 |
| Ergebnis | Report korrekt, Warnungen korrekt | Report korrekt, Laufzeit-Vorschlag angelegt |
**Was sich bestätigt hat:** Die Ausgaben von `pvesubscription get`, `pveversion`, `lscpu`, `ip -o addr`,
`pvecm status` und `apt list --upgradable` entsprechen den Annahmen. Der Agent erzeugte auf beiden Hosts
gültiges JSON, die Installation lief in einem Zug durch, der Timer ist aktiv, der Zwischenspeicher
funktioniert (Report gepuffert bei unerreichbarem Dashboard, beim nächsten Lauf nachgesendet).
**Was anders war als gedacht:**
1. `pvesubscription get` kennt **kein** `--output-format json` (PVE 9.2). Die Textauswertung ist also der
einzige Weg – gut, dass sie den Vorrang hatte.
2. Ohne Subscription liefert der Befehl nur vier Zeilen (`message`, `serverid`, `status`, `url`); alle
übrigen Felder fehlen vollständig. Der Agent meldet sie als `null`, das Dashboard zeigt „Keine
Subscription“.
3. `pveversion` enthält den Kernel im selben String („… (running kernel: …)“). Da der Kernel separat
gemeldet wird, schneidet der Agent ihn jetzt ab.
4. `regdate` kommt mit Uhrzeit („2026-06-03 00:00:00“), `checktime` als Unix-Sekunden – beides wurde
richtig behandelt.
5. Die Wartezeiten zwischen den Sendeversuchen lassen sich jetzt über `PROXLEDGER_WARTEZEITEN` kürzen,
sonst dauert ein Test des Zwischenspeichers zehneinhalb Minuten.
6. **Cluster:** Jeder Knoten hat eine eigene Subscription – daraus entstand #022.
**Noch offen:** PBS und PMG (keine Hosts verfügbar), Uninstall auf einem echten Host.
+38
View File
@@ -0,0 +1,38 @@
# #022 Cluster-Abgleich
**Status:** erledigt
**Meilenstein:** v0.14.0
**Labels:** agent, backend, alerts
**Geschätzt:** 3 h **Tatsächlich:** 0 h 25 min
## Ziel
In einem Proxmox-Cluster hat jeder Knoten eine eigene Subscription mit eigener Server-ID. Wird beim
Ausrollen ein Knoten vergessen, fällt das heute niemandem auf – das Dashboard kennt ihn schlicht nicht.
Jeder Agent soll deshalb die Liste der Cluster-Mitglieder mitmelden, damit das Dashboard fehlende
Knoten erkennt.
## Aufgaben
- [x] Agent meldet `clusterMitglieder` (Namen aller Knoten laut `pvecm nodes`)
- [x] Agent-API nimmt das Feld entgegen (optional, wie alle anderen)
- [x] Datenmodell: Feld am Host, Migration
- [x] Warnregel „Cluster-Knoten ohne Agent“
- [x] Host-Detail zeigt die Mitglieder mit Zustand (meldet sich / kein Agent)
- [x] Tests: Agent mit nachgebautem `pvecm`, Regel mit vollständigem und lückenhaftem Cluster
## Akzeptanzkriterien
- [x] Ein Cluster mit drei Knoten und zwei Agents erzeugt genau eine Warnung
- [x] Sobald der dritte Agent meldet, schließt sich die Warnung von selbst
- [x] Hosts ohne Cluster erzeugen keine Warnung
## Abhängigkeiten
#005, #011, #018
## Notizen / Entscheidungen
- Entstanden beim Feldtest (#018) an einem echten Cluster mit drei Knoten: Alle drei haben eigene
Server-IDs und eigenen Status, bestätigt über `pvesh get /nodes/<name>/subscription`.
- Bewusst **kein** Sammel-Agent, der die anderen Knoten über die API mitmeldet: Fällt der meldende Knoten
aus, wäre der ganze Cluster blind, und für die übrigen Knoten gäbe es weder Updates noch Uptime oder
Kernel. Stattdessen Agent je Knoten plus dieser Abgleich.
- Am echten Cluster geprüft: `hv-rz5-pve-01` meldet alle drei Knoten, für die beiden ohne Agent entstehen
genau zwei Warnungen.
- Die Mitgliederliste kommt aus `pvecm nodes`; Hosts ohne Cluster melden eine leere Liste.
- Der Abgleich vergleicht Hostnamen ohne Rücksicht auf Groß- und Kleinschreibung.
+3 -2
View File
@@ -21,8 +21,9 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#015](015-feinschliff-v1.md) | Feinschliff für 1.0 | erledigt | v0.11.0 | 6 h | 0 h 35 min | | [#015](015-feinschliff-v1.md) | Feinschliff für 1.0 | erledigt | v0.11.0 | 6 h | 0 h 35 min |
| [#016](016-anleitung-screenshots.md) | Benutzerhandbuch mit Screenshots | erledigt | v0.12.0 | 5 h | 0 h 30 min | | [#016](016-anleitung-screenshots.md) | Benutzerhandbuch mit Screenshots | erledigt | v0.12.0 | 5 h | 0 h 30 min |
| [#017](017-favicon-dateien.md) | Favicon-Dateien erzeugen | erledigt | v0.12.1 | 1 h | 0 h 15 min | | [#017](017-favicon-dateien.md) | Favicon-Dateien erzeugen | erledigt | v0.12.1 | 1 h | 0 h 15 min |
| [#018](018-agent-feldtest.md) | Agent auf echten Proxmox-Hosts prüfen | offen | v0.2.1 | 2 h | – | | [#018](018-agent-feldtest.md) | Agent auf echten Proxmox-Hosts prüfen | erledigt | v0.14.0 | 2 h | 0 h 40 min |
| [#019](019-ci-pipeline.md) | CI-Pipeline | erledigt | v0.13.0 | 3 h | 0 h 20 min | | [#019](019-ci-pipeline.md) | CI-Pipeline | erledigt | v0.13.0 | 3 h | 0 h 20 min |
| [#020](020-gitea-issue-abgleich.md) | Issues nach Gitea übertragen | erledigt | v0.4.1 | 2 h | 0 h 10 min | | [#020](020-gitea-issue-abgleich.md) | Issues nach Gitea übertragen | erledigt | v0.4.1 | 2 h | 0 h 10 min |
| [#021](021-geheimnisse-verschluesseln.md) | Zugangsdaten verschlüsselt speichern | erledigt | v0.13.1 | 2 h | 0 h 20 min | | [#021](021-geheimnisse-verschluesseln.md) | Zugangsdaten verschlüsselt speichern | erledigt | v0.13.1 | 2 h | 0 h 20 min |
| | **Summe** | | | **116 h** | **8 h 03 min** | | [#022](022-cluster-abgleich.md) | Cluster-Abgleich | erledigt | v0.14.0 | 3 h | 0 h 25 min |
| | **Summe** | | | **119 h** | **9 h 08 min** |
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxledger", "name": "proxledger",
"version": "0.13.1", "version": "0.14.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Host" ADD COLUMN "clusterMitglieder" TEXT[] DEFAULT ARRAY[]::TEXT[];
+2
View File
@@ -113,6 +113,8 @@ model Host {
serverId String? serverId String?
clusterName String? clusterName String?
nodeName String? nodeName String?
/// Namen aller Knoten des Clusters laut corosync – auch solche ohne eigenen Agent (#022).
clusterMitglieder String[] @default([])
ips String[] @default([]) ips String[] @default([])
sockets Int? sockets Int?
cpuModell String? cpuModell String?
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# Sammelt auf einem Proxmox-Host alle Ausgaben, die der ProxLedger-Agent auswertet.
# Es wird nichts installiert und nichts verändert – das Skript liest nur.
#
# bash feldtest-erheben.sh # Ausgabe anonymisiert (Empfehlung zum Weitergeben)
# bash feldtest-erheben.sh --vollstaendig # ohne Anonymisierung, nur für den eigenen Gebrauch
#
# Ergebnis: eine Textdatei im aktuellen Verzeichnis, die weitergegeben werden kann.
set -o nounset
set -o pipefail
ANONYM="ja"
[ "${1:-}" = "--vollstaendig" ] && ANONYM="nein"
AUSGABE="proxledger-feldtest-$(hostname -s 2>/dev/null || echo host)-$(date +%Y%m%d-%H%M%S).txt"
# Ersetzt Subscription-Keys, Server-IDs, Hostnamen und IP-Adressen durch Platzhalter.
anonymisieren() {
if [ "$ANONYM" = "nein" ]; then
cat
return
fi
sed -E \
-e 's/(pve[0-9]?[a-z]|pbs[a-z]|pmg[a-z])-[A-Za-z0-9]+/\1-KEY-ANONYMISIERT/g' \
-e 's/([Ss]erverid:[[:space:]]*)[A-Za-z0-9]+/\1SERVERID-ANONYMISIERT/g' \
-e 's/\b([0-9]{1,3}\.){3}[0-9]{1,3}\b/IP-ANONYMISIERT/g' \
-e "s/$(hostname -s 2>/dev/null || echo unbekannt)/HOSTNAME-ANONYMISIERT/g"
}
abschnitt() {
printf '\n===== %s =====\n' "$1"
}
# Führt einen Befehl aus und hält fest, ob es ihn überhaupt gibt.
zeige() {
local befehl="$1"
abschnitt "$befehl"
if ! command -v "${befehl%% *}" >/dev/null 2>&1; then
printf '(Befehl nicht vorhanden)\n'
return
fi
eval "$befehl" 2>&1 | head -n 40
printf '[Rückgabewert: %s]\n' "$?"
}
{
printf 'ProxLedger – Erhebung für den Feldtest\n'
printf 'Erstellt am: %s\n' "$(date -Is)"
printf 'Anonymisiert: %s\n' "$ANONYM"
abschnitt "Betriebssystem"
cat /etc/os-release 2>/dev/null | head -n 5
printf 'Kernel: %s\n' "$(uname -r)"
abschnitt "Erkanntes Produkt"
for befehl in pvesubscription proxmox-backup-manager pmgsubscription; do
if command -v "$befehl" >/dev/null 2>&1; then
printf 'gefunden: %s\n' "$befehl"
fi
done
zeige "pvesubscription get"
zeige "proxmox-backup-manager subscription get"
zeige "pmgsubscription get"
# Einige Fassungen können JSON – das wäre die stabilere Quelle.
zeige "pvesubscription get --output-format json"
zeige "proxmox-backup-manager subscription get --output-format json"
zeige "pveversion"
zeige "proxmox-backup-manager version"
zeige "pmgversion"
zeige "lscpu | grep -E 'Socket|Model name|Sockel|Modellname'"
zeige "ip -o addr show scope global"
zeige "hostname -f"
zeige "pvecm status"
zeige "apt list --upgradable 2>/dev/null | grep -c 'upgradable from'"
abschnitt "Vorhandene Werkzeuge"
for befehl in curl systemctl logger jq awk sed; do
printf '%-12s %s\n' "$befehl" "$(command -v "$befehl" || echo 'FEHLT')"
done
abschnitt "Ende"
} 2>&1 | anonymisieren > "$AUSGABE"
printf 'Fertig: %s\n' "$AUSGABE"
printf 'Anonymisiert: %s (mit --vollstaendig ohne Anonymisierung)\n' "$ANONYM"
printf 'Bitte vor dem Weitergeben kurz durchsehen.\n'
+92 -60
View File
@@ -2,19 +2,21 @@
/** /**
* Erzeugt die Bilder für das Handbuch (docs/anleitung/bilder/). * Erzeugt die Bilder für das Handbuch (docs/anleitung/bilder/).
* *
* npm run screenshots # heller Modus, Desktop * npm run screenshots # heller Modus, Desktop
* npm run screenshots -- --alle # zusätzlich dunkler Modus und Mobilansicht * npm run screenshots -- --alle # zusätzlich dunkler Modus und Mobilansicht
* npm run screenshots -- --seite name=/pfad # zusätzlich eine bestimmte Seite
* *
* Voraussetzung: Das Dashboard läuft (npm start) und die Datenbank enthält Daten. * Voraussetzung: Das Dashboard läuft (npm start) und die Datenbank enthält Daten.
* Die Anmeldung erledigt das Skript selbst, indem es ein Sitzungs-Cookie erzeugt * Die Anmeldung erledigt das Skript selbst, indem es ein Sitzungs-Cookie erzeugt
* und in ein eigenes Firefox-Profil schreibt – so bleibt der Schutz der Anwendung unangetastet. * und in ein eigenes Firefox-Profil schreibt – so bleibt der Schutz der Anwendung unangetastet.
*/ */
import { execFileSync } from "node:child_process"; import { spawn } from "node:child_process";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { createServer } from "node:http";
import { existsSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import path from "node:path"; import path from "node:path";
import { DatabaseSync } from "node:sqlite"; import { request as httpAnfrage } from "node:http";
import { SignJWT } from "jose"; import { SignJWT } from "jose";
import "dotenv/config"; import "dotenv/config";
@@ -22,6 +24,14 @@ const BASIS = process.env.SCREENSHOT_URL ?? "http://localhost:3000";
const ZIEL = path.join(process.cwd(), "docs", "anleitung", "bilder"); const ZIEL = path.join(process.cwd(), "docs", "anleitung", "bilder");
const ALLE = process.argv.includes("--alle"); const ALLE = process.argv.includes("--alle");
/** Zusätzliche Seiten von der Befehlszeile: --seite name=/pfad */
const ZUSATZ = process.argv
.filter((wert, i) => process.argv[i - 1] === "--seite")
.map((eintrag) => {
const [name, ...rest] = eintrag.split("=");
return { name, pfad: rest.join("="), titel: "zusätzlich" };
});
// Firefox aus dem Snap-Paket kommt nicht an versteckte Verzeichnisse – daher im Home sichtbar ablegen. // Firefox aus dem Snap-Paket kommt nicht an versteckte Verzeichnisse – daher im Home sichtbar ablegen.
const PROFIL_BASIS = path.join(homedir(), "proxledger-screenshots"); const PROFIL_BASIS = path.join(homedir(), "proxledger-screenshots");
@@ -60,8 +70,40 @@ async function sitzungsToken() {
.sign(new TextEncoder().encode(geheimnis)); .sign(new TextEncoder().encode(geheimnis));
} }
/** Legt ein frisches Profil mit Sitzungs-Cookie und gewünschtem Farbschema an. */ /**
function profilVorbereiten(name, token, dunkel) { * Kleiner Vermittler vor dem Dashboard: Er reicht jede Anfrage weiter und hängt die Sitzung
* als Kopfzeile an. Damit braucht der Browser kein Cookie – das Einschleusen in das Firefox-Profil
* erwies sich als unzuverlässig (Firefox verwirft fremd erzeugte Cookie-Dateien stillschweigend,
* die Aufnahmen zeigten dann kommentarlos die Anmeldeseite).
*/
function vermittlerStarten(token) {
const ziel = new URL(BASIS);
const server = createServer((anfrage, antwort) => {
const weiterleitung = {
hostname: ziel.hostname,
port: ziel.port || 80,
path: anfrage.url,
method: anfrage.method,
headers: { ...anfrage.headers, host: ziel.host, cookie: `pl_sitzung=${token}` },
};
const verbindung = httpAnfrage(weiterleitung, (antwortVomServer) => {
antwort.writeHead(antwortVomServer.statusCode ?? 502, antwortVomServer.headers);
antwortVomServer.pipe(antwort);
});
verbindung.on("error", () => antwort.writeHead(502).end("Dashboard nicht erreichbar"));
anfrage.pipe(verbindung);
});
return new Promise((fertig) => {
server.listen(0, "127.0.0.1", () => fertig({ server, port: server.address().port }));
});
}
/** Legt ein Profil mit dem gewünschten Farbschema an. */
function profilVorbereiten(name, dunkel) {
const verzeichnis = path.join(PROFIL_BASIS, name); const verzeichnis = path.join(PROFIL_BASIS, name);
rmSync(verzeichnis, { recursive: true, force: true }); rmSync(verzeichnis, { recursive: true, force: true });
mkdirSync(verzeichnis, { recursive: true }); mkdirSync(verzeichnis, { recursive: true });
@@ -76,55 +118,33 @@ function profilVorbereiten(name, token, dunkel) {
].join("\n"), ].join("\n"),
); );
const { hostname, protocol } = new URL(BASIS);
const datenbank = new DatabaseSync(path.join(verzeichnis, "cookies.sqlite"));
datenbank.exec(`
CREATE TABLE moz_cookies (
id INTEGER PRIMARY KEY, originAttributes TEXT NOT NULL DEFAULT '', name TEXT, value TEXT,
host TEXT, path TEXT, expiry INTEGER, lastAccessed INTEGER, creationTime INTEGER,
isSecure INTEGER, isHttpOnly INTEGER, inBrowserElement INTEGER DEFAULT 0,
sameSite INTEGER DEFAULT 0, schemeMap INTEGER DEFAULT 0,
isPartitionedAttributeSet INTEGER DEFAULT 0, updateTime INTEGER DEFAULT 0,
CONSTRAINT moz_uniqueid UNIQUE (name, host, path, originAttributes)
)`);
const jetzt = Date.now();
datenbank
.prepare(
`INSERT INTO moz_cookies
(originAttributes,name,value,host,path,expiry,lastAccessed,creationTime,isSecure,isHttpOnly,inBrowserElement,sameSite,schemeMap,isPartitionedAttributeSet,updateTime)
VALUES ('', 'pl_sitzung', ?, ?, '/', ?, ?, ?, ?, 1, 0, 1, ?, 0, ?)`,
)
.run(
token,
hostname,
Math.floor(jetzt / 1000) + 3600,
jetzt * 1000,
jetzt * 1000,
protocol === "https:" ? 1 : 0,
protocol === "https:" ? 2 : 1,
jetzt * 1000,
);
datenbank.close();
return verzeichnis; return verzeichnis;
} }
function aufnehmen(profil, pfad, datei, breite, hoehe) { /**
execFileSync( * Nimmt eine Seite auf. Bewusst asynchron: Der Vermittler läuft im selben Prozess, und ein
"firefox", * blockierender Aufruf würde ihn währenddessen stumm schalten – Firefox liefe in die Zeitüberschreitung.
[ */
"--profile", function aufnehmen(profil, pfad, datei, breite, hoehe, adresse) {
profil, return new Promise((fertig, fehlgeschlagen) => {
"--headless", const vorgang = spawn(
"--window-size", "firefox",
`${breite},${hoehe}`, [
"--screenshot", "--profile",
datei, profil,
`${BASIS}${pfad}`, "--headless",
], "--window-size",
{ stdio: "ignore", timeout: 120_000 }, `${breite},${hoehe}`,
); "--screenshot",
datei,
`${adresse}${pfad}`,
],
{ stdio: "ignore", timeout: 120_000 },
);
vorgang.on("error", fehlgeschlagen);
vorgang.on("close", () => fertig());
});
} }
async function main() { async function main() {
@@ -133,35 +153,47 @@ async function main() {
mkdirSync(ZIEL, { recursive: true }); mkdirSync(ZIEL, { recursive: true });
const token = await sitzungsToken(); const token = await sitzungsToken();
const { server, port } = await vermittlerStarten(token);
const adresse = `http://127.0.0.1:${port}`;
const hell = profilVorbereiten("hell", token, false); const hell = profilVorbereiten("hell", false);
console.log(`Bilder nach ${ZIEL}`); console.log(`Bilder nach ${ZIEL}`);
// Die Anmeldeseite zeigt sich auch mit gültiger Sitzung, sie ist öffentlich. // Die Anmeldeseite zeigt sich auch mit gültiger Sitzung, sie ist öffentlich.
aufnehmen(hell, "/login", path.join(ZIEL, "login.png"), 1280, 900); await aufnehmen(hell, "/login", path.join(ZIEL, "login.png"), 1280, 900, BASIS);
console.log(" login.png"); console.log(" login.png");
for (const seite of SEITEN) { for (const seite of [...SEITEN, ...ZUSATZ]) {
aufnehmen(hell, seite.pfad, path.join(ZIEL, `${seite.name}.png`), 1680, 1150); await aufnehmen(hell, seite.pfad, path.join(ZIEL, `${seite.name}.png`), 1680, 1150, adresse);
console.log(` ${seite.name}.png – ${seite.titel}`); console.log(` ${seite.name}.png – ${seite.titel}`);
} }
if (ALLE) { if (ALLE) {
const dunkel = profilVorbereiten("dunkel", token, true); const dunkel = profilVorbereiten("dunkel", true);
for (const seite of ["dashboard", "hosts", "rechnungen", "warnungen"]) { for (const seite of ["dashboard", "hosts", "rechnungen", "warnungen"]) {
const eintrag = SEITEN.find((s) => s.name === seite); const eintrag = SEITEN.find((s) => s.name === seite);
aufnehmen(dunkel, eintrag.pfad, path.join(ZIEL, `${seite}-dunkel.png`), 1680, 1150); await aufnehmen(dunkel, eintrag.pfad, path.join(ZIEL, `${seite}-dunkel.png`), 1680, 1150, adresse);
console.log(` ${seite}-dunkel.png`); console.log(` ${seite}-dunkel.png`);
} }
for (const seite of ["dashboard", "hosts"]) { for (const seite of ["dashboard", "hosts"]) {
const eintrag = SEITEN.find((s) => s.name === seite); const eintrag = SEITEN.find((s) => s.name === seite);
aufnehmen(hell, eintrag.pfad, path.join(ZIEL, `mobil-${seite}.png`), 390, 1500); await aufnehmen(hell, eintrag.pfad, path.join(ZIEL, `mobil-${seite}.png`), 390, 1500, adresse);
console.log(` mobil-${seite}.png`); console.log(` mobil-${seite}.png`);
} }
} }
server.close();
if (existsSync(PROFIL_BASIS)) rmSync(PROFIL_BASIS, { recursive: true, force: true }); if (existsSync(PROFIL_BASIS)) rmSync(PROFIL_BASIS, { recursive: true, force: true });
// Sicherung gegen stillen Fehlschlag: Alle Aufnahmen gleich groß heißt, jede zeigt die Anmeldeseite.
const groessen = SEITEN.map((seite) => statSync(path.join(ZIEL, `${seite.name}.png`)).size);
if (new Set(groessen).size === 1) {
throw new Error(
"Alle Aufnahmen sind gleich groß – vermutlich zeigt jede die Anmeldeseite. Sitzung prüfen.",
);
}
console.log("Fertig."); console.log("Fertig.");
} }
+35
View File
@@ -46,6 +46,16 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
select: { id: true, name: true }, select: { id: true, name: true },
}); });
// Welche Cluster-Mitglieder melden sich selbst? Fehlende Knoten haben keinen Agent (#022).
const geschwister =
host.clusterMitglieder.length > 0
? await prisma.host.findMany({
where: { hostname: { in: host.clusterMitglieder, mode: "insensitive" } },
select: { id: true, hostname: true, subStatus: true, letzterReportAm: true },
})
: [];
const meldende = new Map(geschwister.map((eintrag) => [eintrag.hostname.toLowerCase(), eintrag]));
const jetzt = new Date(); const jetzt = new Date();
const meldetNicht = !host.letzterReportAm || host.letzterReportAm < meldungsGrenze(jetzt); const meldetNicht = !host.letzterReportAm || host.letzterReportAm < meldungsGrenze(jetzt);
const socketAbweichung = const socketAbweichung =
@@ -113,6 +123,31 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
<Zeile bezeichnung="Cluster / Node"> <Zeile bezeichnung="Cluster / Node">
{host.clusterName ? `${host.clusterName} / ${host.nodeName ?? "?"}` : "–"} {host.clusterName ? `${host.clusterName} / ${host.nodeName ?? "?"}` : "–"}
</Zeile> </Zeile>
{host.clusterMitglieder.length > 0 ? (
<Zeile bezeichnung={`Cluster-Knoten (${host.clusterMitglieder.length})`}>
<span className="d-inline-block text-start">
{host.clusterMitglieder.map((mitglied) => {
const eintrag = meldende.get(mitglied.toLowerCase());
return (
<span key={mitglied} className="d-block small">
{eintrag ? (
<Link href={`/hosts/${eintrag.id}`} className="text-decoration-none">
{mitglied}
</Link>
) : (
mitglied
)}{" "}
{eintrag ? (
<Badge farbe="success">meldet sich</Badge>
) : (
<Badge farbe="warning">kein Agent</Badge>
)}
</span>
);
})}
</span>
</Zeile>
) : null}
<Zeile bezeichnung="IP-Adressen">{host.ips.join(", ") || "–"}</Zeile> <Zeile bezeichnung="IP-Adressen">{host.ips.join(", ") || "–"}</Zeile>
<Zeile bezeichnung="CPU">{host.cpuModell ?? "–"}</Zeile> <Zeile bezeichnung="CPU">{host.cpuModell ?? "–"}</Zeile>
<Zeile bezeichnung="Sockets (lizenziert / physisch)"> <Zeile bezeichnung="Sockets (lizenziert / physisch)">
+1
View File
@@ -36,6 +36,7 @@ export const reportSchema = z.object({
ips: z.array(z.string().max(64)).max(32).nullish(), ips: z.array(z.string().max(64)).max(32).nullish(),
clusterName: nullbarerText, clusterName: nullbarerText,
nodeName: nullbarerText, nodeName: nullbarerText,
clusterMitglieder: z.array(z.string().max(255)).max(64).nullish(),
kernel: nullbarerText, kernel: nullbarerText,
uptimeSekunden: z.number().int().nonnegative().nullish(), uptimeSekunden: z.number().int().nonnegative().nullish(),
cpuModell: nullbarerText, cpuModell: nullbarerText,
+1
View File
@@ -195,6 +195,7 @@ export async function verarbeiteReport(
serverId: serverId ?? host.serverId, serverId: serverId ?? host.serverId,
clusterName: eingabe.host.clusterName ?? host.clusterName, clusterName: eingabe.host.clusterName ?? host.clusterName,
nodeName: eingabe.host.nodeName ?? host.nodeName, nodeName: eingabe.host.nodeName ?? host.nodeName,
clusterMitglieder: eingabe.host.clusterMitglieder ?? host.clusterMitglieder,
ips: eingabe.host.ips ?? host.ips, ips: eingabe.host.ips ?? host.ips,
sockets: eingabe.host.sockets ?? host.sockets, sockets: eingabe.host.sockets ?? host.sockets,
cpuModell: eingabe.host.cpuModell ?? host.cpuModell, cpuModell: eingabe.host.cpuModell ?? host.cpuModell,
+2
View File
@@ -30,6 +30,8 @@ export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKonte
hosts: hosts.map((host) => ({ hosts: hosts.map((host) => ({
id: host.id, id: host.id,
hostname: host.hostname, hostname: host.hostname,
clusterName: host.clusterName,
clusterMitglieder: host.clusterMitglieder,
kundeId: host.kundeId, kundeId: host.kundeId,
kundeName: host.kunde?.name ?? null, kundeName: host.kunde?.name ?? null,
subKey: host.subKey, subKey: host.subKey,
+36
View File
@@ -22,6 +22,8 @@ export interface AlertKandidat {
export interface HostDaten { export interface HostDaten {
id: string; id: string;
hostname: string; hostname: string;
clusterName: string | null;
clusterMitglieder: string[];
kundeId: string | null; kundeId: string | null;
kundeName: string | null; kundeName: string | null;
subKey: string | null; subKey: string | null;
@@ -287,6 +289,39 @@ const hostOhneKunde: Regel = {
}, },
}; };
const clusterKnotenOhneAgent: Regel = {
name: "cluster-knoten-ohne-agent",
beschreibung: "Ein Cluster-Knoten meldet sich nicht beim Dashboard",
standardSchwellwerte: {},
pruefe(kontext) {
// Alle gemeldeten Hostnamen – ein Knoten gilt als bekannt, wenn er selbst meldet.
const meldende = new Set(kontext.hosts.map((host) => host.hostname.toLowerCase()));
const fehlende = new Map<string, { cluster: string; melder: HostDaten }>();
for (const host of kontext.hosts) {
if (!host.clusterName) continue;
for (const mitglied of host.clusterMitglieder) {
const name = mitglied.trim();
if (!name || meldende.has(name.toLowerCase())) continue;
if (!fehlende.has(name.toLowerCase())) {
fehlende.set(name.toLowerCase(), { cluster: host.clusterName, melder: host });
}
}
}
return [...fehlende.entries()].map(([name, eintrag]) => ({
fingerprint: `cluster-knoten-ohne-agent:${eintrag.cluster}:${name}`,
regel: "cluster-knoten-ohne-agent",
schwere: "WARNUNG" as AlertSchwere,
titel: "Cluster-Knoten ohne Agent",
beschreibung: `${name} gehört zum Cluster ${eintrag.cluster} (gemeldet von ${eintrag.melder.hostname}), meldet sich aber nicht – dort fehlt der Agent`,
hostId: eintrag.melder.id,
kundeId: eintrag.melder.kundeId,
laufzeitId: null,
}));
},
};
const proxmoxRechnungFaellig: Regel = { const proxmoxRechnungFaellig: Regel = {
name: "proxmox-rechnung-faellig", name: "proxmox-rechnung-faellig",
beschreibung: "Proxmox-Rechnung ist unbezahlt und wird fällig", beschreibung: "Proxmox-Rechnung ist unbezahlt und wird fällig",
@@ -378,6 +413,7 @@ export const REGELN: Regel[] = [
serverIdGeaendert, serverIdGeaendert,
keyMehrfach, keyMehrfach,
hostOhneKunde, hostOhneKunde,
clusterKnotenOhneAgent,
proxmoxRechnungFaellig, proxmoxRechnungFaellig,
kundenrechnungFehlt, kundenrechnungFehlt,
kundenrechnungUnbezahlt, kundenrechnungUnbezahlt,
+23
View File
@@ -144,6 +144,29 @@ productname: Proxmox "VE" \\ Subscription mit Tab`,
}); });
}); });
it("meldet die Cluster-Mitglieder", () => {
befehlErsetzen("pvesubscription", PVE_SUBSCRIPTION);
befehlErsetzen(
"pvecm",
`Membership information
----------------------
Nodeid Votes Name
1 1 hv-rz5-pve-01 (local)
2 1 hv-rz5-pve-02
3 1 hv-rz5-pve-03`,
);
const report = reportErzeugen() as { host: { clusterMitglieder: string[] } };
expect(report.host.clusterMitglieder).toEqual(["hv-rz5-pve-01", "hv-rz5-pve-02", "hv-rz5-pve-03"]);
});
it("meldet eine leere Liste, wenn der Host in keinem Cluster ist", () => {
befehlErsetzen("pvesubscription", PVE_SUBSCRIPTION);
const report = reportErzeugen() as { host: { clusterMitglieder: string[] } };
expect(report.host.clusterMitglieder).toEqual([]);
});
it("gibt die Version aus", () => { it("gibt die Version aus", () => {
const ausgabe = execFileSync("bash", [AGENT, "--version"], { encoding: "utf8" }).trim(); const ausgabe = execFileSync("bash", [AGENT, "--version"], { encoding: "utf8" }).trim();
expect(ausgabe).toBe("0.2.0"); expect(ausgabe).toBe("0.2.0");
+1 -1
View File
@@ -30,7 +30,7 @@ describe.runIf(datenbankVerfuegbar)("Abgleich der Warnungen", () => {
it("legt die Standard-Einstellungen für alle Regeln an", async () => { it("legt die Standard-Einstellungen für alle Regeln an", async () => {
const einstellungen = await testPrisma.warnregelEinstellung.findMany(); const einstellungen = await testPrisma.warnregelEinstellung.findMany();
expect(einstellungen).toHaveLength(10); expect(einstellungen).toHaveLength(11);
const ablauf = einstellungen.find((e) => e.regel === "subscription-laeuft-ab"); const ablauf = einstellungen.find((e) => e.regel === "subscription-laeuft-ab");
expect(ablauf?.schwellwerte).toEqual({ tage: [30, 14, 7] }); expect(ablauf?.schwellwerte).toEqual({ tage: [30, 14, 7] });
}); });
+62 -3
View File
@@ -18,6 +18,8 @@ function host(überschreibungen: Partial<HostDaten> = {}): HostDaten {
return { return {
id: "h1", id: "h1",
hostname: "pve-01", hostname: "pve-01",
clusterName: null,
clusterMitglieder: [],
kundeId: "k1", kundeId: "k1",
kundeName: "Brandl Elektrotechnik GmbH", kundeName: "Brandl Elektrotechnik GmbH",
subKey: "pve1c-aaaa", subKey: "pve1c-aaaa",
@@ -70,9 +72,9 @@ function ausRegel(k: RegelKontext, name: string) {
} }
describe("Warnregeln", () => { describe("Warnregeln", () => {
it("prüft alle zehn Regeln, wenn nichts abgeschaltet ist", () => { it("prüft alle elf Regeln, wenn nichts abgeschaltet ist", () => {
expect(REGELN).toHaveLength(10); expect(REGELN).toHaveLength(11);
expect(new Set(REGELN.map((r) => r.name)).size).toBe(10); expect(new Set(REGELN.map((r) => r.name)).size).toBe(11);
}); });
describe("Subscription läuft ab", () => { describe("Subscription läuft ab", () => {
@@ -297,6 +299,63 @@ describe("Warnregeln", () => {
}); });
}); });
describe("Cluster-Knoten ohne Agent", () => {
it("meldet einen Knoten, der im Cluster steht, sich aber nicht meldet", () => {
const k = kontext({
hosts: [
host({
id: "h1",
hostname: "pve-node-01",
clusterName: "rz5",
clusterMitglieder: ["pve-node-01", "pve-node-02", "pve-node-03"],
}),
host({ id: "h2", hostname: "pve-node-02", clusterName: "rz5", clusterMitglieder: [] }),
],
});
const kandidaten = ausRegel(k, "cluster-knoten-ohne-agent");
expect(kandidaten).toHaveLength(1);
expect(kandidaten[0].beschreibung).toContain("pve-node-03");
expect(kandidaten[0].fingerprint).toBe("cluster-knoten-ohne-agent:rz5:pve-node-03");
});
it("meldet nichts, wenn alle Knoten melden", () => {
const mitglieder = ["pve-node-01", "pve-node-02"];
const k = kontext({
hosts: [
host({ id: "h1", hostname: "pve-node-01", clusterName: "rz5", clusterMitglieder: mitglieder }),
host({ id: "h2", hostname: "pve-node-02", clusterName: "rz5", clusterMitglieder: mitglieder }),
],
});
expect(ausRegel(k, "cluster-knoten-ohne-agent")).toHaveLength(0);
});
it("beachtet Groß- und Kleinschreibung nicht", () => {
const k = kontext({
hosts: [
host({ id: "h1", hostname: "PVE-Node-01", clusterName: "rz5", clusterMitglieder: ["pve-node-01"] }),
],
});
expect(ausRegel(k, "cluster-knoten-ohne-agent")).toHaveLength(0);
});
it("meldet nichts für Hosts ohne Cluster", () => {
const k = kontext({ hosts: [host({ clusterName: null, clusterMitglieder: [] })] });
expect(ausRegel(k, "cluster-knoten-ohne-agent")).toHaveLength(0);
});
it("meldet jeden fehlenden Knoten nur einmal, auch wenn mehrere ihn melden", () => {
const mitglieder = ["pve-node-01", "pve-node-02", "pve-node-03"];
const k = kontext({
hosts: [
host({ id: "h1", hostname: "pve-node-01", clusterName: "rz5", clusterMitglieder: mitglieder }),
host({ id: "h2", hostname: "pve-node-02", clusterName: "rz5", clusterMitglieder: mitglieder }),
],
});
expect(ausRegel(k, "cluster-knoten-ohne-agent")).toHaveLength(1);
});
});
it("überspringt abgeschaltete Regeln", () => { it("überspringt abgeschaltete Regeln", () => {
const k = kontext({ const k = kontext({
hosts: [host({ kundeId: null, kundeName: null })], hosts: [host({ kundeId: null, kundeName: null })],