"""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)