Files
proxmox-snapshot-service/pvesnap/daemon.py
T
duffyduckandClaude Opus 5.5 74be5aee59 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>
2026-11-01 10:27:21 +01:00

240 lines
8.9 KiB
Python

"""Der eigentliche Dienst: Zeitplan ueberwachen und Gruppen ausfuehren."""
from __future__ import annotations
import errno
import fcntl
import logging
import os
import signal
import threading
import time
from datetime import datetime
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
from .schedule import next_due
from .state import State
log = logging.getLogger("pvesnap.daemon")
class SingleInstanceLock:
"""Dateisperre.
Zwei Sperren sind im Spiel:
* <lock_file>.daemon haelt der Dienst waehrend seiner gesamten Laufzeit,
damit er nicht doppelt startet.
* <lock_file> wird nur waehrend der eigentlichen Snapshot-Arbeit
gehalten - so kann man jederzeit auch von Hand `pvesnap run` aufrufen,
ohne dass sich die Laeufe in die Quere kommen.
"""
def __init__(self, path, wait=0, busy_message=None):
self.path = path
self.wait = wait
self.busy_message = busy_message or "pvesnap laeuft bereits (Sperrdatei %s)" % path
self._handle = None
def __enter__(self):
# Die Datei anzulegen muss sofort klappen - fehlende Rechte sind ein
# harter Fehler und kein "gerade belegt".
try:
os.makedirs(os.path.dirname(os.path.abspath(self.path)), exist_ok=True)
self._handle = open(self.path, "w")
except OSError as exc:
self._handle = None
raise RuntimeError("Sperrdatei %s nicht nutzbar: %s" % (self.path, exc))
deadline = time.monotonic() + max(0, self.wait)
while True:
try:
fcntl.flock(self._handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
self._handle.write("%d\n" % os.getpid())
self._handle.flush()
return self
except OSError as exc:
busy = exc.errno in (errno.EAGAIN, errno.EWOULDBLOCK, errno.EACCES)
if not busy or time.monotonic() >= deadline:
self._handle.close()
self._handle = None
if busy:
raise RuntimeError(self.busy_message)
raise RuntimeError("Sperrdatei %s nicht nutzbar: %s" % (self.path, exc))
time.sleep(1.0)
def __exit__(self, *_exc):
if self._handle:
try:
fcntl.flock(self._handle, fcntl.LOCK_UN)
except OSError:
pass
self._handle.close()
self._handle = None
return False
class Daemon:
def __init__(self, config_path, dry_run=False):
self.config_path = config_path
self.dry_run_override = dry_run
self.config = None
self.state = None
self._stop = threading.Event()
self._reload = threading.Event()
self._wake = threading.Event() # bricht das Warten bei Signalen ab
# -- Signale ----------------------------------------------------------
def install_signal_handlers(self):
signal.signal(signal.SIGTERM, self._on_stop)
signal.signal(signal.SIGINT, self._on_stop)
signal.signal(signal.SIGHUP, self._on_reload)
def _on_stop(self, signum, _frame):
log.info("Signal %s empfangen - beende nach dem aktuellen Durchlauf", signum)
self._stop.set()
self._wake.set()
def _on_reload(self, _signum, _frame):
log.info("SIGHUP empfangen - Konfiguration wird neu geladen")
self._reload.set()
self._wake.set()
# -- Konfiguration ----------------------------------------------------
def load(self):
config = load_config(self.config_path)
problems = config.validate()
if problems:
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()
return config
def _reload_if_requested(self):
if not self._reload.is_set():
return
self._reload.clear()
try:
self.load()
log.info("Konfiguration neu geladen: %d Gruppe(n)", len(self.config.groups))
except ConfigError as exc:
log.error("Neuladen fehlgeschlagen, behalte alte Konfiguration: %s", exc)
# -- Hauptschleife ----------------------------------------------------
def run(self):
self.load()
self.install_signal_handlers()
globals_ = self.config.globals
log.info("pvesnap gestartet (%d Gruppe(n), Praefix '%s'%s)",
len(self.config.groups), globals_.prefix,
", TESTLAUF" if globals_.dry_run else "")
# Der Dienst laeuft trotzdem weiter - aber es soll gleich beim Start im
# Journal stehen, warum spaeter jeder Snapshot scheitert.
warn_about_environment(log)
daemon_lock = SingleInstanceLock(
globals_.lock_file + ".daemon",
busy_message="Der pvesnap-Dienst laeuft bereits (%s.daemon)" % globals_.lock_file)
with daemon_lock:
while not self._stop.is_set():
self._wake.clear()
self._reload_if_requested()
try:
self.tick()
except ProxmoxError as exc:
log.error("Proxmox nicht erreichbar: %s", exc)
except Exception: # pragma: no cover - Dienst darf nie sterben
log.exception("Unerwarteter Fehler im Durchlauf")
self._sleep()
if self.state:
self.state.save()
log.info("pvesnap beendet")
def tick(self, now=None):
"""Einmal alle Gruppen pruefen und faellige ausfuehren."""
now = now or datetime.now()
config = self.config
state = self.state
state.prune_unknown([g.name for g in config.groups])
due = []
for group in config.groups:
if not group.enabled:
continue
last = state.last_run(group.name)
if last is None and not config.globals.run_on_start:
# Erster Start: nicht sofort feuern, sondern auf den naechsten
# regulaeren Termin warten.
state.record_run(group.name, now, created=0, deleted=0)
log.info("Gruppe '%s': erster Termin am %s", group.name,
next_due(group, now, now).strftime("%Y-%m-%d %H:%M:%S"))
continue
if last is None or next_due(group, last, now) <= now:
due.append(group)
if not due:
state.save()
return []
proxmox = Proxmox.from_config(config)
results = []
# Die Arbeitssperre wird nur waehrend des Laufs gehalten, damit
# `pvesnap run` von Hand weiterhin moeglich bleibt.
work_lock = SingleInstanceLock(
config.globals.lock_file, wait=300,
busy_message="Ein anderer pvesnap-Lauf ist noch aktiv - "
"dieser Durchlauf wird uebersprungen")
try:
with work_lock:
guests = proxmox.inventory(refresh=True)
for group in due:
if self._stop.is_set():
break
result = run_group(proxmox, config, group, now=datetime.now(),
guests=guests)
state.record_run(group.name, datetime.now(),
created=len(result.created),
deleted=len(result.deleted), errors=result.errors)
results.append(result)
except RuntimeError as exc:
log.warning("%s", exc)
state.save()
return results
def _sleep(self):
"""Bis zum naechsten Termin schlafen, hoechstens aber check_interval."""
interval = self.config.globals.check_interval
now = datetime.now()
wait = float(interval)
for group in self.config.groups:
if not group.enabled:
continue
last = self.state.last_run(group.name)
if last is None:
continue
try:
remaining = (next_due(group, last, now) - now).total_seconds()
except ValueError:
continue
wait = min(wait, max(1.0, remaining))
# Auf Signale reagieren wir sofort - das Event bricht das Warten ab.
# Geleert wird es am Anfang des Schleifendurchlaufs, damit ein Signal
# waehrend tick() nicht verloren geht.
self._wake.wait(max(1.0, min(wait, float(interval))))