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
+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)
|
||||
from .config import (WEEKDAY_NAMES, Config, ConfigError, Group, clone_group,
|
||||
load_config, parse_time_of_day, save_config)
|
||||
from .mail import MailError, send_test_mail
|
||||
from .naming import effective_slug
|
||||
from .proxmox import Proxmox, ProxmoxError, pvesh_available
|
||||
from .schedule import describe
|
||||
from .util import (format_duration, format_vmid_list, parse_duration,
|
||||
from .util import (format_duration, format_vmid_list, parse_duration, parse_free_space,
|
||||
parse_list, parse_vmid_list, truncate)
|
||||
|
||||
SCHEDULE_CHOICES = [
|
||||
@@ -37,6 +38,12 @@ TYPE_CHOICES = [
|
||||
|
||||
SERVICE = "pvesnap.service"
|
||||
|
||||
MAIL_SECURITY_TEXT = {
|
||||
"starttls": "STARTTLS (TLS, meist Port 587)",
|
||||
"ssl": "SSL/TLS von Anfang an (meist Port 465)",
|
||||
"none": "keine (nur fuer interne Relays ohne Anmeldung)",
|
||||
}
|
||||
|
||||
# Tastenleiste der Gruppenliste: (Taste, lange Beschriftung, kurze Beschriftung)
|
||||
GROUP_KEYS = [
|
||||
("Enter", "Bearbeiten", "Bearb."),
|
||||
@@ -45,6 +52,7 @@ GROUP_KEYS = [
|
||||
("d", "Loeschen", "Loesch"),
|
||||
("Leer", "An/Aus", "An/Aus"),
|
||||
("g", "Global", "Global"),
|
||||
("m", "Mail", "Mail"),
|
||||
("v", "VMs", "VMs"),
|
||||
("s", "Speichern", "Speich."),
|
||||
("r", "Dienst neu laden", "Reload"),
|
||||
@@ -147,8 +155,8 @@ class Editor:
|
||||
def _confirm(self, win, question):
|
||||
return confirm(win, question)
|
||||
|
||||
def _prompt(self, win, label, initial=""):
|
||||
return prompt(win, label, initial)
|
||||
def _prompt(self, win, label, initial="", secret=False):
|
||||
return prompt(win, label, initial, secret)
|
||||
|
||||
def _choose(self, win, title, entries, current=0):
|
||||
return choose(win, title, entries, current)
|
||||
@@ -240,6 +248,8 @@ class Editor:
|
||||
self.dirty = True
|
||||
elif key == "g":
|
||||
self.screen_globals(win)
|
||||
elif key == "m":
|
||||
self.screen_mail(win)
|
||||
elif key == "v":
|
||||
self.screen_overview(win)
|
||||
elif key == "p":
|
||||
@@ -556,6 +566,24 @@ class Editor:
|
||||
int_field("Mindestens behalten", lambda: group.keep_min,
|
||||
lambda v: setattr(group, "keep_min", v), zero_text="0 (keine Untergrenze)")
|
||||
|
||||
# --- Platz ---
|
||||
fields.append(("-", "Platz vor dem Snapshot", "", None))
|
||||
|
||||
def set_min_free(win):
|
||||
raw = self._prompt(win, "Mindestens frei (z.B. 10%, 20G, 0=keine Pruefung)",
|
||||
group.space_min_free)
|
||||
if raw is None:
|
||||
return
|
||||
try:
|
||||
group.space_min_free = parse_free_space(raw)
|
||||
except ValueError as exc:
|
||||
self._message(win, str(exc), error=True)
|
||||
fields.append(("f", "Mindestens frei",
|
||||
"keine Pruefung" if group.space_min_free == "0"
|
||||
else group.space_min_free, set_min_free))
|
||||
int_field("Bei Platzmangel behalten", lambda: group.space_keep,
|
||||
lambda v: setattr(group, "space_keep", v))
|
||||
|
||||
# --- Auswahl ---
|
||||
fields.append(("-", "Welche VMs?", "", None))
|
||||
bool_field("Alle VMs/Container", lambda: group.all,
|
||||
@@ -755,6 +783,9 @@ class Editor:
|
||||
"duration", "retry_delay"),
|
||||
("Pause zwischen zwei VMs", format_duration(globals_.pause_between, zero="keine"),
|
||||
"duration", "pause_between"),
|
||||
("Geduld nach Loeschen bei Platzmangel",
|
||||
format_duration(globals_.space_settle, zero="keine"),
|
||||
"duration", "space_settle"),
|
||||
("Beim Start sofort ausfuehren", "ja" if globals_.run_on_start else "nein",
|
||||
"bool", "run_on_start"),
|
||||
("Testlauf (nichts wirklich tun)", "ja" if globals_.dry_run else "nein",
|
||||
@@ -827,6 +858,101 @@ class Editor:
|
||||
setattr(globals_, attribute, raw)
|
||||
self.dirty = True
|
||||
|
||||
# -- Bildschirm: Mail-Alarm ------------------------------------------
|
||||
|
||||
def screen_mail(self, win):
|
||||
mail = self.config.mail
|
||||
index = 0
|
||||
while True:
|
||||
fields = [
|
||||
("Alarm per Mail", "ja" if mail.enabled else "nein", "enabled"),
|
||||
("SMTP-Server", mail.server or "-", "server"),
|
||||
("Verschluesselung", MAIL_SECURITY_TEXT.get(mail.security, mail.security),
|
||||
"security"),
|
||||
("Port", "%d%s" % (mail.effective_port, "" if mail.port else " (automatisch)"),
|
||||
"port"),
|
||||
("Zertifikat pruefen", "ja" if mail.tls_verify else "nein", "tls_verify"),
|
||||
("Benutzer", mail.user or "- (ohne Anmeldung)", "user"),
|
||||
("Passwort", "(gesetzt)" if mail.password else "-", "password"),
|
||||
("Absender", mail.sender or "-", "sender"),
|
||||
("Empfaenger", ", ".join(mail.recipients) or "-", "recipients"),
|
||||
]
|
||||
index = max(0, min(index, len(fields) - 1))
|
||||
_height, width = self._frame(win, "Mail-Alarm", self.path,
|
||||
"Enter Aendern | t Test-Mail | q zurueck")
|
||||
for row, (label, value, _attribute) in enumerate(fields):
|
||||
attr = curses.color_pair(C_SEL) if row == index else 0
|
||||
fill_row(win, 4 + row, 2,
|
||||
truncate(label, 32).ljust(34) + truncate(str(value), max(4, width - 42)),
|
||||
width, attr)
|
||||
put(win, 6 + len(fields), 2,
|
||||
"Meldet, wenn wegen Platzmangels ein Snapshot ausfaellt.",
|
||||
curses.color_pair(C_DIM))
|
||||
win.refresh()
|
||||
|
||||
key = read_key(win)
|
||||
if key in (curses.KEY_UP, "k"):
|
||||
index -= 1
|
||||
elif key in (curses.KEY_DOWN, "j"):
|
||||
index += 1
|
||||
elif is_enter(key) or key == " ":
|
||||
self._edit_mail(win, fields[index][0], fields[index][2])
|
||||
elif key == "t":
|
||||
self._send_test_mail(win)
|
||||
elif key in ("q", "Q") or is_escape(key):
|
||||
return
|
||||
|
||||
def _edit_mail(self, win, label, attribute):
|
||||
mail = self.config.mail
|
||||
if attribute in ("enabled", "tls_verify"):
|
||||
setattr(mail, attribute, not getattr(mail, attribute))
|
||||
elif attribute == "security":
|
||||
chosen = self._choose(win, label, list(MAIL_SECURITY_TEXT.items()))
|
||||
if chosen is None:
|
||||
return
|
||||
mail.security = chosen
|
||||
elif attribute == "port":
|
||||
raw = self._prompt(win, "Port (0 = automatisch)", str(mail.port))
|
||||
if raw is None:
|
||||
return
|
||||
if not raw.isdigit() or int(raw) > 65535:
|
||||
self._message(win, "Bitte einen Port zwischen 0 und 65535 eingeben.", error=True)
|
||||
return
|
||||
mail.port = int(raw)
|
||||
elif attribute == "password":
|
||||
raw = self._prompt(win, "Passwort (leer = unveraendert, - = entfernen)", "",
|
||||
secret=True)
|
||||
if not raw:
|
||||
return
|
||||
mail.password = "" if raw == "-" else raw
|
||||
elif attribute == "recipients":
|
||||
raw = self._prompt(win, "Empfaenger (mehrere mit Komma)",
|
||||
", ".join(mail.recipients))
|
||||
if raw is None:
|
||||
return
|
||||
mail.recipients = parse_list(raw)
|
||||
else:
|
||||
raw = self._prompt(win, label, getattr(mail, attribute))
|
||||
if raw is None:
|
||||
return
|
||||
setattr(mail, attribute, raw)
|
||||
self.dirty = True
|
||||
|
||||
def _send_test_mail(self, win):
|
||||
problems = self.config.mail.problems()
|
||||
if problems:
|
||||
self._message(win, problems[0], error=True)
|
||||
return
|
||||
height, _width = win.getmaxyx()
|
||||
fill(win, height - 2, " Test-Mail wird gesendet ...", curses.color_pair(C_SEL))
|
||||
win.refresh()
|
||||
try:
|
||||
send_test_mail(self.config.mail)
|
||||
self._message(win, "Test-Mail an %s gesendet."
|
||||
% ", ".join(self.config.mail.recipients))
|
||||
except MailError as exc:
|
||||
self._message(win, "Test-Mail fehlgeschlagen: %s" % exc, error=True)
|
||||
|
||||
# -- Bildschirm: Uebersicht ------------------------------------------
|
||||
|
||||
def screen_overview(self, win):
|
||||
|
||||
Reference in New Issue
Block a user