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>
240 lines
8.9 KiB
Python
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))))
|