Files
proxmox-snapshot-service/pvesnap/engine.py
T
duffyduckandClaude Opus 5 3acbebce6f Lokales Fenster war nicht navigierbar; halbfertige Snapshots erkennen
Zwei gemeldete Fehler.

1. Im rechten Fenster des Explorers liessen sich weder Verzeichnisse
   oeffnen noch ".." benutzen. Die Pruefung "bleibt der Pfad innerhalb der
   Wurzel?" haengte an die Wurzel ein "/" an - bei der Wurzel "/" des
   lokalen Fensters wurde daraus "//", worauf kein Pfad passt. Damit gab
   open_current() immer False zurueck. Die Pruefung steckt jetzt in
   within() und behandelt diesen Fall; die Web-Oberflaeche benutzt
   dieselbe Funktion.

2. "Snapshots vom LXC aufrufen geht nicht": der Container hatte gar keine
   brauchbaren Snapshots. In seiner Konfiguration standen nur zwei
   Eintraege mit snapstate "prepare" und "delete" - Reste aus der Zeit, in
   der jeder Snapshot am cfs-Lock scheiterte. Auf dem Storage liegt
   dahinter nichts (rbd snap ls ist leer), oeffnen kann man sie also
   nicht.

   Solche Eintraege werden jetzt als das behandelt, was sie sind:
     * Explorer und Web-Oberflaeche bieten sie nicht mehr zum Oeffnen an
       und nennen den Aufraeumbefehl.
     * "pvesnap list" markiert sie mit "!".
     * Beim Aufraeumen entfernt der Dienst sie zuerst, und zwar mit
       --force, weil sich ein Eintrag ohne Storage-Snapshot sonst nicht
       loeschen laesst. Vorher waeren sie ewig liegen geblieben und haetten
       zusaetzlich die Zahl der behaltenen Snapshots verfaelscht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:21:59 +02:00

198 lines
7.4 KiB
Python

"""Kernlogik: Gaeste auswaehlen, Snapshots anlegen, alte Snapshots aufraeumen."""
from __future__ import annotations
import fnmatch
import logging
import time
from dataclasses import dataclass, field
from datetime import datetime
from .naming import build_name, effective_slug, parse_name, render_description
from .proxmox import ProxmoxError
log = logging.getLogger("pvesnap.engine")
# ---------------------------------------------------------------------------
# Auswahl der Gaeste
# ---------------------------------------------------------------------------
def _matches_any_glob(text, patterns):
lowered = (text or "").lower()
return any(fnmatch.fnmatch(lowered, pattern.lower()) for pattern in patterns)
def select_guests(group, guests):
"""Alle Gaeste, auf die die Auswahlregeln der Gruppe zutreffen."""
wanted_vmids = set(group.vmids)
wanted_tags = set(group.tags)
wanted_pools = set(p.lower() for p in group.pools)
excluded_vmids = set(group.exclude_vmids)
excluded_tags = set(group.exclude_tags)
selected = []
for guest in guests:
if group.types and guest.type not in group.types:
continue
if group.all:
hit = True
else:
hit = (guest.vmid in wanted_vmids
or (group.names and _matches_any_glob(guest.name, group.names))
or (wanted_tags and wanted_tags.intersection(guest.tags))
or (wanted_pools and guest.pool.lower() in wanted_pools))
if not hit:
continue
if guest.vmid in excluded_vmids:
continue
if group.exclude_names and _matches_any_glob(guest.name, group.exclude_names):
continue
if excluded_tags and excluded_tags.intersection(guest.tags):
continue
selected.append(guest)
return selected
# ---------------------------------------------------------------------------
# Vorhaltezeit
# ---------------------------------------------------------------------------
def managed_snapshots(prefix, group, snapshots):
"""Nur die Snapshots, die pvesnap fuer *diese* Gruppe angelegt hat."""
slug = effective_slug(prefix, group.slug)
result = []
for snap in snapshots:
parsed = parse_name(prefix, snap.name)
if not parsed or parsed["slug"] != slug:
continue
created = (datetime.fromtimestamp(snap.snaptime) if snap.snaptime
else parsed["created"])
result.append((snap, created))
result.sort(key=lambda item: item[1], reverse=True) # neueste zuerst
return result
def plan_prune(prefix, group, snapshots, now):
"""Welche Snapshots sollen weg? Gibt eine Liste von (Snapshot, Grund) zurueck."""
doomed = []
for snap, _created in managed_snapshots(prefix, group, snapshots):
if snap.snapstate:
# Halbfertiger Eintrag aus einem abgebrochenen Lauf - der belegt
# nur die Konfiguration und laesst sich nicht oeffnen.
doomed.append((snap, "unvollstaendig (%s)" % snap.snapstate))
for index, (snap, created) in enumerate(managed_snapshots(prefix, group, snapshots)):
if snap.snapstate:
continue # schon oben eingeplant
if index < group.keep_min:
continue
age = (now - created).total_seconds()
if group.keep_count > 0 and index >= group.keep_count:
doomed.append((snap, "Anzahl > %d" % group.keep_count))
elif group.keep_time > 0 and age > group.keep_time:
doomed.append((snap, "aelter als Vorhaltezeit"))
return doomed
# ---------------------------------------------------------------------------
# Ausfuehrung
# ---------------------------------------------------------------------------
@dataclass
class GroupResult:
group: str
matched: int = 0
created: list = field(default_factory=list)
deleted: list = field(default_factory=list)
skipped: list = field(default_factory=list)
errors: list = field(default_factory=list)
@property
def ok(self):
return not self.errors
def summary(self):
return ("Gruppe '%s': %d Gast/Gaeste, %d Snapshot(s) angelegt, "
"%d geloescht, %d Fehler"
% (self.group, self.matched, len(self.created),
len(self.deleted), len(self.errors)))
def run_group(proxmox, config, group, now=None, create=True, prune=True, guests=None):
"""Legt fuer eine Gruppe Snapshots an und raeumt alte weg."""
now = now or datetime.now()
prefix = config.globals.prefix
result = GroupResult(group=group.name)
if guests is None:
guests = proxmox.inventory()
selected = select_guests(group, guests)
result.matched = len(selected)
if not selected:
log.warning("Gruppe '%s': keine passenden Gaeste gefunden", group.name)
return result
snapshot_name = build_name(prefix, group.slug, now)
template = group.description or config.globals.description
pause = config.globals.pause_between
first = True
for guest in selected:
# Kurz durchatmen zwischen zwei Gaesten - entlastet den Storage-Lock,
# wenn viele VMs auf demselben Storage liegen.
if pause and not first and not config.globals.dry_run:
time.sleep(pause)
first = False
if group.skip_stopped and not guest.running:
result.skipped.append("%s (gestoppt)" % guest.label)
log.info("Gruppe '%s': %s uebersprungen (gestoppt)", group.name, guest.label)
continue
if create:
description = render_description(template, group, guest, now, prefix)
try:
proxmox.create_snapshot(
guest, snapshot_name, description,
vmstate=group.vmstate and guest.running,
)
result.created.append("%s:%s" % (guest.vmid, snapshot_name))
log.info("Gruppe '%s': Snapshot '%s' fuer %s angelegt",
group.name, snapshot_name, guest.label)
except ProxmoxError as exc:
message = "%s: Snapshot fehlgeschlagen: %s" % (guest.label, exc)
result.errors.append(message)
log.error("Gruppe '%s': %s", group.name, message)
continue # ohne neuen Snapshot nicht aufraeumen
if prune:
try:
_prune_guest(proxmox, config, group, guest, now, result)
except ProxmoxError as exc:
message = "%s: Aufraeumen fehlgeschlagen: %s" % (guest.label, exc)
result.errors.append(message)
log.error("Gruppe '%s': %s", group.name, message)
log.info(result.summary())
return result
def _prune_guest(proxmox, config, group, guest, now, result):
prefix = config.globals.prefix
snapshots = proxmox.list_snapshots(guest)
for snap, reason in plan_prune(prefix, group, snapshots, now):
try:
proxmox.delete_snapshot(guest, snap.name, force=bool(snap.snapstate))
result.deleted.append("%s:%s" % (guest.vmid, snap.name))
log.info("Gruppe '%s': Snapshot '%s' von %s geloescht (%s)",
group.name, snap.name, guest.label, reason)
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)