diff --git a/README.md b/README.md index b869de9..dbf2da7 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,7 @@ task_timeout = 15m retries = 2 # Wiederholungen, wenn eine Sperre belegt ist retry_delay = 60s pause_between = 0s # Pause zwischen zwei Gästen +space_settle = 30s # Geduld, bis gelöschter Platz zurückkommt run_on_start = no # beim Start sofort einen Durchlauf machen? dry_run = no # yes = nichts wirklich tun, nur protokollieren description = pvesnap | Gruppe: {group} | erstellt: {datetime} | Vorhaltezeit: {keep_time} @@ -209,6 +210,42 @@ Beides ist kombinierbar — gelöscht wird, was *eine* der beiden Grenzen reißt Mindestens eine der beiden Angaben muss gesetzt sein, sonst würde die Zahl der Snapshots unbegrenzt wachsen (`pvesnap check` weist darauf hin). +### Platz vor dem Snapshot + +```ini +space_min_free = 20% # so viel bleibt auf jedem Storage der VM auch danach frei (auch 2T; 0 = aus) +space_keep = 7 # so viele bleiben auch bei Platzmangel stehen +``` + +Geprüft wird `frei ≥ sofortiger Verbrauch + space_min_free`. Sofort kosten nur der +mitgesicherte RAM und dick angelegte ZFS-Zvols, deren Kosten pvesnap exakt berechnet +(bei Gästen auf anderen Nodes per `ssh`). Die Rechnung zeigt `pvesnap --dry-run run +--force`. + +Fehlt der Platz, löscht pvesnap vom ältesten pvesnap-Snapshot der VM an, über +alle Gruppen hinweg, bis er reicht, und legt dann den neuen an. Sind nur noch die +neuesten `space_keep` übrig und es reicht trotzdem nicht, entfällt der Snapshot +dieser VM mit einem Fehler im Protokoll, und die nächste kommt dran. Von Hand +angelegte Snapshots bleiben unangetastet. Ceph und ZFS geben Platz erst im +Hintergrund frei, deshalb wartet pvesnap nach jedem Löschen, bis sich +`space_settle` (Vorgabe `30s`, in `[global]`) lang nichts mehr tut. + +Fällt ein Snapshot aus, warnt pvesnap auf Wunsch per Mail, mit STARTTLS oder +SSL/TLS. Einzurichten im Editor (`pvesnap config`, Taste `m`) oder von Hand: + +```ini +[mail] +enabled = yes +server = mail.example.org +security = starttls # oder ssl; none nur ohne Anmeldung +user = pvesnap@example.org +password = geheim +sender = pvesnap@example.org +recipients = admin@example.org +``` + +`pvesnap mail-test` schickt eine Test-Mail. + ### Welche VMs? Alle Angaben wirken als **ODER**, Ausschlüsse gewinnen immer: @@ -267,11 +304,12 @@ pvesnap status # Übersicht: Gruppen, letzte und nächste Lä pvesnap vms # alle VMs und die Gruppen, in denen sie stecken pvesnap list # vorhandene pvesnap-Snapshots (-a = auch fremde) pvesnap check # Konfiguration prüfen +pvesnap mail-test # Test-Mail senden pvesnap config # ncurses-Editor pvesnap run # jetzt fällige Gruppen ausführen pvesnap run --force -g täglich # eine Gruppe sofort ausführen -pvesnap run --force --dry-run # Probelauf, ändert nichts +pvesnap --dry-run run --force # Probelauf, ändert nichts pvesnap prune -g stündlich # nur aufräumen, keine neuen Snapshots systemctl status pvesnap diff --git a/config/pvesnap.conf.example b/config/pvesnap.conf.example index 1280207..de52f2a 100644 --- a/config/pvesnap.conf.example +++ b/config/pvesnap.conf.example @@ -39,6 +39,11 @@ retry_delay = 60s # Wartezeit vor dem naechsten Versuch # Druck vom Storage-Lock; 0 = ohne Pause. pause_between = 0s +# Wie lange nach dem Loeschen bei Platzmangel auf den frei werdenden Platz +# gewartet wird. Ceph und ZFS geben ihn erst im Hintergrund frei; solange der +# freie Platz noch waechst, wird weiter gewartet. +space_settle = 30s + # yes = beim Dienststart sofort einen Durchlauf machen, # no = auf den naechsten regulaeren Termin warten. run_on_start = no @@ -57,6 +62,29 @@ dry_run = no description = pvesnap | Gruppe: {group} | erstellt: {datetime} | Vorhaltezeit: {keep_time} | max: {keep_count} +# --------------------------------------------------------------------- +# [mail] meldet per Mail, wenn wegen Platzmangels ein Snapshot ausfaellt. +# Bequemer im Editor: "pvesnap config", Taste m. Testen: pvesnap mail-test +# +# security = starttls TLS nach dem Verbinden (Vorgabe, Port 587) +# security = ssl SSL/TLS von Anfang an (Port 465) +# security = none unverschluesselt, nur ohne Anmeldung +# +# Steht hier ein Passwort, sollte nur root die Datei lesen duerfen +# (chmod 600); der Editor stellt das beim Speichern selbst ein. +# --------------------------------------------------------------------- +# [mail] +# enabled = yes +# server = mail.example.org +# security = starttls +# port = 587 +# tls_verify = yes # no nur bei selbstsignierten Zertifikaten +# user = pvesnap@example.org +# password = geheim +# sender = pvesnap@example.org +# recipients = admin@example.org, technik@example.org + + # --------------------------------------------------------------------- # [defaults] gilt als Vorgabe fuer *alle* Gruppen und kann in jeder # Gruppe einzeln ueberschrieben werden. @@ -65,6 +93,9 @@ description = pvesnap | Gruppe: {group} | erstellt: {datetime} | Vorhaltezeit: { enabled = yes skip_stopped = no vmstate = no +# Platz vor jedem Snapshot - Erklaerung weiter unten bei den Gruppen. +space_min_free = 20% +space_keep = 7 # --------------------------------------------------------------------- @@ -86,6 +117,18 @@ vmstate = no # keep_time = 7d nichts aelter als 7 Tage # keep_min = 1 so viele bleiben immer stehen # +# Platz vor jedem Snapshot, je VM geprueft: +# space_min_free = 20% so viel muss auf jedem Storage der VM auch nach dem +# Snapshot noch frei sein. Was der Snapshot sofort +# belegt (dicke ZFS-Zvols, RAM), kommt dazu. +# Vorschau: pvesnap --dry-run run --force +# (auch absolut, z.B. 20G; 0 = keine Pruefung) +# space_keep = 7 reicht es nicht, wird vom aeltesten pvesnap-Snapshot +# der VM an geloescht, gruppenuebergreifend, bis es +# reicht - die neuesten 7 bleiben aber stehen. +# Reicht es dann immer noch nicht: kein Snapshot fuer +# diese VM, weiter mit der naechsten. +# # Auswahl der Gaeste (alles kombinierbar, wirkt als ODER): # all = yes # vmids = 100,101,105-110 diff --git a/handbuch/docs/nachschlagen/befehle.md b/handbuch/docs/nachschlagen/befehle.md index 6374e13..38f674b 100644 --- a/handbuch/docs/nachschlagen/befehle.md +++ b/handbuch/docs/nachschlagen/befehle.md @@ -25,6 +25,7 @@ pvesnap [globale Optionen] [optionen] | `vms` | alle VMs und Container mit den Gruppen, in denen sie stecken | | `list` | vorhandene pvesnap-Snapshots | | `check` | Konfiguration prüfen | +| `mail-test` | Test-Mail mit den Einstellungen aus [`[mail]`](konfiguration.md#mail) senden | | `config` (oder `edit`) | [ncurses-Editor](../sichern/editor.md) | | `run` | fällige Gruppen jetzt ausführen | | `prune` | nur aufräumen, nichts anlegen | @@ -41,7 +42,7 @@ pvesnap [globale Optionen] [optionen] ```bash pvesnap run # was fällig ist pvesnap run --force -g taeglich # diese Gruppe sofort -pvesnap run --force --dry-run # Probelauf +pvesnap --dry-run run --force # Probelauf pvesnap run -g taeglich -g monatlich # zwei Gruppen ``` diff --git a/handbuch/docs/nachschlagen/fehlersuche.md b/handbuch/docs/nachschlagen/fehlersuche.md index 94931ac..68dd622 100644 --- a/handbuch/docs/nachschlagen/fehlersuche.md +++ b/handbuch/docs/nachschlagen/fehlersuche.md @@ -20,6 +20,22 @@ pvesnap check --- +## Kein Snapshot wegen Platzmangel + +``` +VM 101 (web): kein Snapshot angelegt, zu wenig Platz ('data': 8.0G frei, 25.6G gebraucht) und nichts mehr loeschbar +``` + +Auf dem Storage ist weniger als `space_min_free` frei, und von dieser VM sind +nur noch die neuesten `space_keep` pvesnap-Snapshots übrig. Der Platz steckt +also woanders: bei anderen VMs oder von Hand angelegten Snapshots. Abhilfe schafft Aufräumen, mehr Speicher oder ein kleinerer Wert, +siehe [Platz vor dem Snapshot](../sichern/vorhaltezeit.md#platz-vor-dem-snapshot). + +Ist der Speicher schon voll, stehen die Notfall-Befehle für Ceph und ZFS unter +[Früher „voll“ sagen](speicher.md#fruher-voll-sagen-damit-luft-zum-loschen-bleibt). + +--- + ## Storage-Lock ``` @@ -272,7 +288,7 @@ und danach wieder gestartet. ```bash pvesnap -v check # ausführlich -pvesnap -v run --force --dry-run # zeigt jeden pvesh-Aufruf +pvesnap -v --dry-run run --force # zeigt jeden pvesh-Aufruf journalctl -u pvesnap -n 200 --no-pager ``` diff --git a/handbuch/docs/nachschlagen/konfiguration.md b/handbuch/docs/nachschlagen/konfiguration.md index 9ece36a..6f61195 100644 --- a/handbuch/docs/nachschlagen/konfiguration.md +++ b/handbuch/docs/nachschlagen/konfiguration.md @@ -47,6 +47,7 @@ systemctl reload pvesnap | `retries` | `2` | zusätzliche Versuche, wenn eine Sperre belegt ist | | `retry_delay` | `60s` | Wartezeit vor dem nächsten Versuch | | `pause_between` | `0s` | Pause zwischen zwei Gästen | +| `space_settle` | `30s` | Geduld, bis nach dem Löschen bei Platzmangel der Platz zurückkommt | | `run_on_start` | `no` | beim Dienststart sofort einen Durchlauf machen | | `dry_run` | `no` | `yes` = nichts wirklich tun, nur protokollieren | | `description` | siehe unten | Standard-Beschreibung aller Snapshots | @@ -68,6 +69,47 @@ description = pvesnap | Gruppe: {group} | erstellt: {datetime} | Vorhaltezeit: { --- +## `[mail]` + +Meldet per Mail, wenn wegen Platzmangels ein Snapshot ausfällt. Am bequemsten +im Editor: `pvesnap config`, Taste ++m++, dort sendet ++t++ eine Test-Mail. Von +der Kommandozeile geht das mit `pvesnap mail-test`. + +```ini +[mail] +enabled = yes +server = mail.example.org +security = starttls +user = pvesnap@example.org +password = geheim +sender = pvesnap@example.org +recipients = admin@example.org, technik@example.org +``` + +| Schlüssel | Vorgabe | | +|---|---|---| +| `enabled` | `no` | Alarm einschalten | +| `server` | — | SMTP-Server | +| `security` | `starttls` | `starttls` (TLS nach dem Verbinden), `ssl` (SSL/TLS von Anfang an) oder `none` | +| `port` | passend zu `security` | `587`, `465` bzw. `25` | +| `tls_verify` | `yes` | Zertifikat des Servers prüfen; `no` nur bei selbstsignierten Zertifikaten | +| `user`, `password` | — | Anmeldung; ohne `user` wird nicht angemeldet | +| `sender` | — | Absender | +| `recipients` | — | Empfänger, mehrere mit Komma | + +* Bietet der Server kein STARTTLS an, schlägt der Versand fehl. Auf Klartext + ausgewichen wird nie. +* Eine Anmeldung mit `security = none` lehnt `pvesnap check` ab, weil das Passwort + sonst im Klartext übertragen würde. +* Das Passwort steht im Klartext in der Datei. Der Editor setzt beim Speichern + die Rechte `600`; wer von Hand einträgt, macht das selbst. `pvesnap check` warnt, + wenn jeder die Datei lesen darf. +* Je Gruppe und Lauf geht höchstens eine Mail heraus, in der alle betroffenen + Gäste stehen. Bleibt das Problem bestehen, kommt sie bei jedem Lauf erneut. +* Scheitert der Versand, steht das im Protokoll. Die Snapshots laufen weiter. + +--- + ## `[defaults]` Vorgaben für **alle** Gruppen. Jede Gruppe darf sie einzeln überschreiben. @@ -121,9 +163,12 @@ Einzelheiten unter [Zeitpläne](../sichern/zeitplan.md). | `keep_count` | `0` | höchstens so viele je VM und Gruppe (`0` = unbegrenzt) | | `keep_time` | `0` | nichts älter als (`0` = unbegrenzt) | | `keep_min` | `0` | so viele bleiben in jedem Fall stehen | +| `space_min_free` | `20%` | so viel muss vor dem Snapshot frei sein, auch absolut (`20G`); `0` = keine Prüfung | +| `space_keep` | `7` | so viele bleiben je VM auch bei Platzmangel stehen, über alle Gruppen gezählt | **Mindestens eine von `keep_count` und `keep_time` muss gesetzt sein.** -Einzelheiten unter [Vorhaltezeit](../sichern/vorhaltezeit.md). +Einzelheiten unter [Vorhaltezeit](../sichern/vorhaltezeit.md) und +[Platz vor dem Snapshot](../sichern/vorhaltezeit.md#platz-vor-dem-snapshot). ### Auswahl der Gäste @@ -193,6 +238,13 @@ Damit die Datei lesbar bleibt, versteht pvesnap eine Reihe deutscher Synonyme: | `anzahl`, `max_anzahl`, `behalte_anzahl` | `keep_count` | | `vorhaltezeit`, `behalte_zeit`, `max_alter` | `keep_time` | | `mindestens` | `keep_min` | +| `mindestens_frei` | `space_min_free` | +| `verschluesselung` | `security` | +| `benutzer` | `user` | +| `passwort` | `password` | +| `absender` | `sender` | +| `empfaenger` | `recipients` | +| `platzmangel_behalten` | `space_keep` | | `alle` | `all` | | `namen` | `names` | | `typen` | `types` | diff --git a/handbuch/docs/nachschlagen/speicher.md b/handbuch/docs/nachschlagen/speicher.md index 53d0857..c28b134 100644 --- a/handbuch/docs/nachschlagen/speicher.md +++ b/handbuch/docs/nachschlagen/speicher.md @@ -182,6 +182,161 @@ nennt den Grund, bevor etwas angelegt wird. --- +## Früher „voll“ sagen, damit Luft zum Löschen bleibt + +Auf einem wirklich vollen Speicher stehen die VMs, und auch das Aufräumen kann +scheitern, weil Löschen selbst ein Schreibvorgang ist. Deshalb lohnt es sich, den Speicher schon bei etwa +80 % als voll zu behandeln. Im Notfall gibt man die Grenze kurz frei, räumt auf +und setzt sie wieder zurück. + +Das gilt zusätzlich zu [`space_min_free`](../sichern/vorhaltezeit.md#platz-vor-dem-snapshot). +Beide Grenzen wirken aufeinander: `space_min_free` rechnet mit dem, was Proxmox +als frei meldet, und das schrumpft mit der Grenze. Auf einem Test-Cluster sank +`MAX AVAIL` mit `full = 0.80` von 41 auf 29 GiB. Bei einer Grenze von 80 % passt +deshalb `space_min_free = 15%` besser als die Vorgabe: pvesnap greift dann bei etwa +70 % der vollsten OSD ein, mit der Vorgabe von 20 % schon bei etwa 65 %. + +### Ceph: die Ratios + +```bash +ceph osd dump | grep ratio # aktuelle Werte +ceph df # MAX AVAIL je Pool +ceph osd df # Belegung je OSD; die vollste entscheidet +ceph health detail # wer ist nearfull, backfillfull, full? +``` + +| Ratio | Vorgabe | Was passiert, wenn eine OSD sie erreicht | +|---|---|---| +| `nearfull` | `0.85` | nur eine Warnung | +| `backfillfull` | `0.90` | Ceph verschiebt keine Daten mehr auf diese OSD | +| `full` | `0.95` | **Schreibvorgänge der VMs hängen**, bis wieder Platz da ist | + +**Einrichten**, Beispiel „voll bei 80 %“. Die Werte müssen aufsteigend bleiben und +gelten sofort, ohne Neustart: + +```bash +ceph osd set-nearfull-ratio 0.70 +ceph osd set-backfillfull-ratio 0.75 +ceph osd set-full-ratio 0.80 +``` + +**Im Notfall**, wenn der Cluster `full` meldet: + +```bash +pvesnap list -a # was belegt den Platz? +qm delsnapshot # zuerst einfach versuchen +ceph -s # löst sich full von selbst? + +ceph osd set-full-ratio 0.90 # wenn es hängt, oder die VMs sofort weiter sollen +ceph osd set-full-ratio 0.80 # nach dem Aufräumen sofort zurück +``` + +Bei einem Test mit Ceph 18.2 ging das Löschen trotz `full` durch: Snapshot und +ganze Platte waren nach Sekunden weg, der Platz nach 15 Sekunden zurück, `full` löste +sich von selbst, und die VMs konnten weiterschreiben. Das Anheben allein ließ die +hängenden Schreibvorgänge ebenfalls sofort weiterlaufen. + +Verlassen sollte man sich darauf nicht. Der Test setzte `full` knapp unter die +Belegung, die OSDs hatten also noch reichlich echten Platz. Auf wirklich vollen OSDs +kann es anders aussehen, und ein früherer Vorfall mit einem vollgelaufenen Pool lief +genau so: Auch Löschen stand. + +!!! danger "Nie über `0.96`" + + Ab dem Failsafe-Wert (`ceph config get osd osd_failsafe_full_ratio`, Vorgabe + `0.97`) verweigern die OSDs selbst das Schreiben, egal was `full` sagt. Und + läuft eine OSD wirklich auf 100 %, startet sie unter Umständen nicht mehr. + Das Anheben ist keine Lösung, es verschafft nur Zeit zum Aufräumen. + +!!! warning "Eine niedrige Grenze hat einen Preis" + + Fällt ein Node aus, verteilt Ceph seine Daten auf die übrigen. Das hält bei + `backfillfull` an, mit `0.75` also früher, und der Cluster bleibt dann + ungeschützt, bis Platz da ist oder die Ratio angehoben wird. Bei 4 Nodes und + 3 Replikaten steigt die Belegung durch den Ausfall um ein Drittel. + +### ZFS: die Notreserve + +ZFS hat keine solche Ratio. Die Grenze muss man **vorher** einrichten, im Ernstfall +lässt sie sich nicht mehr nachholen. Am einfachsten ist ein leeres Dataset, das +Platz reserviert. Beispiel: ein Pool `tank` mit 10 TiB, voll bei 80 %: + +```bash +zpool list tank # Größe des Pools +zfs create -o refreservation=2T -o canmount=off -o mountpoint=none tank/notreserve +zfs list -o name,used,avail tank tank/notreserve +``` + +**Im Notfall**, wenn VMs `No space left on device` melden: + +```bash +zfs set refreservation=none tank/notreserve # Reserve freigeben + +pvesnap list -a # was belegt den Platz? +zfs list -t snapshot -o name,used -s used # die größten Snapshots zuletzt +qm delsnapshot # aufräumen + +zfs set refreservation=2T tank/notreserve # danach sofort zurück +``` + +Das Zurücksetzen gelingt erst, wenn genug Platz da ist. Sonst meldet ZFS +`size is greater than available space`, dann muss weiter aufgeräumt werden. + +**Was man dabei sieht** (gemessen an einem kleinen Testpool mit 200 MiB Reserve, +vollgeschrieben): + +| Stelle | Anzeige | | +|---|---|---| +| `zpool list` | `CAP 65 %` | die echte Belegung der Laufwerke, die Reserve zählt nicht | +| `zfs list` | `AVAIL 0` | das ist die Grenze, an die die VMs stoßen | +| Proxmox-Storage auf einem Dataset (`tank/data`) | 100 % von 631 MiB | die Gesamtgröße schrumpft um die Reserve | +| Proxmox-Storage auf dem Pool selbst (`tank`) | 100 % von 832 MiB | Gesamtgröße bleibt, die Reserve zählt als belegt | + +Der Pool wird dabei **nicht schreibgeschützt**. Lesen geht weiter, neue Daten +scheitern mit `No space left on device`, und das Löschen von Dateien und Snapshots +klappte im Test auch ohne Freigabe der Reserve. Für die VMs ist das trotzdem ein +Ausfall: Sie bekommen Schreibfehler, und je nach Einstellung hält Proxmox sie an. + +!!! note "Alternative: Quota" + + `zfs set quota=8T tank/data` wirkt genauso und zählt Snapshots mit, begrenzt + aber nur dieses Dataset und seine Kinder. Im Notfall `zfs set quota=9T tank/data`. + Die Notreserve ist einfacher, weil sie unabhängig vom Aufbau des Pools für alles + gilt. + +!!! warning "Dick angelegte Zvols: Ein Snapshot kostet sofort Platz" + + Ohne `sparse` am Storage reserviert Proxmox die ganze Platte, und diese + Reservierung muss auch nach dem Snapshot gelten. Alles, was seit dem letzten + Snapshot geschrieben wurde, wird deshalb beim Anlegen zusätzlich belegt. Fehlt + der Platz, scheitert der Snapshot mit `out of space`. Bei dünn angelegten Zvols + (`sparse 1`) kostet er nichts. + + Das lässt sich exakt ausrechnen: + + ```bash + zfs get -Hp refreservation,usedbyrefreservation tank/data/vm-100-disk-0 + # Kosten = refreservation - usedbyrefreservation + ``` + + pvesnap rechnet genau das ein und prüft `frei ≥ Kosten + space_min_free`, die + Reserve bleibt also auch nach dem Snapshot frei. `pvesnap --dry-run run --force` + zeigt die Rechnung, ohne etwas zu ändern: + + ``` + VM 100 (web): 'zfs': 1.1G frei, Snapshot belegt sofort 100.6M, danach frei 992.2M, Reserve 50.0M + ``` + + Die Werte liefert die Proxmox-API nicht. Läuft der Gast auf einem anderen Node, + fragt pvesnap sie per `ssh` ab, über den Schlüssel, den Proxmox im Cluster ohnehin + einrichtet. Klappt das nicht, steht eine Warnung im Protokoll und das Volume zählt + mit 0, gelöscht wird deswegen nichts. + + Das Löschen alter Snapshots gibt bei Zvols nur frei, was allein dieser Snapshot + festhält. Überschreibt eine VM immer dieselben Blöcke, ist das viel, sonst wenig. + +--- + ## Empfehlung | Lage | | diff --git a/handbuch/docs/nachschlagen/tasten.md b/handbuch/docs/nachschlagen/tasten.md index a65c438..950e09c 100644 --- a/handbuch/docs/nachschlagen/tasten.md +++ b/handbuch/docs/nachschlagen/tasten.md @@ -14,6 +14,7 @@ Alle Oberflächen auf einer Seite. Zum Ausdrucken und Danebenlegen. | ++c++ | Gruppe kopieren | | ++d++ | Gruppe löschen | | ++g++ | globale Einstellungen | +| ++m++ | Mail-Alarm einrichten, dort ++t++ für eine Test-Mail | | ++v++ | Übersicht: welche VM in welcher Gruppe | | ++s++ | speichern | | ++r++ | Dienst neu laden | diff --git a/handbuch/docs/schnellstart.md b/handbuch/docs/schnellstart.md index 3651ac2..197a897 100644 --- a/handbuch/docs/schnellstart.md +++ b/handbuch/docs/schnellstart.md @@ -92,7 +92,7 @@ Maschine gesichert wird und welche nicht. Nicht bis morgen früh warten wollen: ```bash -pvesnap run --force -g taeglich --dry-run # Probelauf, ändert nichts +pvesnap --dry-run run --force -g taeglich # Probelauf, ändert nichts pvesnap run --force -g taeglich # jetzt wirklich ``` diff --git a/handbuch/docs/sichern/betrieb.md b/handbuch/docs/sichern/betrieb.md index f031413..988b1fb 100644 --- a/handbuch/docs/sichern/betrieb.md +++ b/handbuch/docs/sichern/betrieb.md @@ -43,7 +43,7 @@ anderem: ```bash pvesnap run # jetzt fällige Gruppen ausführen pvesnap run --force -g taeglich # diese Gruppe sofort, egal ob fällig -pvesnap run --force --dry-run # Probelauf, ändert nichts +pvesnap --dry-run run --force # Probelauf, ändert nichts pvesnap prune -g stuendlich # nur aufräumen ``` @@ -60,7 +60,7 @@ zwischen zwei Terminen schlicht nichts. ```bash pvesnap -c /root/test.conf check -pvesnap -c /root/test.conf run --force --dry-run +pvesnap -c /root/test.conf --dry-run run --force ``` `-c` gilt für alle Unterbefehle. Zusammen mit `--dry-run` ein gefahrloser Weg, @@ -78,7 +78,7 @@ dry_run = yes oder einmalig: ```bash -pvesnap run --force --dry-run +pvesnap --dry-run run --force ``` Protokolliert, was geschähe, ohne etwas anzulegen oder zu löschen: diff --git a/handbuch/docs/sichern/editor.md b/handbuch/docs/sichern/editor.md index 4b665b5..c8f6e4f 100644 --- a/handbuch/docs/sichern/editor.md +++ b/handbuch/docs/sichern/editor.md @@ -123,6 +123,15 @@ einer neuen Konfiguration ohne Risiko. --- +## Mail-Alarm + +Taste ++m++ in der Gruppenliste: SMTP-Server, Verschlüsselung (STARTTLS oder +SSL/TLS), Anmeldung, Absender und Empfänger. ++t++ sendet sofort eine Test-Mail +mit den angezeigten Werten, auch wenn sie noch nicht gespeichert sind. Alles +Weitere steht unter [`[mail]`](../nachschlagen/konfiguration.md#mail). + +--- + ## Die Übersicht Taste ++v++ in der Gruppenliste: diff --git a/handbuch/docs/sichern/vorhaltezeit.md b/handbuch/docs/sichern/vorhaltezeit.md index 8618bf1..ed897a3 100644 --- a/handbuch/docs/sichern/vorhaltezeit.md +++ b/handbuch/docs/sichern/vorhaltezeit.md @@ -51,13 +51,99 @@ bleibt immer einer übrig. --- +## Platz vor dem Snapshot + +```ini +space_min_free = 20% # so viel muss frei sein (auch absolut: 20G; 0 = keine Prüfung) +space_keep = 7 # so viele bleiben auch bei Platzmangel stehen +``` + +Vor jedem Snapshot sieht pvesnap auf **allen Storages der VM** nach, ob +`space_min_free` auch danach noch frei bleibt. Die Werte oben gelten als Vorgabe, +auch ohne Eintrag in der Konfiguration. Wer mehr oder weniger möchte, setzt sie +in `[defaults]` für alle Gruppen, je Gruppe einzeln oder im +[Editor](editor.md) unter *Mindestens frei*. + +1. Genug Platz: Der Snapshot wird angelegt. +2. Zu wenig Platz: Der **älteste** pvesnap-Snapshot der VM wird gelöscht, dann wird + erneut geprüft. Das wiederholt sich, bis der Platz reicht. Weiter gelöscht + wird dann nicht. +3. Sind nur noch die neuesten `space_keep` übrig und es reicht immer noch + nicht, entsteht für diese VM **kein** Snapshot. Das steht als Fehler im + Protokoll, und die nächste VM kommt dran. + +**Was geprüft wird:** `frei ≥ sofortiger Verbrauch + space_min_free`. Die Reserve +bleibt also auch nach dem Snapshot frei und ist für das da, was die VM danach +schreibt, denn wie groß ein Snapshot später wird, lässt sich nicht vorhersagen. + +| | Sofortiger Verbrauch | +|---|---| +| Ceph, LVM-thin, dünne ZFS-Zvols, qcow2 | praktisch nichts | +| [Dicke ZFS-Zvols](../nachschlagen/speicher.md#zfs-die-notreserve) | alles seit dem letzten Snapshot Geschriebene, exakt berechnet | +| `vmstate = yes` | zusätzlich der zugewiesene RAM, als Obergrenze | + +Eine Vorschau zeigt `pvesnap --dry-run run --force`: Je Storage steht dort, was frei +ist, was der Snapshot belegen würde, was danach frei bleibt und wie groß die Reserve +ist. + +Dabei gilt: + +* Gezählt wird **je VM über alle Gruppen**, anders als bei der normalen + Vorhaltezeit. Ein voller Pool ist schlimmer als ein verlorener alter Stand. + Bei Platzmangel kann eine stündliche Gruppe deshalb auch die Monatsstände + derselben VM löschen, ebenso Snapshots von Gruppen, die es nicht mehr gibt. +* Halbfertige Einträge werden zuerst entfernt und zählen nicht mit. +* Das `keep_min` jeder Gruppe bleibt auch hier gewahrt. +* Von Hand angelegte Snapshots bleiben auch hier unangetastet. +* Lässt sich der Platz nicht feststellen, wird nichts gelöscht und der Snapshot + trotzdem versucht. +* Ein Klon-Quell-Snapshot (`snapshot is protected`) wird als Fehler gemeldet und + übergangen, weiter geht es mit dem nächsten. +* `pvesnap run --no-prune` löscht nie. Fehlt der Platz, entfällt der Snapshot. +* `pvesnap --dry-run run` löscht nichts und kann deshalb nicht sehen, wann es + reichen würde. Es nennt alle Snapshots, die infrage kämen. + +**Wie viel Puffer?** Die Schwelle bleibt frei für das, was die VMs bis zum +nächsten Lauf schreiben, und damit Löschen überhaupt noch funktioniert. Grobe +Werte aus einem Ceph-Cluster mit 8 OSDs und 3 Replikaten: + +| `space_min_free` | Vollste OSD | | +|---|---|---| +| `10%` | 86 % | schon über der `nearfull`-Warnung | +| `15%` | 82 % | knapp darunter, wenig Reaktionszeit | +| `20%` | 78 % | Vorgabe | +| `30%` | 69 % | für viel Schreiblast oder kleine Pools | + +Als Probe sollte der Puffer mindestens das Dreifache von dem abdecken, was im +Pool zwischen zwei Läufen im schlimmsten Fall neu belegt wird. Bei großen Pools +passt oft eine feste Größe wie `2T` besser. + +Fällt deshalb ein Snapshot aus, kann pvesnap +[per Mail warnen](../nachschlagen/konfiguration.md#mail). Wie man Ceph und ZFS +schon vorher eine Grenze setzt, damit im Notfall Luft zum Löschen bleibt, steht +unter [Früher „voll“ sagen](../nachschlagen/speicher.md#fruher-voll-sagen-damit-luft-zum-loschen-bleibt). + +!!! note "`space_settle`: warten, bis der Platz zurückkommt" + + Ceph und ZFS geben gelöschten Platz erst im Hintergrund frei. Würde pvesnap + sofort nachsehen, wäre scheinbar noch nichts frei und der nächste Snapshot + würde umsonst gelöscht. Deshalb wartet es, solange der freie Platz wächst, + und geht erst weiter, wenn sich `space_settle` lang (Vorgabe `30s`, in + `[global]`) nichts mehr getan hat. Auf einem Test-Cluster mit NVMe war der Platz + nach wenigen Sekunden zurück; ohne die Wartezeit wurde dort ein Snapshot zu viel + gelöscht. Bei HDDs und großen Snapshots dauert es länger. Das Löschen einer VM kann also dauern; + die Arbeitssperre bleibt währenddessen gehalten. + +--- + ## Getrennt je VM und Gruppe Beide Grenzen zählen **pro Gast und pro Gruppe**, nicht insgesamt. `keep_count = 24` bei 30 Maschinen heißt also: bis zu 720 Snapshots — aber je Maschine nur 24. Und eine VM, die in drei Gruppen steckt, hat 24 + 14 + 6 -Snapshots nebeneinander, die sich nicht gegenseitig verdrängen. +Snapshots nebeneinander, die sich nicht gegenseitig verdrängen. Einzige Ausnahme +ist der [Platzmangel](#platz-vor-dem-snapshot). --- @@ -89,7 +175,7 @@ vor-update-14.2 ← wird nie angefasst ```bash pvesnap prune # alle Gruppen pvesnap prune -g stuendlich # nur diese -pvesnap prune --dry-run # zeigt, was wegkäme +pvesnap --dry-run prune # zeigt, was wegkäme ``` Praktisch nach dem Verkürzen einer Vorhaltezeit: Der Dienst würde erst beim diff --git a/install.sh b/install.sh index 8327016..ae95caa 100755 --- a/install.sh +++ b/install.sh @@ -294,7 +294,7 @@ Fertig. Konfiguration pruefen : pvesnap check Uebersicht : pvesnap status VMs und Gruppen : pvesnap vms - Testlauf ohne Aenderung : pvesnap run --force --dry-run + Testlauf ohne Aenderung : pvesnap --dry-run run --force Nach Aenderungen : systemctl reload pvesnap Protokoll : journalctl -u pvesnap -f diff --git a/pvesnap/cli.py b/pvesnap/cli.py index f6e6c22..cb8c5c7 100644 --- a/pvesnap/cli.py +++ b/pvesnap/cli.py @@ -9,7 +9,7 @@ import sys from datetime import datetime from . import __version__ -from .config import DEFAULT_CONFIG_PATH, ConfigError, load_config +from .config import DEFAULT_CONFIG_PATH, ConfigError, load_config, world_readable_secret from .daemon import Daemon, SingleInstanceLock from .engine import run_group, select_guests from .naming import parse_name @@ -337,6 +337,10 @@ def cmd_check(args): print(" - %s" % problem, file=sys.stderr) return 1 environment = check_environment(require_pve=pvesh_available()) + if world_readable_secret(config): + environment = list(environment) + ["%s enthaelt das SMTP-Passwort und ist fuer alle " + "lesbar - bitte: chmod 600 %s" + % (config.path, config.path)] if environment: print("Hinweise zur Umgebung:", file=sys.stderr) for problem in environment: @@ -352,6 +356,24 @@ def cmd_check(args): return 0 +def cmd_mail_test(args): + from .mail import MailError, send_test_mail + mail = load_config(args.config).mail + problems = mail.problems() + if problems: + print("Mail-Einstellungen unvollstaendig:", file=sys.stderr) + for problem in problems: + print(" - %s" % problem, file=sys.stderr) + return 1 + try: + send_test_mail(mail) + except MailError as exc: + print("Test-Mail fehlgeschlagen: %s" % exc, file=sys.stderr) + return 1 + print("Test-Mail an %s gesendet." % ", ".join(mail.recipients)) + return 0 + + def cmd_config(args): from .tui import run_editor return run_editor(args.config) @@ -405,6 +427,9 @@ def build_parser(): p_check = sub.add_parser("check", help="Konfiguration pruefen") p_check.set_defaults(func=cmd_check) + p_mail = sub.add_parser("mail-test", help="Test-Mail mit den Einstellungen aus [mail] senden") + p_mail.set_defaults(func=cmd_mail_test) + for name in ("config", "edit"): p_config = sub.add_parser(name, help="Konfiguration im ncurses-Editor bearbeiten") p_config.set_defaults(func=cmd_config) diff --git a/pvesnap/config.py b/pvesnap/config.py index ba6eb21..77e6976 100644 --- a/pvesnap/config.py +++ b/pvesnap/config.py @@ -8,7 +8,8 @@ import re from dataclasses import dataclass, field, replace from .util import (format_bool, format_duration, format_vmid_list, parse_bool, - parse_duration, parse_list, parse_vmid_list, slugify) + parse_duration, parse_free_space, parse_list, parse_vmid_list, + slugify) DEFAULT_CONFIG_PATH = "/etc/pvesnap/pvesnap.conf" @@ -34,6 +35,12 @@ DEFAULT_DESCRIPTION = ( ) +MAIL_PORTS = {"starttls": 587, "ssl": 465, "none": 25} + +_MAIL_SECURITY = {"starttls": "starttls", "tls": "starttls", "ssl": "ssl", "smtps": "ssl", + "none": "none", "keine": "none", "off": "none", "aus": "none", "no": "none"} + + class ConfigError(Exception): """Fehler beim Lesen oder Pruefen der Konfiguration.""" @@ -86,6 +93,10 @@ class Group: keep_time: int = 0 # max. Alter in Sekunden (0 = unbegrenzt) keep_min: int = 0 # so viele bleiben immer stehen + # --- Platz ---------------------------------------------------------- + space_min_free: str = "20%" # so viel muss vorher frei sein (0 = aus) + space_keep: int = 7 # so viele bleiben je Gast bei Platzmangel + # --- Auswahl der Gaeste --------------------------------------------- all: bool = False vmids: list = field(default_factory=list) @@ -155,6 +166,7 @@ class GlobalConfig: retries: int = 2 # Wiederholungen bei belegten Sperren retry_delay: int = 60 # Wartezeit dazwischen pause_between: int = 0 # Pause zwischen zwei Gaesten + space_settle: int = 30 # Geduld, bis geloeschter Platz frei wird def validate(self): problems = [] @@ -174,9 +186,49 @@ class GlobalConfig: return problems +@dataclass +class MailConfig: + """SMTP-Zugang fuer den Alarm bei Platzmangel.""" + + enabled: bool = False + server: str = "" + port: int = 0 # 0 = passend zu 'security' + security: str = "starttls" # starttls | ssl | none + tls_verify: bool = True # Zertifikat des Servers pruefen + user: str = "" + password: str = "" + sender: str = "" + recipients: list = field(default_factory=list) + + @property + def effective_port(self): + return self.port or MAIL_PORTS.get(self.security, 25) + + def problems(self): + problems = [] + if not self.server: + problems.append("[mail] kein 'server' angegeben") + if not self.sender: + problems.append("[mail] kein 'sender' angegeben") + if not self.recipients: + problems.append("[mail] keine 'recipients' angegeben") + if self.security not in MAIL_PORTS: + problems.append("[mail] unbekannte 'security': %s (erlaubt: %s)" + % (self.security, ", ".join(MAIL_PORTS))) + if not 0 <= self.port <= 65535: + problems.append("[mail] 'port' muss zwischen 1 und 65535 liegen") + if self.password and not self.user: + problems.append("[mail] 'password' ohne 'user'") + if self.user and self.security == "none": + problems.append("[mail] Anmeldung ohne Verschluesselung wuerde das Passwort " + "im Klartext senden - 'security' auf starttls oder ssl stellen") + return problems + + @dataclass class Config: globals: GlobalConfig = field(default_factory=GlobalConfig) + mail: MailConfig = field(default_factory=MailConfig) groups: list = field(default_factory=list) defaults: dict = field(default_factory=dict) path: str = DEFAULT_CONFIG_PATH @@ -191,6 +243,8 @@ class Config: from .naming import effective_slug problems = list(self.globals.validate()) + if self.mail.enabled: + problems.extend(self.mail.problems()) seen = {} for group in self.groups: problems.extend(group.validate()) @@ -212,7 +266,7 @@ _GROUP_KEYS = { "enabled", "interval", "schedule", "at", "minute", "day_of_week", "day_of_month", "month", "align", "keep_count", "keep_time", "keep_min", "all", "vmids", "names", "tags", "pools", "types", "exclude_vmids", "exclude_names", "exclude_tags", - "vmstate", "skip_stopped", "description", + "vmstate", "skip_stopped", "description", "space_min_free", "space_keep", } # Deutsche Schreibweisen als Synonyme, damit die INI lesbar bleibt. @@ -227,6 +281,8 @@ _KEY_ALIASES = { "anzahl": "keep_count", "max_anzahl": "keep_count", "behalte_anzahl": "keep_count", "vorhaltezeit": "keep_time", "behalte_zeit": "keep_time", "max_alter": "keep_time", "mindestens": "keep_min", + "mindestens_frei": "space_min_free", + "platzmangel_behalten": "space_keep", "alle": "all", "namen": "names", "typen": "types", @@ -234,6 +290,11 @@ _KEY_ALIASES = { "ausschluss_vmids": "exclude_vmids", "ausschluss_namen": "exclude_names", "gestoppte_ueberspringen": "skip_stopped", + "verschluesselung": "security", + "benutzer": "user", + "passwort": "password", + "absender": "sender", + "empfaenger": "recipients", } @@ -243,7 +304,7 @@ _KEY_ALIASES = { _INLINE_COMMENT = re.compile(r"\s+[#;].*$", re.DOTALL) # Schluessel, deren Wert unangetastet bleibt (freier Text). -_VERBATIM_KEYS = {"description", "description_template"} +_VERBATIM_KEYS = {"description", "description_template", "password"} def _strip_comment(value): @@ -281,6 +342,12 @@ def load_config(path=DEFAULT_CONFIG_PATH): config.globals = _parse_global(_normalize_section(parser[name]), errors) break + # [mail] + for name in parser.sections(): + if name.strip().lower() in ("mail", "smtp"): + config.mail = _parse_mail(_normalize_section(parser[name]), errors) + break + # [defaults] - Vorgabewerte fuer alle Gruppen defaults = {} for name in parser.sections(): @@ -329,6 +396,7 @@ def _parse_global(section, errors): take("retries", lambda v: int(str(v).strip())) take("retry_delay", lambda v: parse_duration(v, default_unit="s")) take("pause_between", lambda v: parse_duration(v, default_unit="s")) + take("space_settle", lambda v: parse_duration(v, default_unit="s")) # Synonyme if "description_template" in section: result.description = str(section["description_template"]).strip() @@ -342,11 +410,43 @@ def _parse_global(section, errors): if key not in ("prefix", "check_interval", "state_file", "log_level", "log_file", "lock_file", "dry_run", "task_timeout", "run_on_start", "description", "description_template", "testlauf", "retries", "retry_delay", - "pause_between"): + "pause_between", "space_settle"): errors.append("[global] unbekannter Schluessel: %s" % key) return result +def _parse_mail_security(text): + value = str(text).strip().lower() + if value not in _MAIL_SECURITY: + raise ValueError("erlaubt sind starttls, ssl und none, nicht %r" % text) + return _MAIL_SECURITY[value] + + +def _parse_mail(section, errors): + result = MailConfig() + parsers = { + "enabled": parse_bool, + "server": lambda v: str(v).strip(), + "port": lambda v: int(str(v).strip()), + "security": _parse_mail_security, + "tls_verify": parse_bool, + "user": lambda v: str(v).strip(), + "password": lambda v: str(v), + "sender": lambda v: str(v).strip(), + "recipients": parse_list, + } + for key, raw in section.items(): + if key not in parsers: + errors.append("[mail] unbekannter Schluessel: %s" % key) + continue + try: + value = raw if key in _VERBATIM_KEYS else _strip_comment(raw) + setattr(result, key, parsers[key](value)) + except ValueError as exc: + errors.append("[mail] %s: %s" % (key, exc)) + return result + + def _parse_group(name, section, errors): group = Group(name=name) prefix = "[group:%s]" % name @@ -373,6 +473,8 @@ def _parse_group(name, section, errors): take("keep_count", lambda v: max(0, int(str(v).strip()))) take("keep_time", lambda v: parse_duration(v, default_unit="d")) take("keep_min", lambda v: max(0, int(str(v).strip()))) + take("space_min_free", parse_free_space) + take("space_keep", lambda v: max(0, int(str(v).strip()))) take("all", parse_bool) take("vmids", parse_vmid_list) @@ -441,6 +543,8 @@ def _group_to_lines(group, globals_): lines.append("keep_time = %s" % format_duration(group.keep_time, zero="0")) if group.keep_min: lines.append("keep_min = %d" % group.keep_min) + lines.append("space_min_free = %s" % group.space_min_free) + lines.append("space_keep = %d" % group.space_keep) if group.all: lines.append("all = yes") @@ -470,6 +574,23 @@ def _group_to_lines(group, globals_): return lines +def _mail_to_lines(mail): + lines = ["[mail]", "enabled = %s" % format_bool(mail.enabled)] + lines.append("server = %s" % mail.server) + if mail.port: + lines.append("port = %d" % mail.port) + lines.append("security = %s" % mail.security) + if not mail.tls_verify: + lines.append("tls_verify = no") + if mail.user: + lines.append("user = %s" % mail.user) + if mail.password: + lines.append("password = %s" % mail.password) + lines.append("sender = %s" % mail.sender) + lines.append("recipients = %s" % ", ".join(mail.recipients)) + return lines + + def dump_config(config): """Erzeugt den kompletten INI-Text zu einer Config.""" g = config.globals @@ -486,11 +607,16 @@ def dump_config(config): lines.append("retries = %d" % g.retries) lines.append("retry_delay = %s" % format_duration(g.retry_delay, zero="60s")) lines.append("pause_between = %s" % format_duration(g.pause_between, zero="0")) + lines.append("space_settle = %s" % format_duration(g.space_settle, zero="0")) lines.append("run_on_start = %s" % format_bool(g.run_on_start)) lines.append("dry_run = %s" % format_bool(g.dry_run)) lines.append("description = %s" % g.description) lines.append("") + if config.mail != MailConfig(): + lines.extend(_mail_to_lines(config.mail)) + lines.append("") + for group in config.groups: lines.extend(_group_to_lines(group, g)) lines.append("") @@ -498,8 +624,27 @@ def dump_config(config): return "\n".join(lines).rstrip() + "\n" +def _open_private(path, mode): + """Oeffnet zum Schreiben und setzt die Rechte, bevor etwas hineinkommt.""" + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode) + os.fchmod(descriptor, mode) + return os.fdopen(descriptor, "w", encoding="utf-8") + + +def world_readable_secret(config): + """Liegt das SMTP-Passwort in einer Datei, die jeder lesen darf?""" + try: + return bool(config.mail.password and os.stat(config.path).st_mode & 0o007) + except OSError: + return False + + def save_config(config, path=None): - """Schreibt die Config atomar; legt vorher eine .bak-Kopie an.""" + """Schreibt die Config atomar; legt vorher eine .bak-Kopie an. + + Die Datei kann das SMTP-Passwort enthalten, und die Kopie ein frueheres - + deshalb darf sie nur root lesen. + """ target = path or config.path directory = os.path.dirname(os.path.abspath(target)) os.makedirs(directory, exist_ok=True) @@ -508,13 +653,13 @@ def save_config(config, path=None): try: with open(target, "r", encoding="utf-8") as handle: previous = handle.read() - with open(target + ".bak", "w", encoding="utf-8") as handle: + with _open_private(target + ".bak", 0o600) as handle: handle.write(previous) except OSError: pass # Backup ist Kuer, kein Muss tmp = target + ".tmp" - with open(tmp, "w", encoding="utf-8") as handle: + with _open_private(tmp, 0o600 if config.mail.password else 0o640) as handle: handle.write(dump_config(config)) handle.flush() os.fsync(handle.fileno()) diff --git a/pvesnap/curses_util.py b/pvesnap/curses_util.py index 7831d7b..51d2ace 100644 --- a/pvesnap/curses_util.py +++ b/pvesnap/curses_util.py @@ -149,8 +149,11 @@ def ask(win, question, answers, default=None): return default -def prompt(win, label, initial=""): - """Einzeilige Eingabe am unteren Rand. None = abgebrochen.""" +def prompt(win, label, initial="", secret=False): + """Einzeilige Eingabe am unteren Rand. None = abgebrochen. + + Mit `secret` erscheinen statt der Zeichen Sternchen. + """ height, width = win.getmaxyx() buffer = list(str(initial)) curses.curs_set(1) @@ -160,6 +163,8 @@ def prompt(win, label, initial=""): label_text = " %s: " % label visible = width - len(label_text) - 2 shown = text[-visible:] if visible > 0 and len(text) > visible else text + if secret: + shown = "*" * len(shown) fill(win, height - 2, label_text + shown, curses.color_pair(C_SEL)) try: win.move(height - 2, min(width - 2, len(label_text) + len(shown))) diff --git a/pvesnap/daemon.py b/pvesnap/daemon.py index 3299426..a8aa1e4 100644 --- a/pvesnap/daemon.py +++ b/pvesnap/daemon.py @@ -11,7 +11,7 @@ import threading import time from datetime import datetime -from .config import ConfigError, load_config +from .config import ConfigError, load_config, world_readable_secret from .engine import run_group from .preflight import warn_about_environment from .proxmox import Proxmox, ProxmoxError @@ -112,6 +112,9 @@ class Daemon: raise ConfigError("Konfiguration fehlerhaft:\n - %s" % "\n - ".join(problems)) if self.dry_run_override: config.globals.dry_run = True + if world_readable_secret(config): + log.warning("%s enthaelt das SMTP-Passwort und ist fuer alle lesbar - " + "bitte: chmod 600 %s", config.path, config.path) self.config = config if self.state is None or self.state.path != config.globals.state_file: self.state = State(config.globals.state_file).load() diff --git a/pvesnap/engine.py b/pvesnap/engine.py index 44b0e7f..a5052b4 100644 --- a/pvesnap/engine.py +++ b/pvesnap/engine.py @@ -8,8 +8,11 @@ import time from dataclasses import dataclass, field from datetime import datetime +from .mail import send_space_alert from .naming import build_name, effective_slug, parse_name, render_description -from .proxmox import ProxmoxError +from .proxmox import ProxmoxError, guest_volumes +from .snapfs import human_bytes +from .util import free_space_bytes, parse_free_space log = logging.getLogger("pvesnap.engine") @@ -98,6 +101,174 @@ def plan_prune(prefix, group, snapshots, now): return doomed +def plan_reclaim(config, group, snapshots): + """Was bei Platzmangel weg darf, in der Reihenfolge des Loeschens. + + Anders als die Vorhaltezeit zaehlt das je Gast ueber alle pvesnap-Snapshots, + auch die von Gruppen, die es nicht mehr gibt. Ein voller Pool ist schlimmer + als ein verlorener alter Stand, und gerade die aeltesten halten am meisten + Platz fest. Getrennt gezaehlt koennte eine Gruppe mit keep_count unter + space_keep nie etwas freigeben. + + Halbfertige Eintraege zuerst - sie enthalten nichts. Danach die aeltesten, + bis nur noch die neuesten space_keep uebrig sind. Das keep_min jeder Gruppe + bleibt dabei gewahrt. + """ + prefix = config.globals.prefix + managed = [] + for snap in snapshots: + parsed = parse_name(prefix, snap.name) + if parsed: + created = (datetime.fromtimestamp(snap.snaptime) if snap.snaptime + else parsed["created"]) + managed.append((created, snap)) + managed.sort(key=lambda item: item[0], reverse=True) # neueste zuerst + broken = [snap for _created, snap in managed if snap.snapstate] + complete = [snap for _created, snap in managed if not snap.snapstate] + + kept = {snap.name for snap in complete[:group.space_keep]} + for other in config.groups: + own = [snap for snap, _created in managed_snapshots(prefix, other, complete)] + kept.update(snap.name for snap in own[:other.keep_min]) + return broken[::-1] + [snap for snap in complete[::-1] if snap.name not in kept] + + +# --------------------------------------------------------------------------- +# Platz vor dem Anlegen +# --------------------------------------------------------------------------- + +SPACE_POLL = 5 # Sekunden zwischen zwei Blicken auf das Storage + + +def _space_demand(proxmox, guest, group): + """Je Storage des Gastes der Platz, den der Snapshot sofort belegt. + + Fuer die meisten Platten ist das nichts - ein Snapshot waechst erst mit dem, + was danach geschrieben wird. Sofort kosten nur dick angelegte ZFS-Zvols und + der mitgesicherte RAM. + """ + config = proxmox.guest_config(guest) + demand = {} + for volid in guest_volumes(config, guest.type): + name = volid.split(":", 1)[0] + demand[name] = demand.get(name, 0) + proxmox.snapshot_cost(guest.node, volid) + if group.vmstate and guest.running and guest.type == "qemu": + ram = int(proxmox.guest_status(guest).get("maxmem") or 0) + target = config.get("vmstatestorage") + for name in ([target] if target else list(demand)): + demand[name] = demand.get(name, 0) + ram + return demand + + +def _space_shortages(proxmox, guest, group, demand): + """Die Storages, auf denen es nicht reicht: [(Name, frei, gebraucht)]. + + Gebraucht wird, was der Snapshot sofort belegt, plus die Reserve, die danach + noch frei bleiben muss. + """ + shortages = [] + for name, extra in sorted(demand.items()): + space = proxmox.storage_space(guest.node, name) + if space is None: + continue + free, total = space + reserve = free_space_bytes(group.space_min_free, total) + needed = reserve + extra + log.log(logging.INFO if proxmox.dry_run else logging.DEBUG, + "%s: '%s': %s frei, Snapshot belegt sofort %s, danach frei %s, " + "Reserve %s", guest.label, name, human_bytes(free), human_bytes(extra), + human_bytes(max(0, free - extra)), human_bytes(reserve)) + if free < needed: + shortages.append((name, free, needed)) + return shortages + + +def _describe_shortages(shortages): + return ", ".join("'%s': %s frei, %s gebraucht" + % (name, human_bytes(free), human_bytes(needed)) + for name, free, needed in shortages) + + +def _wait_for_space(proxmox, guest, group, demand, settle, limit): + """Nach dem Loeschen abwarten, bis der Platz wirklich zurueckgekommen ist. + + Ceph und ZFS geben ihn erst im Hintergrund frei. Saehe man sofort nach, + fehlte er scheinbar noch - und der naechste Snapshot wuerde umsonst + geloescht. Gewartet wird deshalb, solange der freie Platz noch waechst, und + aufgegeben erst, wenn sich `settle` Sekunden lang nichts mehr tut. + """ + shortages = _space_shortages(proxmox, guest, group, demand) + best = sum(free for _name, free, _needed in shortages) + started = last_growth = time.monotonic() + while (shortages and time.monotonic() - last_growth < settle + and time.monotonic() - started < limit): + time.sleep(min(SPACE_POLL, settle)) + shortages = _space_shortages(proxmox, guest, group, demand) + free_now = sum(free for _name, free, _needed in shortages) + if free_now > best: + best = free_now + last_growth = time.monotonic() + return shortages + + +def _ensure_space(proxmox, config, group, guest, result, may_delete=True): + """Sorgt vor dem Snapshot fuer Platz. False = dieser Gast wird ausgelassen. + + Geloescht wird einzeln und immer nur so viel, bis es reicht. Laesst sich der + Platz nicht feststellen, bleibt alles stehen und der Snapshot wird versucht: + Auf Verdacht wird nichts geloescht. + """ + if parse_free_space(group.space_min_free) == "0": + return True + + try: + demand = _space_demand(proxmox, guest, group) + shortages = _space_shortages(proxmox, guest, group, demand) + if not shortages: + return True + snapshots = proxmox.list_snapshots(guest) + except ProxmoxError as exc: + log.warning("Gruppe '%s': Platz fuer %s nicht pruefbar: %s", + group.name, guest.label, exc) + return True + + log.warning("Gruppe '%s': %s: zu wenig Platz (%s)", + group.name, guest.label, _describe_shortages(shortages)) + + candidates = plan_reclaim(config, group, snapshots) if may_delete else [] + for snap in candidates: + try: + proxmox.delete_snapshot(guest, snap.name, force=bool(snap.snapstate)) + except ProxmoxError as exc: + message = "%s: '%s' nicht loeschbar: %s" % (guest.label, snap.name, exc) + result.errors.append(message) + log.error("Gruppe '%s': %s", group.name, message) + continue + result.deleted.append("%s:%s" % (guest.vmid, snap.name)) + log.info("Gruppe '%s': Snapshot '%s' von %s geloescht (Platzmangel)", + group.name, snap.name, guest.label) + if proxmox.dry_run: + continue + shortages = _wait_for_space(proxmox, guest, group, demand, + config.globals.space_settle, + config.globals.task_timeout) + if not shortages: + log.info("Gruppe '%s': %s: Platz reicht jetzt", group.name, guest.label) + return True + + if proxmox.dry_run and candidates: + log.info("[TESTLAUF] %s: ein echter Lauf loescht davon nur, bis der Platz " + "reicht", guest.label) + return True + + message = ("%s: kein Snapshot angelegt, zu wenig Platz (%s) und nichts mehr " + "loeschbar" % (guest.label, _describe_shortages(shortages))) + result.errors.append(message) + result.no_space.append(message) + log.error("Gruppe '%s': %s", group.name, message) + return False + + # --------------------------------------------------------------------------- # Ausfuehrung # --------------------------------------------------------------------------- @@ -110,6 +281,7 @@ class GroupResult: deleted: list = field(default_factory=list) skipped: list = field(default_factory=list) errors: list = field(default_factory=list) + no_space: list = field(default_factory=list) # Abbrueche wegen Platzmangels @property def ok(self): @@ -155,6 +327,9 @@ def run_group(proxmox, config, group, now=None, create=True, prune=True, guests= continue if create: + if not _ensure_space(proxmox, config, group, guest, result, may_delete=prune): + continue + description = render_description(template, group, guest, now, prefix) try: proxmox.create_snapshot( @@ -179,6 +354,8 @@ def run_group(proxmox, config, group, now=None, create=True, prune=True, guests= log.error("Gruppe '%s': %s", group.name, message) log.info(result.summary()) + if result.no_space: + send_space_alert(config, group.name, result.no_space) return result diff --git a/pvesnap/mail.py b/pvesnap/mail.py new file mode 100644 index 0000000..7530a2e --- /dev/null +++ b/pvesnap/mail.py @@ -0,0 +1,96 @@ +"""Alarm per Mail, wenn wegen Platzmangels ein Snapshot ausfaellt.""" + +from __future__ import annotations + +import logging +import smtplib +import socket +import ssl +from email.message import EmailMessage +from email.utils import formatdate, make_msgid + +log = logging.getLogger("pvesnap.mail") + +SMTP_TIMEOUT = 30 + + +class MailError(Exception): + """Die Mail konnte nicht zugestellt werden.""" + + +def _tls_context(mail): + context = ssl.create_default_context() + if not mail.tls_verify: + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + return context + + +def _connect(mail): + if mail.security == "ssl": + return smtplib.SMTP_SSL(mail.server, mail.effective_port, timeout=SMTP_TIMEOUT, + context=_tls_context(mail)) + client = smtplib.SMTP(mail.server, mail.effective_port, timeout=SMTP_TIMEOUT) + if mail.security == "starttls": + try: + # Bietet der Server kein STARTTLS an, schlaegt das fehl. Weiter im + # Klartext geht es dann bewusst nicht. + client.starttls(context=_tls_context(mail)) + except Exception: + client.close() + raise + return client + + +def send_mail(mail, subject, body): + """Verschickt eine Mail; jedes Scheitern wird zu einem MailError.""" + message = EmailMessage() + message["Subject"] = subject + message["From"] = mail.sender + message["To"] = ", ".join(mail.recipients) + message["Date"] = formatdate(localtime=True) + message["Message-ID"] = make_msgid(domain=socket.getfqdn()) + message.set_content(body) + try: + with _connect(mail) as client: + if mail.user: + client.login(mail.user, mail.password) + client.send_message(message) + except (OSError, smtplib.SMTPException, ValueError) as exc: + raise MailError("%s: %s" % (mail.server, exc)) + + +def send_test_mail(mail): + host = socket.gethostname() + send_mail(mail, "[pvesnap] %s: Test-Mail" % host, + "Der Mail-Alarm von pvesnap auf %s funktioniert.\n" % host) + + +def send_space_alert(config, group_name, messages): + """Meldet die ausgefallenen Snapshots eines Laufs in einer einzigen Mail. + + Scheitert das, bleibt es bei einem Fehler im Protokoll: Eine kaputte Mail + darf die uebrigen Gruppen nicht aufhalten. + """ + mail = config.mail + if not mail.enabled: + return + host = socket.gethostname() + if config.globals.dry_run: + log.info("[TESTLAUF] wuerde Alarm-Mail an %s senden", ", ".join(mail.recipients)) + return + subject = ("[pvesnap] %s: %d Snapshot(s) wegen Platzmangels ausgefallen" + % (host, len(messages))) + body = ("Auf %s konnte pvesnap in der Gruppe '%s' nicht genug Platz schaffen.\n" + "Fuer diese Gaeste wurde kein Snapshot angelegt:\n\n%s\n\n" + "Der Speicher wird knapp - laeuft er voll, stehen alle Maschinen darauf,\n" + "und auch das Aufraeumen kann dann scheitern. Bitte zeitnah pruefen:\n\n" + " ceph df (Ceph: MAX AVAIL)\n" + " pvesm status\n" + " pvesnap list -a (auch von Hand angelegte Snapshots)\n" + % (host, group_name, "\n".join(" - %s" % text for text in messages))) + try: + send_mail(mail, subject, body) + log.info("Alarm-Mail an %s gesendet", ", ".join(mail.recipients)) + except MailError as exc: + log.error("Alarm-Mail nicht zustellbar: %s", exc) diff --git a/pvesnap/proxmox.py b/pvesnap/proxmox.py index 0c60cb8..3702c2b 100644 --- a/pvesnap/proxmox.py +++ b/pvesnap/proxmox.py @@ -9,6 +9,7 @@ from __future__ import annotations import json import logging import re +import shlex import shutil import subprocess import time @@ -17,10 +18,14 @@ from dataclasses import dataclass, field log = logging.getLogger("pvesnap.proxmox") PVESH = "/usr/bin/pvesh" +ZFS = "/sbin/zfs" # UPID:node:PID:PSTART:STARTTIME:TYPE:ID:USER: _UPID_PATTERN = re.compile(r"UPID:[^\s\"']+") +QEMU_DISK_KEY = re.compile(r"^((ide|sata|scsi|virtio)\d+|efidisk0|tpmstate0)$") +LXC_DISK_KEY = re.compile(r"^(rootfs|mp\d+)$") + class ProxmoxError(Exception): """Ein pvesh-Aufruf ist fehlgeschlagen.""" @@ -61,6 +66,23 @@ class Snapshot: return not self.snapstate +def guest_volumes(config, guest_type): + """Die Volumes (storage:name) der Datentraeger eines Gastes. + + Durchgereichte Platten und Bind-Mounts haben kein Storage, ein CD-Laufwerk + bekommt keinen Snapshot. + """ + pattern = LXC_DISK_KEY if guest_type == "lxc" else QEMU_DISK_KEY + volumes = [] + for key, value in sorted(config.items()): + if not pattern.match(key) or "media=cdrom" in str(value): + continue + volid = str(value).split(",", 1)[0].strip() + if ":" in volid and not volid.startswith("/"): + volumes.append(volid) + return volumes + + def pvesh_available(): return shutil.which(PVESH) is not None or shutil.which("pvesh") is not None @@ -80,6 +102,7 @@ class Proxmox: self.retries = retries self.retry_delay = retry_delay self._inventory_cache = None + self._storage_cache = {} @classmethod def from_config(cls, config): @@ -207,6 +230,73 @@ class Proxmox: what = "Loeschen von %s bei %s" % (name, guest.label) return self._retry(what, lambda: self._task(guest, args, what)) + # -- Platz auf den Storages ------------------------------------------- + + def storage_space(self, node, storage): + """(frei, gesamt) in Bytes - oder None, wenn es sich nicht sagen laesst. + + "Frei" ist bei Ceph MAX AVAIL, also schon nach Replikation. + """ + try: + info = self._json(["get", "/nodes/%s/storage/%s/status" % (node, storage)]) + except ProxmoxError as exc: + log.warning("Storage '%s' auf %s nicht abfragbar: %s", storage, node, exc) + return None + # Ein inaktives Storage meldet 0 von 0 - das ist nicht dasselbe wie voll. + if not isinstance(info, dict) or not info.get("total"): + log.warning("Storage '%s' auf %s meldet keine Groesse", storage, node) + return None + return int(info.get("avail") or 0), int(info["total"]) + + def snapshot_cost(self, node, volid): + """Was ein Snapshot dieses Volumes sofort belegt, in Bytes. + + Fast ueberall nichts. Nur ein dick angelegtes ZFS-Zvol muss seine ganze + Reservierung auch nach dem Snapshot garantieren: Alles seit dem letzten + Snapshot Geschriebene wird dann zusaetzlich belegt. Das laesst sich exakt + ausrechnen, die Proxmox-API liefert die Werte aber nicht - daher `zfs get`. + Klappt das nicht, zaehlt das Volume mit 0: Auf Verdacht wird nichts geloescht. + """ + storage, volname = volid.split(":", 1) + info = self._storage_config(storage) + if info.get("type") != "zfspool" or not info.get("pool"): + return 0 + # Bei verknuepften Klonen steht davor noch die Vorlage: base-1-disk-0/vm-2-disk-0 + dataset = "%s/%s" % (info["pool"], volname.rsplit("/", 1)[-1]) + command = [ZFS, "get", "-Hp", "-o", "value", + "refreservation,usedbyrefreservation", dataset] + if node != self.local_node(): + command = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", + "-o", "HostKeyAlias=%s" % node, "root@%s" % self._node_address(node), + " ".join(shlex.quote(part) for part in command)] + try: + output = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + timeout=self.command_timeout, check=True).stdout + reserved, used = (int(line) for line in output.decode().split()) + except (OSError, ValueError, subprocess.SubprocessError) as exc: + log.warning("Snapshot-Kosten von %s auf %s nicht ermittelbar: %s", volid, node, exc) + return 0 + return max(0, reserved - used) + + def _node_address(self, node): + """Die IP aus dem Clusterstatus. Node-Namen muss nicht jeder Host aufloesen.""" + try: + for entry in self._json(["get", "/cluster/status"]) or []: + if entry.get("type") == "node" and entry.get("name") == node and entry.get("ip"): + return entry["ip"] + except ProxmoxError as exc: + log.debug("Clusterstatus nicht lesbar: %s", exc) + return node + + def _storage_config(self, storage): + if storage not in self._storage_cache: + try: + self._storage_cache[storage] = self._json(["get", "/storage/%s" % storage]) or {} + except ProxmoxError as exc: + log.warning("Storage '%s' nicht lesbar: %s", storage, exc) + self._storage_cache[storage] = {} + return self._storage_cache[storage] + # -- Gaeste lesen und steuern ----------------------------------------- def guest_config(self, guest, snapshot=None): diff --git a/pvesnap/tui.py b/pvesnap/tui.py index eb346dd..0f10ee1 100644 --- a/pvesnap/tui.py +++ b/pvesnap/tui.py @@ -14,10 +14,11 @@ from .curses_util import (C_DIM, C_FOOTER, C_HEADER, C_OK, C_SEL, C_WARN, is_escape, keybar, message, prompt, put, read_key) from .config import (WEEKDAY_NAMES, Config, ConfigError, Group, clone_group, load_config, parse_time_of_day, save_config) +from .mail import MailError, send_test_mail from .naming import effective_slug from .proxmox import Proxmox, ProxmoxError, pvesh_available from .schedule import describe -from .util import (format_duration, format_vmid_list, parse_duration, +from .util import (format_duration, format_vmid_list, parse_duration, parse_free_space, parse_list, parse_vmid_list, truncate) SCHEDULE_CHOICES = [ @@ -37,6 +38,12 @@ TYPE_CHOICES = [ SERVICE = "pvesnap.service" +MAIL_SECURITY_TEXT = { + "starttls": "STARTTLS (TLS, meist Port 587)", + "ssl": "SSL/TLS von Anfang an (meist Port 465)", + "none": "keine (nur fuer interne Relays ohne Anmeldung)", +} + # Tastenleiste der Gruppenliste: (Taste, lange Beschriftung, kurze Beschriftung) GROUP_KEYS = [ ("Enter", "Bearbeiten", "Bearb."), @@ -45,6 +52,7 @@ GROUP_KEYS = [ ("d", "Loeschen", "Loesch"), ("Leer", "An/Aus", "An/Aus"), ("g", "Global", "Global"), + ("m", "Mail", "Mail"), ("v", "VMs", "VMs"), ("s", "Speichern", "Speich."), ("r", "Dienst neu laden", "Reload"), @@ -147,8 +155,8 @@ class Editor: def _confirm(self, win, question): return confirm(win, question) - def _prompt(self, win, label, initial=""): - return prompt(win, label, initial) + def _prompt(self, win, label, initial="", secret=False): + return prompt(win, label, initial, secret) def _choose(self, win, title, entries, current=0): return choose(win, title, entries, current) @@ -240,6 +248,8 @@ class Editor: self.dirty = True elif key == "g": self.screen_globals(win) + elif key == "m": + self.screen_mail(win) elif key == "v": self.screen_overview(win) elif key == "p": @@ -556,6 +566,24 @@ class Editor: int_field("Mindestens behalten", lambda: group.keep_min, lambda v: setattr(group, "keep_min", v), zero_text="0 (keine Untergrenze)") + # --- Platz --- + fields.append(("-", "Platz vor dem Snapshot", "", None)) + + def set_min_free(win): + raw = self._prompt(win, "Mindestens frei (z.B. 10%, 20G, 0=keine Pruefung)", + group.space_min_free) + if raw is None: + return + try: + group.space_min_free = parse_free_space(raw) + except ValueError as exc: + self._message(win, str(exc), error=True) + fields.append(("f", "Mindestens frei", + "keine Pruefung" if group.space_min_free == "0" + else group.space_min_free, set_min_free)) + int_field("Bei Platzmangel behalten", lambda: group.space_keep, + lambda v: setattr(group, "space_keep", v)) + # --- Auswahl --- fields.append(("-", "Welche VMs?", "", None)) bool_field("Alle VMs/Container", lambda: group.all, @@ -755,6 +783,9 @@ class Editor: "duration", "retry_delay"), ("Pause zwischen zwei VMs", format_duration(globals_.pause_between, zero="keine"), "duration", "pause_between"), + ("Geduld nach Loeschen bei Platzmangel", + format_duration(globals_.space_settle, zero="keine"), + "duration", "space_settle"), ("Beim Start sofort ausfuehren", "ja" if globals_.run_on_start else "nein", "bool", "run_on_start"), ("Testlauf (nichts wirklich tun)", "ja" if globals_.dry_run else "nein", @@ -827,6 +858,101 @@ class Editor: setattr(globals_, attribute, raw) self.dirty = True + # -- Bildschirm: Mail-Alarm ------------------------------------------ + + def screen_mail(self, win): + mail = self.config.mail + index = 0 + while True: + fields = [ + ("Alarm per Mail", "ja" if mail.enabled else "nein", "enabled"), + ("SMTP-Server", mail.server or "-", "server"), + ("Verschluesselung", MAIL_SECURITY_TEXT.get(mail.security, mail.security), + "security"), + ("Port", "%d%s" % (mail.effective_port, "" if mail.port else " (automatisch)"), + "port"), + ("Zertifikat pruefen", "ja" if mail.tls_verify else "nein", "tls_verify"), + ("Benutzer", mail.user or "- (ohne Anmeldung)", "user"), + ("Passwort", "(gesetzt)" if mail.password else "-", "password"), + ("Absender", mail.sender or "-", "sender"), + ("Empfaenger", ", ".join(mail.recipients) or "-", "recipients"), + ] + index = max(0, min(index, len(fields) - 1)) + _height, width = self._frame(win, "Mail-Alarm", self.path, + "Enter Aendern | t Test-Mail | q zurueck") + for row, (label, value, _attribute) in enumerate(fields): + attr = curses.color_pair(C_SEL) if row == index else 0 + fill_row(win, 4 + row, 2, + truncate(label, 32).ljust(34) + truncate(str(value), max(4, width - 42)), + width, attr) + put(win, 6 + len(fields), 2, + "Meldet, wenn wegen Platzmangels ein Snapshot ausfaellt.", + curses.color_pair(C_DIM)) + win.refresh() + + key = read_key(win) + if key in (curses.KEY_UP, "k"): + index -= 1 + elif key in (curses.KEY_DOWN, "j"): + index += 1 + elif is_enter(key) or key == " ": + self._edit_mail(win, fields[index][0], fields[index][2]) + elif key == "t": + self._send_test_mail(win) + elif key in ("q", "Q") or is_escape(key): + return + + def _edit_mail(self, win, label, attribute): + mail = self.config.mail + if attribute in ("enabled", "tls_verify"): + setattr(mail, attribute, not getattr(mail, attribute)) + elif attribute == "security": + chosen = self._choose(win, label, list(MAIL_SECURITY_TEXT.items())) + if chosen is None: + return + mail.security = chosen + elif attribute == "port": + raw = self._prompt(win, "Port (0 = automatisch)", str(mail.port)) + if raw is None: + return + if not raw.isdigit() or int(raw) > 65535: + self._message(win, "Bitte einen Port zwischen 0 und 65535 eingeben.", error=True) + return + mail.port = int(raw) + elif attribute == "password": + raw = self._prompt(win, "Passwort (leer = unveraendert, - = entfernen)", "", + secret=True) + if not raw: + return + mail.password = "" if raw == "-" else raw + elif attribute == "recipients": + raw = self._prompt(win, "Empfaenger (mehrere mit Komma)", + ", ".join(mail.recipients)) + if raw is None: + return + mail.recipients = parse_list(raw) + else: + raw = self._prompt(win, label, getattr(mail, attribute)) + if raw is None: + return + setattr(mail, attribute, raw) + self.dirty = True + + def _send_test_mail(self, win): + problems = self.config.mail.problems() + if problems: + self._message(win, problems[0], error=True) + return + height, _width = win.getmaxyx() + fill(win, height - 2, " Test-Mail wird gesendet ...", curses.color_pair(C_SEL)) + win.refresh() + try: + send_test_mail(self.config.mail) + self._message(win, "Test-Mail an %s gesendet." + % ", ".join(self.config.mail.recipients)) + except MailError as exc: + self._message(win, "Test-Mail fehlgeschlagen: %s" % exc, error=True) + # -- Bildschirm: Uebersicht ------------------------------------------ def screen_overview(self, win): diff --git a/pvesnap/util.py b/pvesnap/util.py index defe81f..b58c9c4 100644 --- a/pvesnap/util.py +++ b/pvesnap/util.py @@ -64,6 +64,37 @@ def format_duration(seconds, zero="unbegrenzt"): return "".join(parts) +_FREE_SPACE = re.compile(r"^(\d+(?:[.,]\d+)?)\s*(%|[kmgt])?(?:i?b)?$") +_FREE_SPACE_UNITS = {"k": 1024, "m": 1024 ** 2, "g": 1024 ** 3, "t": 1024 ** 4} + + +def parse_free_space(text): + """'10 %' -> '10%', '20gib' -> '20G'. 0 / 'aus' -> '0' (= keine Pruefung).""" + value = str(text if text is not None else "").strip().lower() + if value in _NEVER: + return "0" + match = _FREE_SPACE.match(value) + if not match: + raise ValueError("ungueltige Platzangabe: %r (erwartet z.B. 10%% oder 20G)" % text) + number = float(match.group(1).replace(",", ".")) + unit = match.group(2) or "g" + if unit == "%" and number >= 100: + raise ValueError("Platzangabe muss unter 100%% liegen: %r" % text) + if not number: + return "0" + return "%g%s" % (number, unit.upper()) + + +def free_space_bytes(spec, total): + """Rechnet eine Angabe von parse_free_space in Bytes um.""" + value = parse_free_space(spec) + if value == "0": + return 0 + if value.endswith("%"): + return int(total * float(value[:-1]) / 100) + return int(float(value[:-1]) * _FREE_SPACE_UNITS[value[-1].lower()]) + + def slugify(text, maxlen=20): """Gruppenname -> kurzer, PVE-tauglicher Bezeichner (nur a-z0-9, beginnt mit Buchstabe).""" # Umlaute vor der Normalisierung ersetzen - NFKD wuerde sie sonst zerlegen.