Files
proxmox-snapshot-service/pvesnap/engine.py
T
duffyduckandClaude Opus 5 e9aeaf9e62 Snapshot-Laeufe gegen cfs-Sperren des Storages absichern
Auf einem echten Host schlugen Snapshots reihenweise mit
"cfs-lock 'storage-NAME' error: got lock request timeout" fehl.

Ursache: 'pvesh create .../snapshot' lief mit dem allgemeinen
Kommando-Zeitlimit von 60s. Genau so lange wartet Proxmox aber auf den
Storage-Lock. Lief der Aufruf in unser Zeitlimit, ging es mit der naechsten
VM weiter, waehrend der Task noch lief - und die naechste VM scheiterte
dann an derselben Sperre. Eine VM konnte so einen ganzen Lauf umwerfen.

* Snapshot-Aktionen laufen jetzt mit dem langen task_timeout statt mit dem
  kurzen Zeitlimit fuer Lesezugriffe.
* Ohne UPID in der Antwort wird ersatzweise gewartet, bis der Gast nicht
  mehr gesperrt ist, statt sofort weiterzumachen.
* Sperr-Fehler gelten als voruebergehend und werden 'retries'-mal mit
  'retry_delay' Abstand wiederholt; echte Fehler wie "storage does not
  support snapshots" nicht.
* Neu: 'pause_between' fuer eine Pause zwischen zwei Gaesten.

Ausserdem: Kommentare hinter einem Wert ("retries = 2  # ...") wurden nicht
abgeschnitten und machten die Konfiguration ungueltig - das eigene
Beispiel war davon betroffen. 'description' bleibt bewusst unangetastet,
damit ein '#' in der Beschreibung erhalten bleibt.

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

191 lines
6.9 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 index, (snap, created) in enumerate(managed_snapshots(prefix, group, snapshots)):
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)
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)