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>
198 lines
7.4 KiB
Python
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)
|