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
+152
-7
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user