Platz vor jedem Snapshot pruefen, bei Mangel alte loeschen, per Mail warnen
Volle Ceph-Pools legen alle VMs darauf lahm. Vor jedem Snapshot gilt jetzt je Storage des Gastes: frei >= sofortiger Verbrauch + space_min_free (Vorgabe 20 %). Die Reserve bleibt also auch nach dem Snapshot frei. Reicht es nicht, wird der aelteste pvesnap-Snapshot des Gastes geloescht und neu geprueft, bis es reicht. Sind nur noch die neuesten space_keep (Vorgabe 7) uebrig, entfaellt der Snapshot und der naechste Gast kommt dran. Gezaehlt wird je Gast ueber alle Gruppen, anders als bei der Vorhaltezeit. Auf dem Testhost haette eine Grenze je Gruppe nie gegriffen: keep_count = 5 liegt unter 7, und 13 GiB steckten in einer Gruppe, die es nicht mehr gibt. Sofortiger Verbrauch: Dicke ZFS-Zvols muessen ihre Reservierung auch nach dem Snapshot garantieren, das kostet alles seit dem letzten Snapshot Geschriebene. Exakt ist das refreservation - usedbyrefreservation; die Werte kommen per zfs get, bei anderen Nodes per ssh ueber die IP aus dem Clusterstatus, weil Node-Namen nicht ueberall aufloesen. Dazu kommt der RAM bei vmstate. --dry-run zeigt die Rechnung je Storage. Ceph und ZFS geben Platz erst im Hintergrund frei. Deshalb wird nach jedem Loeschen gewartet, solange der Platz waechst, und erst nach space_settle (Vorgabe 30s) ohne Zuwachs weitergemacht. Ohne das wurde auf Ceph ein Snapshot zu viel geloescht. Laesst sich der Platz nicht feststellen, wird nichts geloescht und der Snapshot trotzdem versucht. Neu ist [mail]: eine Alarm-Mail je Gruppe und Lauf, wenn ein Snapshot wegen Platzmangels ausfaellt. STARTTLS oder SSL/TLS mit Zertifikatspruefung, ohne Rueckfall auf Klartext. Einzurichten im Editor (Taste m) oder von Hand, Test mit pvesnap mail-test. Liegt ein Passwort in der Datei, setzt der Editor die Rechte auf 600, auch fuer die .bak. Das Handbuch beschreibt, wie man Ceph (Ratios) und ZFS (Notreserve) frueher "voll" sagen laesst und was im Notfall zu tun ist. Ausserdem war "pvesnap run --dry-run" ueberall falsch: Die Option ist global und muss vor dem Unterbefehl stehen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d9d9345d03
commit
74be5aee59
@@ -135,6 +135,7 @@ task_timeout = 15m
|
|||||||
retries = 2 # Wiederholungen, wenn eine Sperre belegt ist
|
retries = 2 # Wiederholungen, wenn eine Sperre belegt ist
|
||||||
retry_delay = 60s
|
retry_delay = 60s
|
||||||
pause_between = 0s # Pause zwischen zwei Gästen
|
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?
|
run_on_start = no # beim Start sofort einen Durchlauf machen?
|
||||||
dry_run = no # yes = nichts wirklich tun, nur protokollieren
|
dry_run = no # yes = nichts wirklich tun, nur protokollieren
|
||||||
description = pvesnap | Gruppe: {group} | erstellt: {datetime} | Vorhaltezeit: {keep_time}
|
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
|
Mindestens eine der beiden Angaben muss gesetzt sein, sonst würde die Zahl der
|
||||||
Snapshots unbegrenzt wachsen (`pvesnap check` weist darauf hin).
|
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?
|
### Welche VMs?
|
||||||
|
|
||||||
Alle Angaben wirken als **ODER**, Ausschlüsse gewinnen immer:
|
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 vms # alle VMs und die Gruppen, in denen sie stecken
|
||||||
pvesnap list # vorhandene pvesnap-Snapshots (-a = auch fremde)
|
pvesnap list # vorhandene pvesnap-Snapshots (-a = auch fremde)
|
||||||
pvesnap check # Konfiguration prüfen
|
pvesnap check # Konfiguration prüfen
|
||||||
|
pvesnap mail-test # Test-Mail senden
|
||||||
pvesnap config # ncurses-Editor
|
pvesnap config # ncurses-Editor
|
||||||
|
|
||||||
pvesnap run # jetzt fällige Gruppen ausführen
|
pvesnap run # jetzt fällige Gruppen ausführen
|
||||||
pvesnap run --force -g täglich # eine Gruppe sofort 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
|
pvesnap prune -g stündlich # nur aufräumen, keine neuen Snapshots
|
||||||
|
|
||||||
systemctl status pvesnap
|
systemctl status pvesnap
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ retry_delay = 60s # Wartezeit vor dem naechsten Versuch
|
|||||||
# Druck vom Storage-Lock; 0 = ohne Pause.
|
# Druck vom Storage-Lock; 0 = ohne Pause.
|
||||||
pause_between = 0s
|
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,
|
# yes = beim Dienststart sofort einen Durchlauf machen,
|
||||||
# no = auf den naechsten regulaeren Termin warten.
|
# no = auf den naechsten regulaeren Termin warten.
|
||||||
run_on_start = no
|
run_on_start = no
|
||||||
@@ -57,6 +62,29 @@ dry_run = no
|
|||||||
description = pvesnap | Gruppe: {group} | erstellt: {datetime} | Vorhaltezeit: {keep_time} | max: {keep_count}
|
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
|
# [defaults] gilt als Vorgabe fuer *alle* Gruppen und kann in jeder
|
||||||
# Gruppe einzeln ueberschrieben werden.
|
# Gruppe einzeln ueberschrieben werden.
|
||||||
@@ -65,6 +93,9 @@ description = pvesnap | Gruppe: {group} | erstellt: {datetime} | Vorhaltezeit: {
|
|||||||
enabled = yes
|
enabled = yes
|
||||||
skip_stopped = no
|
skip_stopped = no
|
||||||
vmstate = 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_time = 7d nichts aelter als 7 Tage
|
||||||
# keep_min = 1 so viele bleiben immer stehen
|
# 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):
|
# Auswahl der Gaeste (alles kombinierbar, wirkt als ODER):
|
||||||
# all = yes
|
# all = yes
|
||||||
# vmids = 100,101,105-110
|
# vmids = 100,101,105-110
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ pvesnap [globale Optionen] <befehl> [optionen]
|
|||||||
| `vms` | alle VMs und Container mit den Gruppen, in denen sie stecken |
|
| `vms` | alle VMs und Container mit den Gruppen, in denen sie stecken |
|
||||||
| `list` | vorhandene pvesnap-Snapshots |
|
| `list` | vorhandene pvesnap-Snapshots |
|
||||||
| `check` | Konfiguration prüfen |
|
| `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) |
|
| `config` (oder `edit`) | [ncurses-Editor](../sichern/editor.md) |
|
||||||
| `run` | fällige Gruppen jetzt ausführen |
|
| `run` | fällige Gruppen jetzt ausführen |
|
||||||
| `prune` | nur aufräumen, nichts anlegen |
|
| `prune` | nur aufräumen, nichts anlegen |
|
||||||
@@ -41,7 +42,7 @@ pvesnap [globale Optionen] <befehl> [optionen]
|
|||||||
```bash
|
```bash
|
||||||
pvesnap run # was fällig ist
|
pvesnap run # was fällig ist
|
||||||
pvesnap run --force -g taeglich # diese Gruppe sofort
|
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
|
pvesnap run -g taeglich -g monatlich # zwei Gruppen
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
## Storage-Lock
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -272,7 +288,7 @@ und danach wieder gestartet.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
pvesnap -v check # ausführlich
|
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
|
journalctl -u pvesnap -n 200 --no-pager
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ systemctl reload pvesnap
|
|||||||
| `retries` | `2` | zusätzliche Versuche, wenn eine Sperre belegt ist |
|
| `retries` | `2` | zusätzliche Versuche, wenn eine Sperre belegt ist |
|
||||||
| `retry_delay` | `60s` | Wartezeit vor dem nächsten Versuch |
|
| `retry_delay` | `60s` | Wartezeit vor dem nächsten Versuch |
|
||||||
| `pause_between` | `0s` | Pause zwischen zwei Gästen |
|
| `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 |
|
| `run_on_start` | `no` | beim Dienststart sofort einen Durchlauf machen |
|
||||||
| `dry_run` | `no` | `yes` = nichts wirklich tun, nur protokollieren |
|
| `dry_run` | `no` | `yes` = nichts wirklich tun, nur protokollieren |
|
||||||
| `description` | siehe unten | Standard-Beschreibung aller Snapshots |
|
| `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]`
|
## `[defaults]`
|
||||||
|
|
||||||
Vorgaben für **alle** Gruppen. Jede Gruppe darf sie einzeln überschreiben.
|
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_count` | `0` | höchstens so viele je VM und Gruppe (`0` = unbegrenzt) |
|
||||||
| `keep_time` | `0` | nichts älter als (`0` = unbegrenzt) |
|
| `keep_time` | `0` | nichts älter als (`0` = unbegrenzt) |
|
||||||
| `keep_min` | `0` | so viele bleiben in jedem Fall stehen |
|
| `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.**
|
**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
|
### 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` |
|
| `anzahl`, `max_anzahl`, `behalte_anzahl` | `keep_count` |
|
||||||
| `vorhaltezeit`, `behalte_zeit`, `max_alter` | `keep_time` |
|
| `vorhaltezeit`, `behalte_zeit`, `max_alter` | `keep_time` |
|
||||||
| `mindestens` | `keep_min` |
|
| `mindestens` | `keep_min` |
|
||||||
|
| `mindestens_frei` | `space_min_free` |
|
||||||
|
| `verschluesselung` | `security` |
|
||||||
|
| `benutzer` | `user` |
|
||||||
|
| `passwort` | `password` |
|
||||||
|
| `absender` | `sender` |
|
||||||
|
| `empfaenger` | `recipients` |
|
||||||
|
| `platzmangel_behalten` | `space_keep` |
|
||||||
| `alle` | `all` |
|
| `alle` | `all` |
|
||||||
| `namen` | `names` |
|
| `namen` | `names` |
|
||||||
| `typen` | `types` |
|
| `typen` | `types` |
|
||||||
|
|||||||
@@ -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 <vmid> <snapshot> # 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 <vmid> <snapshot> # 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
|
## Empfehlung
|
||||||
|
|
||||||
| Lage | |
|
| Lage | |
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ Alle Oberflächen auf einer Seite. Zum Ausdrucken und Danebenlegen.
|
|||||||
| ++c++ | Gruppe kopieren |
|
| ++c++ | Gruppe kopieren |
|
||||||
| ++d++ | Gruppe löschen |
|
| ++d++ | Gruppe löschen |
|
||||||
| ++g++ | globale Einstellungen |
|
| ++g++ | globale Einstellungen |
|
||||||
|
| ++m++ | Mail-Alarm einrichten, dort ++t++ für eine Test-Mail |
|
||||||
| ++v++ | Übersicht: welche VM in welcher Gruppe |
|
| ++v++ | Übersicht: welche VM in welcher Gruppe |
|
||||||
| ++s++ | speichern |
|
| ++s++ | speichern |
|
||||||
| ++r++ | Dienst neu laden |
|
| ++r++ | Dienst neu laden |
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ Maschine gesichert wird und welche nicht.
|
|||||||
Nicht bis morgen früh warten wollen:
|
Nicht bis morgen früh warten wollen:
|
||||||
|
|
||||||
```bash
|
```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
|
pvesnap run --force -g taeglich # jetzt wirklich
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ anderem:
|
|||||||
```bash
|
```bash
|
||||||
pvesnap run # jetzt fällige Gruppen ausführen
|
pvesnap run # jetzt fällige Gruppen ausführen
|
||||||
pvesnap run --force -g taeglich # diese Gruppe sofort, egal ob fällig
|
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
|
pvesnap prune -g stuendlich # nur aufräumen
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ zwischen zwei Terminen schlicht nichts.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
pvesnap -c /root/test.conf check
|
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,
|
`-c` gilt für alle Unterbefehle. Zusammen mit `--dry-run` ein gefahrloser Weg,
|
||||||
@@ -78,7 +78,7 @@ dry_run = yes
|
|||||||
oder einmalig:
|
oder einmalig:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pvesnap run --force --dry-run
|
pvesnap --dry-run run --force
|
||||||
```
|
```
|
||||||
|
|
||||||
Protokolliert, was geschähe, ohne etwas anzulegen oder zu löschen:
|
Protokolliert, was geschähe, ohne etwas anzulegen oder zu löschen:
|
||||||
|
|||||||
@@ -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
|
## Die Übersicht
|
||||||
|
|
||||||
Taste ++v++ in der Gruppenliste:
|
Taste ++v++ in der Gruppenliste:
|
||||||
|
|||||||
@@ -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
|
## Getrennt je VM und Gruppe
|
||||||
|
|
||||||
Beide Grenzen zählen **pro Gast und pro Gruppe**, nicht insgesamt.
|
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
|
`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
|
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
|
```bash
|
||||||
pvesnap prune # alle Gruppen
|
pvesnap prune # alle Gruppen
|
||||||
pvesnap prune -g stuendlich # nur diese
|
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
|
Praktisch nach dem Verkürzen einer Vorhaltezeit: Der Dienst würde erst beim
|
||||||
|
|||||||
+1
-1
@@ -294,7 +294,7 @@ Fertig.
|
|||||||
Konfiguration pruefen : pvesnap check
|
Konfiguration pruefen : pvesnap check
|
||||||
Uebersicht : pvesnap status
|
Uebersicht : pvesnap status
|
||||||
VMs und Gruppen : pvesnap vms
|
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
|
Nach Aenderungen : systemctl reload pvesnap
|
||||||
Protokoll : journalctl -u pvesnap -f
|
Protokoll : journalctl -u pvesnap -f
|
||||||
|
|
||||||
|
|||||||
+26
-1
@@ -9,7 +9,7 @@ import sys
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from . import __version__
|
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 .daemon import Daemon, SingleInstanceLock
|
||||||
from .engine import run_group, select_guests
|
from .engine import run_group, select_guests
|
||||||
from .naming import parse_name
|
from .naming import parse_name
|
||||||
@@ -337,6 +337,10 @@ def cmd_check(args):
|
|||||||
print(" - %s" % problem, file=sys.stderr)
|
print(" - %s" % problem, file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
environment = check_environment(require_pve=pvesh_available())
|
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:
|
if environment:
|
||||||
print("Hinweise zur Umgebung:", file=sys.stderr)
|
print("Hinweise zur Umgebung:", file=sys.stderr)
|
||||||
for problem in environment:
|
for problem in environment:
|
||||||
@@ -352,6 +356,24 @@ def cmd_check(args):
|
|||||||
return 0
|
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):
|
def cmd_config(args):
|
||||||
from .tui import run_editor
|
from .tui import run_editor
|
||||||
return run_editor(args.config)
|
return run_editor(args.config)
|
||||||
@@ -405,6 +427,9 @@ def build_parser():
|
|||||||
p_check = sub.add_parser("check", help="Konfiguration pruefen")
|
p_check = sub.add_parser("check", help="Konfiguration pruefen")
|
||||||
p_check.set_defaults(func=cmd_check)
|
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"):
|
for name in ("config", "edit"):
|
||||||
p_config = sub.add_parser(name, help="Konfiguration im ncurses-Editor bearbeiten")
|
p_config = sub.add_parser(name, help="Konfiguration im ncurses-Editor bearbeiten")
|
||||||
p_config.set_defaults(func=cmd_config)
|
p_config.set_defaults(func=cmd_config)
|
||||||
|
|||||||
+152
-7
@@ -8,7 +8,8 @@ import re
|
|||||||
from dataclasses import dataclass, field, replace
|
from dataclasses import dataclass, field, replace
|
||||||
|
|
||||||
from .util import (format_bool, format_duration, format_vmid_list, parse_bool,
|
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"
|
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):
|
class ConfigError(Exception):
|
||||||
"""Fehler beim Lesen oder Pruefen der Konfiguration."""
|
"""Fehler beim Lesen oder Pruefen der Konfiguration."""
|
||||||
|
|
||||||
@@ -86,6 +93,10 @@ class Group:
|
|||||||
keep_time: int = 0 # max. Alter in Sekunden (0 = unbegrenzt)
|
keep_time: int = 0 # max. Alter in Sekunden (0 = unbegrenzt)
|
||||||
keep_min: int = 0 # so viele bleiben immer stehen
|
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 ---------------------------------------------
|
# --- Auswahl der Gaeste ---------------------------------------------
|
||||||
all: bool = False
|
all: bool = False
|
||||||
vmids: list = field(default_factory=list)
|
vmids: list = field(default_factory=list)
|
||||||
@@ -155,6 +166,7 @@ class GlobalConfig:
|
|||||||
retries: int = 2 # Wiederholungen bei belegten Sperren
|
retries: int = 2 # Wiederholungen bei belegten Sperren
|
||||||
retry_delay: int = 60 # Wartezeit dazwischen
|
retry_delay: int = 60 # Wartezeit dazwischen
|
||||||
pause_between: int = 0 # Pause zwischen zwei Gaesten
|
pause_between: int = 0 # Pause zwischen zwei Gaesten
|
||||||
|
space_settle: int = 30 # Geduld, bis geloeschter Platz frei wird
|
||||||
|
|
||||||
def validate(self):
|
def validate(self):
|
||||||
problems = []
|
problems = []
|
||||||
@@ -174,9 +186,49 @@ class GlobalConfig:
|
|||||||
return problems
|
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
|
@dataclass
|
||||||
class Config:
|
class Config:
|
||||||
globals: GlobalConfig = field(default_factory=GlobalConfig)
|
globals: GlobalConfig = field(default_factory=GlobalConfig)
|
||||||
|
mail: MailConfig = field(default_factory=MailConfig)
|
||||||
groups: list = field(default_factory=list)
|
groups: list = field(default_factory=list)
|
||||||
defaults: dict = field(default_factory=dict)
|
defaults: dict = field(default_factory=dict)
|
||||||
path: str = DEFAULT_CONFIG_PATH
|
path: str = DEFAULT_CONFIG_PATH
|
||||||
@@ -191,6 +243,8 @@ class Config:
|
|||||||
from .naming import effective_slug
|
from .naming import effective_slug
|
||||||
|
|
||||||
problems = list(self.globals.validate())
|
problems = list(self.globals.validate())
|
||||||
|
if self.mail.enabled:
|
||||||
|
problems.extend(self.mail.problems())
|
||||||
seen = {}
|
seen = {}
|
||||||
for group in self.groups:
|
for group in self.groups:
|
||||||
problems.extend(group.validate())
|
problems.extend(group.validate())
|
||||||
@@ -212,7 +266,7 @@ _GROUP_KEYS = {
|
|||||||
"enabled", "interval", "schedule", "at", "minute", "day_of_week", "day_of_month",
|
"enabled", "interval", "schedule", "at", "minute", "day_of_week", "day_of_month",
|
||||||
"month", "align", "keep_count", "keep_time", "keep_min", "all", "vmids", "names",
|
"month", "align", "keep_count", "keep_time", "keep_min", "all", "vmids", "names",
|
||||||
"tags", "pools", "types", "exclude_vmids", "exclude_names", "exclude_tags",
|
"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.
|
# 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",
|
"anzahl": "keep_count", "max_anzahl": "keep_count", "behalte_anzahl": "keep_count",
|
||||||
"vorhaltezeit": "keep_time", "behalte_zeit": "keep_time", "max_alter": "keep_time",
|
"vorhaltezeit": "keep_time", "behalte_zeit": "keep_time", "max_alter": "keep_time",
|
||||||
"mindestens": "keep_min",
|
"mindestens": "keep_min",
|
||||||
|
"mindestens_frei": "space_min_free",
|
||||||
|
"platzmangel_behalten": "space_keep",
|
||||||
"alle": "all",
|
"alle": "all",
|
||||||
"namen": "names",
|
"namen": "names",
|
||||||
"typen": "types",
|
"typen": "types",
|
||||||
@@ -234,6 +290,11 @@ _KEY_ALIASES = {
|
|||||||
"ausschluss_vmids": "exclude_vmids",
|
"ausschluss_vmids": "exclude_vmids",
|
||||||
"ausschluss_namen": "exclude_names",
|
"ausschluss_namen": "exclude_names",
|
||||||
"gestoppte_ueberspringen": "skip_stopped",
|
"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)
|
_INLINE_COMMENT = re.compile(r"\s+[#;].*$", re.DOTALL)
|
||||||
|
|
||||||
# Schluessel, deren Wert unangetastet bleibt (freier Text).
|
# Schluessel, deren Wert unangetastet bleibt (freier Text).
|
||||||
_VERBATIM_KEYS = {"description", "description_template"}
|
_VERBATIM_KEYS = {"description", "description_template", "password"}
|
||||||
|
|
||||||
|
|
||||||
def _strip_comment(value):
|
def _strip_comment(value):
|
||||||
@@ -281,6 +342,12 @@ def load_config(path=DEFAULT_CONFIG_PATH):
|
|||||||
config.globals = _parse_global(_normalize_section(parser[name]), errors)
|
config.globals = _parse_global(_normalize_section(parser[name]), errors)
|
||||||
break
|
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] - Vorgabewerte fuer alle Gruppen
|
||||||
defaults = {}
|
defaults = {}
|
||||||
for name in parser.sections():
|
for name in parser.sections():
|
||||||
@@ -329,6 +396,7 @@ def _parse_global(section, errors):
|
|||||||
take("retries", lambda v: int(str(v).strip()))
|
take("retries", lambda v: int(str(v).strip()))
|
||||||
take("retry_delay", lambda v: parse_duration(v, default_unit="s"))
|
take("retry_delay", lambda v: parse_duration(v, default_unit="s"))
|
||||||
take("pause_between", 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
|
# Synonyme
|
||||||
if "description_template" in section:
|
if "description_template" in section:
|
||||||
result.description = str(section["description_template"]).strip()
|
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",
|
if key not in ("prefix", "check_interval", "state_file", "log_level", "log_file",
|
||||||
"lock_file", "dry_run", "task_timeout", "run_on_start", "description",
|
"lock_file", "dry_run", "task_timeout", "run_on_start", "description",
|
||||||
"description_template", "testlauf", "retries", "retry_delay",
|
"description_template", "testlauf", "retries", "retry_delay",
|
||||||
"pause_between"):
|
"pause_between", "space_settle"):
|
||||||
errors.append("[global] unbekannter Schluessel: %s" % key)
|
errors.append("[global] unbekannter Schluessel: %s" % key)
|
||||||
return result
|
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):
|
def _parse_group(name, section, errors):
|
||||||
group = Group(name=name)
|
group = Group(name=name)
|
||||||
prefix = "[group:%s]" % 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_count", lambda v: max(0, int(str(v).strip())))
|
||||||
take("keep_time", lambda v: parse_duration(v, default_unit="d"))
|
take("keep_time", lambda v: parse_duration(v, default_unit="d"))
|
||||||
take("keep_min", lambda v: max(0, int(str(v).strip())))
|
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("all", parse_bool)
|
||||||
take("vmids", parse_vmid_list)
|
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"))
|
lines.append("keep_time = %s" % format_duration(group.keep_time, zero="0"))
|
||||||
if group.keep_min:
|
if group.keep_min:
|
||||||
lines.append("keep_min = %d" % 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:
|
if group.all:
|
||||||
lines.append("all = yes")
|
lines.append("all = yes")
|
||||||
@@ -470,6 +574,23 @@ def _group_to_lines(group, globals_):
|
|||||||
return lines
|
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):
|
def dump_config(config):
|
||||||
"""Erzeugt den kompletten INI-Text zu einer Config."""
|
"""Erzeugt den kompletten INI-Text zu einer Config."""
|
||||||
g = config.globals
|
g = config.globals
|
||||||
@@ -486,11 +607,16 @@ def dump_config(config):
|
|||||||
lines.append("retries = %d" % g.retries)
|
lines.append("retries = %d" % g.retries)
|
||||||
lines.append("retry_delay = %s" % format_duration(g.retry_delay, zero="60s"))
|
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("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("run_on_start = %s" % format_bool(g.run_on_start))
|
||||||
lines.append("dry_run = %s" % format_bool(g.dry_run))
|
lines.append("dry_run = %s" % format_bool(g.dry_run))
|
||||||
lines.append("description = %s" % g.description)
|
lines.append("description = %s" % g.description)
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
|
if config.mail != MailConfig():
|
||||||
|
lines.extend(_mail_to_lines(config.mail))
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
for group in config.groups:
|
for group in config.groups:
|
||||||
lines.extend(_group_to_lines(group, g))
|
lines.extend(_group_to_lines(group, g))
|
||||||
lines.append("")
|
lines.append("")
|
||||||
@@ -498,8 +624,27 @@ def dump_config(config):
|
|||||||
return "\n".join(lines).rstrip() + "\n"
|
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):
|
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
|
target = path or config.path
|
||||||
directory = os.path.dirname(os.path.abspath(target))
|
directory = os.path.dirname(os.path.abspath(target))
|
||||||
os.makedirs(directory, exist_ok=True)
|
os.makedirs(directory, exist_ok=True)
|
||||||
@@ -508,13 +653,13 @@ def save_config(config, path=None):
|
|||||||
try:
|
try:
|
||||||
with open(target, "r", encoding="utf-8") as handle:
|
with open(target, "r", encoding="utf-8") as handle:
|
||||||
previous = handle.read()
|
previous = handle.read()
|
||||||
with open(target + ".bak", "w", encoding="utf-8") as handle:
|
with _open_private(target + ".bak", 0o600) as handle:
|
||||||
handle.write(previous)
|
handle.write(previous)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass # Backup ist Kuer, kein Muss
|
pass # Backup ist Kuer, kein Muss
|
||||||
|
|
||||||
tmp = target + ".tmp"
|
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.write(dump_config(config))
|
||||||
handle.flush()
|
handle.flush()
|
||||||
os.fsync(handle.fileno())
|
os.fsync(handle.fileno())
|
||||||
|
|||||||
@@ -149,8 +149,11 @@ def ask(win, question, answers, default=None):
|
|||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
def prompt(win, label, initial=""):
|
def prompt(win, label, initial="", secret=False):
|
||||||
"""Einzeilige Eingabe am unteren Rand. None = abgebrochen."""
|
"""Einzeilige Eingabe am unteren Rand. None = abgebrochen.
|
||||||
|
|
||||||
|
Mit `secret` erscheinen statt der Zeichen Sternchen.
|
||||||
|
"""
|
||||||
height, width = win.getmaxyx()
|
height, width = win.getmaxyx()
|
||||||
buffer = list(str(initial))
|
buffer = list(str(initial))
|
||||||
curses.curs_set(1)
|
curses.curs_set(1)
|
||||||
@@ -160,6 +163,8 @@ def prompt(win, label, initial=""):
|
|||||||
label_text = " %s: " % label
|
label_text = " %s: " % label
|
||||||
visible = width - len(label_text) - 2
|
visible = width - len(label_text) - 2
|
||||||
shown = text[-visible:] if visible > 0 and len(text) > visible else text
|
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))
|
fill(win, height - 2, label_text + shown, curses.color_pair(C_SEL))
|
||||||
try:
|
try:
|
||||||
win.move(height - 2, min(width - 2, len(label_text) + len(shown)))
|
win.move(height - 2, min(width - 2, len(label_text) + len(shown)))
|
||||||
|
|||||||
+4
-1
@@ -11,7 +11,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from datetime import datetime
|
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 .engine import run_group
|
||||||
from .preflight import warn_about_environment
|
from .preflight import warn_about_environment
|
||||||
from .proxmox import Proxmox, ProxmoxError
|
from .proxmox import Proxmox, ProxmoxError
|
||||||
@@ -112,6 +112,9 @@ class Daemon:
|
|||||||
raise ConfigError("Konfiguration fehlerhaft:\n - %s" % "\n - ".join(problems))
|
raise ConfigError("Konfiguration fehlerhaft:\n - %s" % "\n - ".join(problems))
|
||||||
if self.dry_run_override:
|
if self.dry_run_override:
|
||||||
config.globals.dry_run = True
|
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
|
self.config = config
|
||||||
if self.state is None or self.state.path != config.globals.state_file:
|
if self.state is None or self.state.path != config.globals.state_file:
|
||||||
self.state = State(config.globals.state_file).load()
|
self.state = State(config.globals.state_file).load()
|
||||||
|
|||||||
+178
-1
@@ -8,8 +8,11 @@ import time
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
from .mail import send_space_alert
|
||||||
from .naming import build_name, effective_slug, parse_name, render_description
|
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")
|
log = logging.getLogger("pvesnap.engine")
|
||||||
|
|
||||||
@@ -98,6 +101,174 @@ def plan_prune(prefix, group, snapshots, now):
|
|||||||
return doomed
|
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
|
# Ausfuehrung
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -110,6 +281,7 @@ class GroupResult:
|
|||||||
deleted: list = field(default_factory=list)
|
deleted: list = field(default_factory=list)
|
||||||
skipped: list = field(default_factory=list)
|
skipped: list = field(default_factory=list)
|
||||||
errors: list = field(default_factory=list)
|
errors: list = field(default_factory=list)
|
||||||
|
no_space: list = field(default_factory=list) # Abbrueche wegen Platzmangels
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ok(self):
|
def ok(self):
|
||||||
@@ -155,6 +327,9 @@ def run_group(proxmox, config, group, now=None, create=True, prune=True, guests=
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if create:
|
if create:
|
||||||
|
if not _ensure_space(proxmox, config, group, guest, result, may_delete=prune):
|
||||||
|
continue
|
||||||
|
|
||||||
description = render_description(template, group, guest, now, prefix)
|
description = render_description(template, group, guest, now, prefix)
|
||||||
try:
|
try:
|
||||||
proxmox.create_snapshot(
|
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.error("Gruppe '%s': %s", group.name, message)
|
||||||
|
|
||||||
log.info(result.summary())
|
log.info(result.summary())
|
||||||
|
if result.no_space:
|
||||||
|
send_space_alert(config, group.name, result.no_space)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
@@ -17,10 +18,14 @@ from dataclasses import dataclass, field
|
|||||||
log = logging.getLogger("pvesnap.proxmox")
|
log = logging.getLogger("pvesnap.proxmox")
|
||||||
|
|
||||||
PVESH = "/usr/bin/pvesh"
|
PVESH = "/usr/bin/pvesh"
|
||||||
|
ZFS = "/sbin/zfs"
|
||||||
|
|
||||||
# UPID:node:PID:PSTART:STARTTIME:TYPE:ID:USER:
|
# UPID:node:PID:PSTART:STARTTIME:TYPE:ID:USER:
|
||||||
_UPID_PATTERN = re.compile(r"UPID:[^\s\"']+")
|
_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):
|
class ProxmoxError(Exception):
|
||||||
"""Ein pvesh-Aufruf ist fehlgeschlagen."""
|
"""Ein pvesh-Aufruf ist fehlgeschlagen."""
|
||||||
@@ -61,6 +66,23 @@ class Snapshot:
|
|||||||
return not self.snapstate
|
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():
|
def pvesh_available():
|
||||||
return shutil.which(PVESH) is not None or shutil.which("pvesh") is not None
|
return shutil.which(PVESH) is not None or shutil.which("pvesh") is not None
|
||||||
|
|
||||||
@@ -80,6 +102,7 @@ class Proxmox:
|
|||||||
self.retries = retries
|
self.retries = retries
|
||||||
self.retry_delay = retry_delay
|
self.retry_delay = retry_delay
|
||||||
self._inventory_cache = None
|
self._inventory_cache = None
|
||||||
|
self._storage_cache = {}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_config(cls, config):
|
def from_config(cls, config):
|
||||||
@@ -207,6 +230,73 @@ class Proxmox:
|
|||||||
what = "Loeschen von %s bei %s" % (name, guest.label)
|
what = "Loeschen von %s bei %s" % (name, guest.label)
|
||||||
return self._retry(what, lambda: self._task(guest, args, what))
|
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 -----------------------------------------
|
# -- Gaeste lesen und steuern -----------------------------------------
|
||||||
|
|
||||||
def guest_config(self, guest, snapshot=None):
|
def guest_config(self, guest, snapshot=None):
|
||||||
|
|||||||
+129
-3
@@ -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)
|
is_escape, keybar, message, prompt, put, read_key)
|
||||||
from .config import (WEEKDAY_NAMES, Config, ConfigError, Group, clone_group,
|
from .config import (WEEKDAY_NAMES, Config, ConfigError, Group, clone_group,
|
||||||
load_config, parse_time_of_day, save_config)
|
load_config, parse_time_of_day, save_config)
|
||||||
|
from .mail import MailError, send_test_mail
|
||||||
from .naming import effective_slug
|
from .naming import effective_slug
|
||||||
from .proxmox import Proxmox, ProxmoxError, pvesh_available
|
from .proxmox import Proxmox, ProxmoxError, pvesh_available
|
||||||
from .schedule import describe
|
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)
|
parse_list, parse_vmid_list, truncate)
|
||||||
|
|
||||||
SCHEDULE_CHOICES = [
|
SCHEDULE_CHOICES = [
|
||||||
@@ -37,6 +38,12 @@ TYPE_CHOICES = [
|
|||||||
|
|
||||||
SERVICE = "pvesnap.service"
|
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)
|
# Tastenleiste der Gruppenliste: (Taste, lange Beschriftung, kurze Beschriftung)
|
||||||
GROUP_KEYS = [
|
GROUP_KEYS = [
|
||||||
("Enter", "Bearbeiten", "Bearb."),
|
("Enter", "Bearbeiten", "Bearb."),
|
||||||
@@ -45,6 +52,7 @@ GROUP_KEYS = [
|
|||||||
("d", "Loeschen", "Loesch"),
|
("d", "Loeschen", "Loesch"),
|
||||||
("Leer", "An/Aus", "An/Aus"),
|
("Leer", "An/Aus", "An/Aus"),
|
||||||
("g", "Global", "Global"),
|
("g", "Global", "Global"),
|
||||||
|
("m", "Mail", "Mail"),
|
||||||
("v", "VMs", "VMs"),
|
("v", "VMs", "VMs"),
|
||||||
("s", "Speichern", "Speich."),
|
("s", "Speichern", "Speich."),
|
||||||
("r", "Dienst neu laden", "Reload"),
|
("r", "Dienst neu laden", "Reload"),
|
||||||
@@ -147,8 +155,8 @@ class Editor:
|
|||||||
def _confirm(self, win, question):
|
def _confirm(self, win, question):
|
||||||
return confirm(win, question)
|
return confirm(win, question)
|
||||||
|
|
||||||
def _prompt(self, win, label, initial=""):
|
def _prompt(self, win, label, initial="", secret=False):
|
||||||
return prompt(win, label, initial)
|
return prompt(win, label, initial, secret)
|
||||||
|
|
||||||
def _choose(self, win, title, entries, current=0):
|
def _choose(self, win, title, entries, current=0):
|
||||||
return choose(win, title, entries, current)
|
return choose(win, title, entries, current)
|
||||||
@@ -240,6 +248,8 @@ class Editor:
|
|||||||
self.dirty = True
|
self.dirty = True
|
||||||
elif key == "g":
|
elif key == "g":
|
||||||
self.screen_globals(win)
|
self.screen_globals(win)
|
||||||
|
elif key == "m":
|
||||||
|
self.screen_mail(win)
|
||||||
elif key == "v":
|
elif key == "v":
|
||||||
self.screen_overview(win)
|
self.screen_overview(win)
|
||||||
elif key == "p":
|
elif key == "p":
|
||||||
@@ -556,6 +566,24 @@ class Editor:
|
|||||||
int_field("Mindestens behalten", lambda: group.keep_min,
|
int_field("Mindestens behalten", lambda: group.keep_min,
|
||||||
lambda v: setattr(group, "keep_min", v), zero_text="0 (keine Untergrenze)")
|
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 ---
|
# --- Auswahl ---
|
||||||
fields.append(("-", "Welche VMs?", "", None))
|
fields.append(("-", "Welche VMs?", "", None))
|
||||||
bool_field("Alle VMs/Container", lambda: group.all,
|
bool_field("Alle VMs/Container", lambda: group.all,
|
||||||
@@ -755,6 +783,9 @@ class Editor:
|
|||||||
"duration", "retry_delay"),
|
"duration", "retry_delay"),
|
||||||
("Pause zwischen zwei VMs", format_duration(globals_.pause_between, zero="keine"),
|
("Pause zwischen zwei VMs", format_duration(globals_.pause_between, zero="keine"),
|
||||||
"duration", "pause_between"),
|
"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",
|
("Beim Start sofort ausfuehren", "ja" if globals_.run_on_start else "nein",
|
||||||
"bool", "run_on_start"),
|
"bool", "run_on_start"),
|
||||||
("Testlauf (nichts wirklich tun)", "ja" if globals_.dry_run else "nein",
|
("Testlauf (nichts wirklich tun)", "ja" if globals_.dry_run else "nein",
|
||||||
@@ -827,6 +858,101 @@ class Editor:
|
|||||||
setattr(globals_, attribute, raw)
|
setattr(globals_, attribute, raw)
|
||||||
self.dirty = True
|
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 ------------------------------------------
|
# -- Bildschirm: Uebersicht ------------------------------------------
|
||||||
|
|
||||||
def screen_overview(self, win):
|
def screen_overview(self, win):
|
||||||
|
|||||||
@@ -64,6 +64,37 @@ def format_duration(seconds, zero="unbegrenzt"):
|
|||||||
return "".join(parts)
|
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):
|
def slugify(text, maxlen=20):
|
||||||
"""Gruppenname -> kurzer, PVE-tauglicher Bezeichner (nur a-z0-9, beginnt mit Buchstabe)."""
|
"""Gruppenname -> kurzer, PVE-tauglicher Bezeichner (nur a-z0-9, beginnt mit Buchstabe)."""
|
||||||
# Umlaute vor der Normalisierung ersetzen - NFKD wuerde sie sonst zerlegen.
|
# Umlaute vor der Normalisierung ersetzen - NFKD wuerde sie sonst zerlegen.
|
||||||
|
|||||||
Reference in New Issue
Block a user