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
+26 -1
View File
@@ -9,7 +9,7 @@ import sys
from datetime import datetime
from . import __version__
from .config import DEFAULT_CONFIG_PATH, ConfigError, load_config
from .config import DEFAULT_CONFIG_PATH, ConfigError, load_config, world_readable_secret
from .daemon import Daemon, SingleInstanceLock
from .engine import run_group, select_guests
from .naming import parse_name
@@ -337,6 +337,10 @@ def cmd_check(args):
print(" - %s" % problem, file=sys.stderr)
return 1
environment = check_environment(require_pve=pvesh_available())
if world_readable_secret(config):
environment = list(environment) + ["%s enthaelt das SMTP-Passwort und ist fuer alle "
"lesbar - bitte: chmod 600 %s"
% (config.path, config.path)]
if environment:
print("Hinweise zur Umgebung:", file=sys.stderr)
for problem in environment:
@@ -352,6 +356,24 @@ def cmd_check(args):
return 0
def cmd_mail_test(args):
from .mail import MailError, send_test_mail
mail = load_config(args.config).mail
problems = mail.problems()
if problems:
print("Mail-Einstellungen unvollstaendig:", file=sys.stderr)
for problem in problems:
print(" - %s" % problem, file=sys.stderr)
return 1
try:
send_test_mail(mail)
except MailError as exc:
print("Test-Mail fehlgeschlagen: %s" % exc, file=sys.stderr)
return 1
print("Test-Mail an %s gesendet." % ", ".join(mail.recipients))
return 0
def cmd_config(args):
from .tui import run_editor
return run_editor(args.config)
@@ -405,6 +427,9 @@ def build_parser():
p_check = sub.add_parser("check", help="Konfiguration pruefen")
p_check.set_defaults(func=cmd_check)
p_mail = sub.add_parser("mail-test", help="Test-Mail mit den Einstellungen aus [mail] senden")
p_mail.set_defaults(func=cmd_mail_test)
for name in ("config", "edit"):
p_config = sub.add_parser(name, help="Konfiguration im ncurses-Editor bearbeiten")
p_config.set_defaults(func=cmd_config)
+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())
+7 -2
View File
@@ -149,8 +149,11 @@ def ask(win, question, answers, default=None):
return default
def prompt(win, label, initial=""):
"""Einzeilige Eingabe am unteren Rand. None = abgebrochen."""
def prompt(win, label, initial="", secret=False):
"""Einzeilige Eingabe am unteren Rand. None = abgebrochen.
Mit `secret` erscheinen statt der Zeichen Sternchen.
"""
height, width = win.getmaxyx()
buffer = list(str(initial))
curses.curs_set(1)
@@ -160,6 +163,8 @@ def prompt(win, label, initial=""):
label_text = " %s: " % label
visible = width - len(label_text) - 2
shown = text[-visible:] if visible > 0 and len(text) > visible else text
if secret:
shown = "*" * len(shown)
fill(win, height - 2, label_text + shown, curses.color_pair(C_SEL))
try:
win.move(height - 2, min(width - 2, len(label_text) + len(shown)))
+4 -1
View File
@@ -11,7 +11,7 @@ import threading
import time
from datetime import datetime
from .config import ConfigError, load_config
from .config import ConfigError, load_config, world_readable_secret
from .engine import run_group
from .preflight import warn_about_environment
from .proxmox import Proxmox, ProxmoxError
@@ -112,6 +112,9 @@ class Daemon:
raise ConfigError("Konfiguration fehlerhaft:\n - %s" % "\n - ".join(problems))
if self.dry_run_override:
config.globals.dry_run = True
if world_readable_secret(config):
log.warning("%s enthaelt das SMTP-Passwort und ist fuer alle lesbar - "
"bitte: chmod 600 %s", config.path, config.path)
self.config = config
if self.state is None or self.state.path != config.globals.state_file:
self.state = State(config.globals.state_file).load()
+178 -1
View File
@@ -8,8 +8,11 @@ import time
from dataclasses import dataclass, field
from datetime import datetime
from .mail import send_space_alert
from .naming import build_name, effective_slug, parse_name, render_description
from .proxmox import ProxmoxError
from .proxmox import ProxmoxError, guest_volumes
from .snapfs import human_bytes
from .util import free_space_bytes, parse_free_space
log = logging.getLogger("pvesnap.engine")
@@ -98,6 +101,174 @@ def plan_prune(prefix, group, snapshots, now):
return doomed
def plan_reclaim(config, group, snapshots):
"""Was bei Platzmangel weg darf, in der Reihenfolge des Loeschens.
Anders als die Vorhaltezeit zaehlt das je Gast ueber alle pvesnap-Snapshots,
auch die von Gruppen, die es nicht mehr gibt. Ein voller Pool ist schlimmer
als ein verlorener alter Stand, und gerade die aeltesten halten am meisten
Platz fest. Getrennt gezaehlt koennte eine Gruppe mit keep_count unter
space_keep nie etwas freigeben.
Halbfertige Eintraege zuerst - sie enthalten nichts. Danach die aeltesten,
bis nur noch die neuesten space_keep uebrig sind. Das keep_min jeder Gruppe
bleibt dabei gewahrt.
"""
prefix = config.globals.prefix
managed = []
for snap in snapshots:
parsed = parse_name(prefix, snap.name)
if parsed:
created = (datetime.fromtimestamp(snap.snaptime) if snap.snaptime
else parsed["created"])
managed.append((created, snap))
managed.sort(key=lambda item: item[0], reverse=True) # neueste zuerst
broken = [snap for _created, snap in managed if snap.snapstate]
complete = [snap for _created, snap in managed if not snap.snapstate]
kept = {snap.name for snap in complete[:group.space_keep]}
for other in config.groups:
own = [snap for snap, _created in managed_snapshots(prefix, other, complete)]
kept.update(snap.name for snap in own[:other.keep_min])
return broken[::-1] + [snap for snap in complete[::-1] if snap.name not in kept]
# ---------------------------------------------------------------------------
# Platz vor dem Anlegen
# ---------------------------------------------------------------------------
SPACE_POLL = 5 # Sekunden zwischen zwei Blicken auf das Storage
def _space_demand(proxmox, guest, group):
"""Je Storage des Gastes der Platz, den der Snapshot sofort belegt.
Fuer die meisten Platten ist das nichts - ein Snapshot waechst erst mit dem,
was danach geschrieben wird. Sofort kosten nur dick angelegte ZFS-Zvols und
der mitgesicherte RAM.
"""
config = proxmox.guest_config(guest)
demand = {}
for volid in guest_volumes(config, guest.type):
name = volid.split(":", 1)[0]
demand[name] = demand.get(name, 0) + proxmox.snapshot_cost(guest.node, volid)
if group.vmstate and guest.running and guest.type == "qemu":
ram = int(proxmox.guest_status(guest).get("maxmem") or 0)
target = config.get("vmstatestorage")
for name in ([target] if target else list(demand)):
demand[name] = demand.get(name, 0) + ram
return demand
def _space_shortages(proxmox, guest, group, demand):
"""Die Storages, auf denen es nicht reicht: [(Name, frei, gebraucht)].
Gebraucht wird, was der Snapshot sofort belegt, plus die Reserve, die danach
noch frei bleiben muss.
"""
shortages = []
for name, extra in sorted(demand.items()):
space = proxmox.storage_space(guest.node, name)
if space is None:
continue
free, total = space
reserve = free_space_bytes(group.space_min_free, total)
needed = reserve + extra
log.log(logging.INFO if proxmox.dry_run else logging.DEBUG,
"%s: '%s': %s frei, Snapshot belegt sofort %s, danach frei %s, "
"Reserve %s", guest.label, name, human_bytes(free), human_bytes(extra),
human_bytes(max(0, free - extra)), human_bytes(reserve))
if free < needed:
shortages.append((name, free, needed))
return shortages
def _describe_shortages(shortages):
return ", ".join("'%s': %s frei, %s gebraucht"
% (name, human_bytes(free), human_bytes(needed))
for name, free, needed in shortages)
def _wait_for_space(proxmox, guest, group, demand, settle, limit):
"""Nach dem Loeschen abwarten, bis der Platz wirklich zurueckgekommen ist.
Ceph und ZFS geben ihn erst im Hintergrund frei. Saehe man sofort nach,
fehlte er scheinbar noch - und der naechste Snapshot wuerde umsonst
geloescht. Gewartet wird deshalb, solange der freie Platz noch waechst, und
aufgegeben erst, wenn sich `settle` Sekunden lang nichts mehr tut.
"""
shortages = _space_shortages(proxmox, guest, group, demand)
best = sum(free for _name, free, _needed in shortages)
started = last_growth = time.monotonic()
while (shortages and time.monotonic() - last_growth < settle
and time.monotonic() - started < limit):
time.sleep(min(SPACE_POLL, settle))
shortages = _space_shortages(proxmox, guest, group, demand)
free_now = sum(free for _name, free, _needed in shortages)
if free_now > best:
best = free_now
last_growth = time.monotonic()
return shortages
def _ensure_space(proxmox, config, group, guest, result, may_delete=True):
"""Sorgt vor dem Snapshot fuer Platz. False = dieser Gast wird ausgelassen.
Geloescht wird einzeln und immer nur so viel, bis es reicht. Laesst sich der
Platz nicht feststellen, bleibt alles stehen und der Snapshot wird versucht:
Auf Verdacht wird nichts geloescht.
"""
if parse_free_space(group.space_min_free) == "0":
return True
try:
demand = _space_demand(proxmox, guest, group)
shortages = _space_shortages(proxmox, guest, group, demand)
if not shortages:
return True
snapshots = proxmox.list_snapshots(guest)
except ProxmoxError as exc:
log.warning("Gruppe '%s': Platz fuer %s nicht pruefbar: %s",
group.name, guest.label, exc)
return True
log.warning("Gruppe '%s': %s: zu wenig Platz (%s)",
group.name, guest.label, _describe_shortages(shortages))
candidates = plan_reclaim(config, group, snapshots) if may_delete else []
for snap in candidates:
try:
proxmox.delete_snapshot(guest, snap.name, force=bool(snap.snapstate))
except ProxmoxError as exc:
message = "%s: '%s' nicht loeschbar: %s" % (guest.label, snap.name, exc)
result.errors.append(message)
log.error("Gruppe '%s': %s", group.name, message)
continue
result.deleted.append("%s:%s" % (guest.vmid, snap.name))
log.info("Gruppe '%s': Snapshot '%s' von %s geloescht (Platzmangel)",
group.name, snap.name, guest.label)
if proxmox.dry_run:
continue
shortages = _wait_for_space(proxmox, guest, group, demand,
config.globals.space_settle,
config.globals.task_timeout)
if not shortages:
log.info("Gruppe '%s': %s: Platz reicht jetzt", group.name, guest.label)
return True
if proxmox.dry_run and candidates:
log.info("[TESTLAUF] %s: ein echter Lauf loescht davon nur, bis der Platz "
"reicht", guest.label)
return True
message = ("%s: kein Snapshot angelegt, zu wenig Platz (%s) und nichts mehr "
"loeschbar" % (guest.label, _describe_shortages(shortages)))
result.errors.append(message)
result.no_space.append(message)
log.error("Gruppe '%s': %s", group.name, message)
return False
# ---------------------------------------------------------------------------
# Ausfuehrung
# ---------------------------------------------------------------------------
@@ -110,6 +281,7 @@ class GroupResult:
deleted: list = field(default_factory=list)
skipped: list = field(default_factory=list)
errors: list = field(default_factory=list)
no_space: list = field(default_factory=list) # Abbrueche wegen Platzmangels
@property
def ok(self):
@@ -155,6 +327,9 @@ def run_group(proxmox, config, group, now=None, create=True, prune=True, guests=
continue
if create:
if not _ensure_space(proxmox, config, group, guest, result, may_delete=prune):
continue
description = render_description(template, group, guest, now, prefix)
try:
proxmox.create_snapshot(
@@ -179,6 +354,8 @@ def run_group(proxmox, config, group, now=None, create=True, prune=True, guests=
log.error("Gruppe '%s': %s", group.name, message)
log.info(result.summary())
if result.no_space:
send_space_alert(config, group.name, result.no_space)
return result
+96
View File
@@ -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)
+90
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import json
import logging
import re
import shlex
import shutil
import subprocess
import time
@@ -17,10 +18,14 @@ from dataclasses import dataclass, field
log = logging.getLogger("pvesnap.proxmox")
PVESH = "/usr/bin/pvesh"
ZFS = "/sbin/zfs"
# UPID:node:PID:PSTART:STARTTIME:TYPE:ID:USER:
_UPID_PATTERN = re.compile(r"UPID:[^\s\"']+")
QEMU_DISK_KEY = re.compile(r"^((ide|sata|scsi|virtio)\d+|efidisk0|tpmstate0)$")
LXC_DISK_KEY = re.compile(r"^(rootfs|mp\d+)$")
class ProxmoxError(Exception):
"""Ein pvesh-Aufruf ist fehlgeschlagen."""
@@ -61,6 +66,23 @@ class Snapshot:
return not self.snapstate
def guest_volumes(config, guest_type):
"""Die Volumes (storage:name) der Datentraeger eines Gastes.
Durchgereichte Platten und Bind-Mounts haben kein Storage, ein CD-Laufwerk
bekommt keinen Snapshot.
"""
pattern = LXC_DISK_KEY if guest_type == "lxc" else QEMU_DISK_KEY
volumes = []
for key, value in sorted(config.items()):
if not pattern.match(key) or "media=cdrom" in str(value):
continue
volid = str(value).split(",", 1)[0].strip()
if ":" in volid and not volid.startswith("/"):
volumes.append(volid)
return volumes
def pvesh_available():
return shutil.which(PVESH) is not None or shutil.which("pvesh") is not None
@@ -80,6 +102,7 @@ class Proxmox:
self.retries = retries
self.retry_delay = retry_delay
self._inventory_cache = None
self._storage_cache = {}
@classmethod
def from_config(cls, config):
@@ -207,6 +230,73 @@ class Proxmox:
what = "Loeschen von %s bei %s" % (name, guest.label)
return self._retry(what, lambda: self._task(guest, args, what))
# -- Platz auf den Storages -------------------------------------------
def storage_space(self, node, storage):
"""(frei, gesamt) in Bytes - oder None, wenn es sich nicht sagen laesst.
"Frei" ist bei Ceph MAX AVAIL, also schon nach Replikation.
"""
try:
info = self._json(["get", "/nodes/%s/storage/%s/status" % (node, storage)])
except ProxmoxError as exc:
log.warning("Storage '%s' auf %s nicht abfragbar: %s", storage, node, exc)
return None
# Ein inaktives Storage meldet 0 von 0 - das ist nicht dasselbe wie voll.
if not isinstance(info, dict) or not info.get("total"):
log.warning("Storage '%s' auf %s meldet keine Groesse", storage, node)
return None
return int(info.get("avail") or 0), int(info["total"])
def snapshot_cost(self, node, volid):
"""Was ein Snapshot dieses Volumes sofort belegt, in Bytes.
Fast ueberall nichts. Nur ein dick angelegtes ZFS-Zvol muss seine ganze
Reservierung auch nach dem Snapshot garantieren: Alles seit dem letzten
Snapshot Geschriebene wird dann zusaetzlich belegt. Das laesst sich exakt
ausrechnen, die Proxmox-API liefert die Werte aber nicht - daher `zfs get`.
Klappt das nicht, zaehlt das Volume mit 0: Auf Verdacht wird nichts geloescht.
"""
storage, volname = volid.split(":", 1)
info = self._storage_config(storage)
if info.get("type") != "zfspool" or not info.get("pool"):
return 0
# Bei verknuepften Klonen steht davor noch die Vorlage: base-1-disk-0/vm-2-disk-0
dataset = "%s/%s" % (info["pool"], volname.rsplit("/", 1)[-1])
command = [ZFS, "get", "-Hp", "-o", "value",
"refreservation,usedbyrefreservation", dataset]
if node != self.local_node():
command = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10",
"-o", "HostKeyAlias=%s" % node, "root@%s" % self._node_address(node),
" ".join(shlex.quote(part) for part in command)]
try:
output = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
timeout=self.command_timeout, check=True).stdout
reserved, used = (int(line) for line in output.decode().split())
except (OSError, ValueError, subprocess.SubprocessError) as exc:
log.warning("Snapshot-Kosten von %s auf %s nicht ermittelbar: %s", volid, node, exc)
return 0
return max(0, reserved - used)
def _node_address(self, node):
"""Die IP aus dem Clusterstatus. Node-Namen muss nicht jeder Host aufloesen."""
try:
for entry in self._json(["get", "/cluster/status"]) or []:
if entry.get("type") == "node" and entry.get("name") == node and entry.get("ip"):
return entry["ip"]
except ProxmoxError as exc:
log.debug("Clusterstatus nicht lesbar: %s", exc)
return node
def _storage_config(self, storage):
if storage not in self._storage_cache:
try:
self._storage_cache[storage] = self._json(["get", "/storage/%s" % storage]) or {}
except ProxmoxError as exc:
log.warning("Storage '%s' nicht lesbar: %s", storage, exc)
self._storage_cache[storage] = {}
return self._storage_cache[storage]
# -- Gaeste lesen und steuern -----------------------------------------
def guest_config(self, guest, snapshot=None):
+129 -3
View File
@@ -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):
+31
View File
@@ -64,6 +64,37 @@ def format_duration(seconds, zero="unbegrenzt"):
return "".join(parts)
_FREE_SPACE = re.compile(r"^(\d+(?:[.,]\d+)?)\s*(%|[kmgt])?(?:i?b)?$")
_FREE_SPACE_UNITS = {"k": 1024, "m": 1024 ** 2, "g": 1024 ** 3, "t": 1024 ** 4}
def parse_free_space(text):
"""'10 %' -> '10%', '20gib' -> '20G'. 0 / 'aus' -> '0' (= keine Pruefung)."""
value = str(text if text is not None else "").strip().lower()
if value in _NEVER:
return "0"
match = _FREE_SPACE.match(value)
if not match:
raise ValueError("ungueltige Platzangabe: %r (erwartet z.B. 10%% oder 20G)" % text)
number = float(match.group(1).replace(",", "."))
unit = match.group(2) or "g"
if unit == "%" and number >= 100:
raise ValueError("Platzangabe muss unter 100%% liegen: %r" % text)
if not number:
return "0"
return "%g%s" % (number, unit.upper())
def free_space_bytes(spec, total):
"""Rechnet eine Angabe von parse_free_space in Bytes um."""
value = parse_free_space(spec)
if value == "0":
return 0
if value.endswith("%"):
return int(total * float(value[:-1]) / 100)
return int(float(value[:-1]) * _FREE_SPACE_UNITS[value[-1].lower()])
def slugify(text, maxlen=20):
"""Gruppenname -> kurzer, PVE-tauglicher Bezeichner (nur a-z0-9, beginnt mit Buchstabe)."""
# Umlaute vor der Normalisierung ersetzen - NFKD wuerde sie sonst zerlegen.