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:
duffyduck
2026-11-01 10:27:21 +01:00
co-authored by Claude Opus 5.5
parent d9d9345d03
commit 74be5aee59
21 changed files with 1125 additions and 26 deletions
+152 -7
View File
@@ -8,7 +8,8 @@ import re
from dataclasses import dataclass, field, replace
from .util import (format_bool, format_duration, format_vmid_list, parse_bool,
parse_duration, parse_list, parse_vmid_list, slugify)
parse_duration, parse_free_space, parse_list, parse_vmid_list,
slugify)
DEFAULT_CONFIG_PATH = "/etc/pvesnap/pvesnap.conf"
@@ -34,6 +35,12 @@ DEFAULT_DESCRIPTION = (
)
MAIL_PORTS = {"starttls": 587, "ssl": 465, "none": 25}
_MAIL_SECURITY = {"starttls": "starttls", "tls": "starttls", "ssl": "ssl", "smtps": "ssl",
"none": "none", "keine": "none", "off": "none", "aus": "none", "no": "none"}
class ConfigError(Exception):
"""Fehler beim Lesen oder Pruefen der Konfiguration."""
@@ -86,6 +93,10 @@ class Group:
keep_time: int = 0 # max. Alter in Sekunden (0 = unbegrenzt)
keep_min: int = 0 # so viele bleiben immer stehen
# --- Platz ----------------------------------------------------------
space_min_free: str = "20%" # so viel muss vorher frei sein (0 = aus)
space_keep: int = 7 # so viele bleiben je Gast bei Platzmangel
# --- Auswahl der Gaeste ---------------------------------------------
all: bool = False
vmids: list = field(default_factory=list)
@@ -155,6 +166,7 @@ class GlobalConfig:
retries: int = 2 # Wiederholungen bei belegten Sperren
retry_delay: int = 60 # Wartezeit dazwischen
pause_between: int = 0 # Pause zwischen zwei Gaesten
space_settle: int = 30 # Geduld, bis geloeschter Platz frei wird
def validate(self):
problems = []
@@ -174,9 +186,49 @@ class GlobalConfig:
return problems
@dataclass
class MailConfig:
"""SMTP-Zugang fuer den Alarm bei Platzmangel."""
enabled: bool = False
server: str = ""
port: int = 0 # 0 = passend zu 'security'
security: str = "starttls" # starttls | ssl | none
tls_verify: bool = True # Zertifikat des Servers pruefen
user: str = ""
password: str = ""
sender: str = ""
recipients: list = field(default_factory=list)
@property
def effective_port(self):
return self.port or MAIL_PORTS.get(self.security, 25)
def problems(self):
problems = []
if not self.server:
problems.append("[mail] kein 'server' angegeben")
if not self.sender:
problems.append("[mail] kein 'sender' angegeben")
if not self.recipients:
problems.append("[mail] keine 'recipients' angegeben")
if self.security not in MAIL_PORTS:
problems.append("[mail] unbekannte 'security': %s (erlaubt: %s)"
% (self.security, ", ".join(MAIL_PORTS)))
if not 0 <= self.port <= 65535:
problems.append("[mail] 'port' muss zwischen 1 und 65535 liegen")
if self.password and not self.user:
problems.append("[mail] 'password' ohne 'user'")
if self.user and self.security == "none":
problems.append("[mail] Anmeldung ohne Verschluesselung wuerde das Passwort "
"im Klartext senden - 'security' auf starttls oder ssl stellen")
return problems
@dataclass
class Config:
globals: GlobalConfig = field(default_factory=GlobalConfig)
mail: MailConfig = field(default_factory=MailConfig)
groups: list = field(default_factory=list)
defaults: dict = field(default_factory=dict)
path: str = DEFAULT_CONFIG_PATH
@@ -191,6 +243,8 @@ class Config:
from .naming import effective_slug
problems = list(self.globals.validate())
if self.mail.enabled:
problems.extend(self.mail.problems())
seen = {}
for group in self.groups:
problems.extend(group.validate())
@@ -212,7 +266,7 @@ _GROUP_KEYS = {
"enabled", "interval", "schedule", "at", "minute", "day_of_week", "day_of_month",
"month", "align", "keep_count", "keep_time", "keep_min", "all", "vmids", "names",
"tags", "pools", "types", "exclude_vmids", "exclude_names", "exclude_tags",
"vmstate", "skip_stopped", "description",
"vmstate", "skip_stopped", "description", "space_min_free", "space_keep",
}
# Deutsche Schreibweisen als Synonyme, damit die INI lesbar bleibt.
@@ -227,6 +281,8 @@ _KEY_ALIASES = {
"anzahl": "keep_count", "max_anzahl": "keep_count", "behalte_anzahl": "keep_count",
"vorhaltezeit": "keep_time", "behalte_zeit": "keep_time", "max_alter": "keep_time",
"mindestens": "keep_min",
"mindestens_frei": "space_min_free",
"platzmangel_behalten": "space_keep",
"alle": "all",
"namen": "names",
"typen": "types",
@@ -234,6 +290,11 @@ _KEY_ALIASES = {
"ausschluss_vmids": "exclude_vmids",
"ausschluss_namen": "exclude_names",
"gestoppte_ueberspringen": "skip_stopped",
"verschluesselung": "security",
"benutzer": "user",
"passwort": "password",
"absender": "sender",
"empfaenger": "recipients",
}
@@ -243,7 +304,7 @@ _KEY_ALIASES = {
_INLINE_COMMENT = re.compile(r"\s+[#;].*$", re.DOTALL)
# Schluessel, deren Wert unangetastet bleibt (freier Text).
_VERBATIM_KEYS = {"description", "description_template"}
_VERBATIM_KEYS = {"description", "description_template", "password"}
def _strip_comment(value):
@@ -281,6 +342,12 @@ def load_config(path=DEFAULT_CONFIG_PATH):
config.globals = _parse_global(_normalize_section(parser[name]), errors)
break
# [mail]
for name in parser.sections():
if name.strip().lower() in ("mail", "smtp"):
config.mail = _parse_mail(_normalize_section(parser[name]), errors)
break
# [defaults] - Vorgabewerte fuer alle Gruppen
defaults = {}
for name in parser.sections():
@@ -329,6 +396,7 @@ def _parse_global(section, errors):
take("retries", lambda v: int(str(v).strip()))
take("retry_delay", lambda v: parse_duration(v, default_unit="s"))
take("pause_between", lambda v: parse_duration(v, default_unit="s"))
take("space_settle", lambda v: parse_duration(v, default_unit="s"))
# Synonyme
if "description_template" in section:
result.description = str(section["description_template"]).strip()
@@ -342,11 +410,43 @@ def _parse_global(section, errors):
if key not in ("prefix", "check_interval", "state_file", "log_level", "log_file",
"lock_file", "dry_run", "task_timeout", "run_on_start", "description",
"description_template", "testlauf", "retries", "retry_delay",
"pause_between"):
"pause_between", "space_settle"):
errors.append("[global] unbekannter Schluessel: %s" % key)
return result
def _parse_mail_security(text):
value = str(text).strip().lower()
if value not in _MAIL_SECURITY:
raise ValueError("erlaubt sind starttls, ssl und none, nicht %r" % text)
return _MAIL_SECURITY[value]
def _parse_mail(section, errors):
result = MailConfig()
parsers = {
"enabled": parse_bool,
"server": lambda v: str(v).strip(),
"port": lambda v: int(str(v).strip()),
"security": _parse_mail_security,
"tls_verify": parse_bool,
"user": lambda v: str(v).strip(),
"password": lambda v: str(v),
"sender": lambda v: str(v).strip(),
"recipients": parse_list,
}
for key, raw in section.items():
if key not in parsers:
errors.append("[mail] unbekannter Schluessel: %s" % key)
continue
try:
value = raw if key in _VERBATIM_KEYS else _strip_comment(raw)
setattr(result, key, parsers[key](value))
except ValueError as exc:
errors.append("[mail] %s: %s" % (key, exc))
return result
def _parse_group(name, section, errors):
group = Group(name=name)
prefix = "[group:%s]" % name
@@ -373,6 +473,8 @@ def _parse_group(name, section, errors):
take("keep_count", lambda v: max(0, int(str(v).strip())))
take("keep_time", lambda v: parse_duration(v, default_unit="d"))
take("keep_min", lambda v: max(0, int(str(v).strip())))
take("space_min_free", parse_free_space)
take("space_keep", lambda v: max(0, int(str(v).strip())))
take("all", parse_bool)
take("vmids", parse_vmid_list)
@@ -441,6 +543,8 @@ def _group_to_lines(group, globals_):
lines.append("keep_time = %s" % format_duration(group.keep_time, zero="0"))
if group.keep_min:
lines.append("keep_min = %d" % group.keep_min)
lines.append("space_min_free = %s" % group.space_min_free)
lines.append("space_keep = %d" % group.space_keep)
if group.all:
lines.append("all = yes")
@@ -470,6 +574,23 @@ def _group_to_lines(group, globals_):
return lines
def _mail_to_lines(mail):
lines = ["[mail]", "enabled = %s" % format_bool(mail.enabled)]
lines.append("server = %s" % mail.server)
if mail.port:
lines.append("port = %d" % mail.port)
lines.append("security = %s" % mail.security)
if not mail.tls_verify:
lines.append("tls_verify = no")
if mail.user:
lines.append("user = %s" % mail.user)
if mail.password:
lines.append("password = %s" % mail.password)
lines.append("sender = %s" % mail.sender)
lines.append("recipients = %s" % ", ".join(mail.recipients))
return lines
def dump_config(config):
"""Erzeugt den kompletten INI-Text zu einer Config."""
g = config.globals
@@ -486,11 +607,16 @@ def dump_config(config):
lines.append("retries = %d" % g.retries)
lines.append("retry_delay = %s" % format_duration(g.retry_delay, zero="60s"))
lines.append("pause_between = %s" % format_duration(g.pause_between, zero="0"))
lines.append("space_settle = %s" % format_duration(g.space_settle, zero="0"))
lines.append("run_on_start = %s" % format_bool(g.run_on_start))
lines.append("dry_run = %s" % format_bool(g.dry_run))
lines.append("description = %s" % g.description)
lines.append("")
if config.mail != MailConfig():
lines.extend(_mail_to_lines(config.mail))
lines.append("")
for group in config.groups:
lines.extend(_group_to_lines(group, g))
lines.append("")
@@ -498,8 +624,27 @@ def dump_config(config):
return "\n".join(lines).rstrip() + "\n"
def _open_private(path, mode):
"""Oeffnet zum Schreiben und setzt die Rechte, bevor etwas hineinkommt."""
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode)
os.fchmod(descriptor, mode)
return os.fdopen(descriptor, "w", encoding="utf-8")
def world_readable_secret(config):
"""Liegt das SMTP-Passwort in einer Datei, die jeder lesen darf?"""
try:
return bool(config.mail.password and os.stat(config.path).st_mode & 0o007)
except OSError:
return False
def save_config(config, path=None):
"""Schreibt die Config atomar; legt vorher eine .bak-Kopie an."""
"""Schreibt die Config atomar; legt vorher eine .bak-Kopie an.
Die Datei kann das SMTP-Passwort enthalten, und die Kopie ein frueheres -
deshalb darf sie nur root lesen.
"""
target = path or config.path
directory = os.path.dirname(os.path.abspath(target))
os.makedirs(directory, exist_ok=True)
@@ -508,13 +653,13 @@ def save_config(config, path=None):
try:
with open(target, "r", encoding="utf-8") as handle:
previous = handle.read()
with open(target + ".bak", "w", encoding="utf-8") as handle:
with _open_private(target + ".bak", 0o600) as handle:
handle.write(previous)
except OSError:
pass # Backup ist Kuer, kein Muss
tmp = target + ".tmp"
with open(tmp, "w", encoding="utf-8") as handle:
with _open_private(tmp, 0o600 if config.mail.password else 0o640) as handle:
handle.write(dump_config(config))
handle.flush()
os.fsync(handle.fileno())