Files
proxmox-snapshot-service/pvesnap/cli.py
T
duffyduckandClaude Opus 5.5 74be5aee59 Platz vor jedem Snapshot pruefen, bei Mangel alte loeschen, per Mail warnen
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>
2026-11-01 10:27:21 +01:00

466 lines
16 KiB
Python

"""Kommandozeile von pvesnap."""
from __future__ import annotations
import argparse
import logging
import os
import sys
from datetime import datetime
from . import __version__
from .config import DEFAULT_CONFIG_PATH, ConfigError, load_config, world_readable_secret
from .daemon import Daemon, SingleInstanceLock
from .engine import run_group, select_guests
from .naming import parse_name
from .preflight import check_environment
from .proxmox import Proxmox, ProxmoxError, pvesh_available
from .schedule import describe, next_due
from .state import State
from .util import format_duration, truncate
log = logging.getLogger("pvesnap")
# ---------------------------------------------------------------------------
# Hilfsfunktionen
# ---------------------------------------------------------------------------
def setup_logging(level, log_file=""):
root = logging.getLogger("pvesnap")
root.setLevel(getattr(logging, str(level).upper(), logging.INFO))
for handler in list(root.handlers):
root.removeHandler(handler)
# Unter systemd steht der Zeitstempel schon im Journal.
plain = os.environ.get("JOURNAL_STREAM") or os.environ.get("INVOCATION_ID")
fmt = "%(levelname)s %(message)s" if plain else "%(asctime)s %(levelname)s %(message)s"
stream = logging.StreamHandler(sys.stderr)
stream.setFormatter(logging.Formatter(fmt, datefmt="%Y-%m-%d %H:%M:%S"))
root.addHandler(stream)
if log_file:
try:
handler = logging.FileHandler(log_file, encoding="utf-8")
handler.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S"))
root.addHandler(handler)
except OSError as exc:
root.warning("Logdatei %s nicht beschreibbar: %s", log_file, exc)
def _load(args):
config = load_config(args.config)
problems = config.validate()
if problems:
raise ConfigError("Konfiguration fehlerhaft:\n - %s" % "\n - ".join(problems))
if args.dry_run:
config.globals.dry_run = True
setup_logging("DEBUG" if args.verbose else config.globals.log_level,
config.globals.log_file)
return config
def _table(rows, headers):
if not rows:
return ""
widths = [len(h) for h in headers]
for row in rows:
for index, cell in enumerate(row):
widths[index] = max(widths[index], len(str(cell)))
lines = [" ".join(h.ljust(widths[i]) for i, h in enumerate(headers)).rstrip()]
lines.append(" ".join("-" * widths[i] for i in range(len(headers))))
for row in rows:
lines.append(" ".join(str(cell).ljust(widths[i])
for i, cell in enumerate(row)).rstrip())
return "\n".join(lines)
def _age(moment, now):
seconds = int((now - moment).total_seconds())
if seconds < 0:
return "in " + format_duration(-seconds, zero="0s")
return format_duration(seconds, zero="0s")
def _require_pve():
if not pvesh_available():
raise ProxmoxError("'pvesh' nicht gefunden - dieser Befehl muss auf einem "
"Proxmox-VE-Host ausgefuehrt werden.")
# ---------------------------------------------------------------------------
# Befehle
# ---------------------------------------------------------------------------
def cmd_daemon(args):
config = _load(args)
daemon = Daemon(args.config, dry_run=args.dry_run)
daemon.config = config
daemon.run()
return 0
def cmd_run(args):
config = _load(args)
_require_pve()
state = State(config.globals.state_file).load()
now = datetime.now()
if args.group:
wanted = []
for name in args.group:
group = config.group(name)
if group is None:
print("Unbekannte Gruppe: %s" % name, file=sys.stderr)
return 2
wanted.append(group)
else:
wanted = [g for g in config.groups if g.enabled]
if not args.force:
due = []
for group in wanted:
last = state.last_run(group.name)
if last is None or next_due(group, last, now) <= now:
due.append(group)
wanted = due
if not wanted:
print("Zurzeit ist keine Gruppe faellig. (--force erzwingt den Lauf)")
return 0
proxmox = Proxmox.from_config(config)
guests = proxmox.inventory()
failed = False
with SingleInstanceLock(config.globals.lock_file, wait=600,
busy_message="Ein anderer pvesnap-Lauf ist gerade aktiv. "
"Bitte spaeter erneut versuchen."):
for group in wanted:
result = run_group(proxmox, config, group, now=datetime.now(),
prune=not args.no_prune, guests=guests)
if not config.globals.dry_run:
state.record_run(group.name, datetime.now(),
created=len(result.created),
deleted=len(result.deleted), errors=result.errors)
print(result.summary())
failed = failed or bool(result.errors)
state.save()
return 1 if failed else 0
def cmd_prune(args):
config = _load(args)
_require_pve()
groups = []
for name in (args.group or [g.name for g in config.groups]):
group = config.group(name)
if group is None:
print("Unbekannte Gruppe: %s" % name, file=sys.stderr)
return 2
groups.append(group)
proxmox = Proxmox.from_config(config)
guests = proxmox.inventory()
failed = False
with SingleInstanceLock(config.globals.lock_file, wait=600,
busy_message="Ein anderer pvesnap-Lauf ist gerade aktiv. "
"Bitte spaeter erneut versuchen."):
for group in groups:
result = run_group(proxmox, config, group, now=datetime.now(),
create=False, prune=True, guests=guests)
print(result.summary())
failed = failed or bool(result.errors)
return 1 if failed else 0
def cmd_status(args):
config = _load(args)
state = State(config.globals.state_file).load()
now = datetime.now()
guests = None
if pvesh_available():
try:
guests = Proxmox().inventory()
except ProxmoxError as exc:
print("Hinweis: Inventar nicht lesbar (%s)\n" % exc, file=sys.stderr)
print("pvesnap %s - Konfiguration: %s" % (__version__, config.path))
print("Praefix: %s Zustand: %s%s\n"
% (config.globals.prefix, config.globals.state_file,
" [TESTLAUF aktiv]" if config.globals.dry_run else ""))
rows = []
for group in config.groups:
last = state.last_run(group.name)
info = state.info(group.name)
if group.enabled:
try:
upcoming = next_due(group, last, now)
next_text = upcoming.strftime("%d.%m. %H:%M")
if upcoming <= now:
next_text = "faellig"
except ValueError:
next_text = "?"
else:
next_text = "-"
matched = "?" if guests is None else str(len(select_guests(group, guests)))
rows.append([
group.name,
"ja" if group.enabled else "nein",
describe(group),
"%s / %s" % (group.keep_count or "unbegr.", format_duration(group.keep_time)),
matched,
last.strftime("%d.%m. %H:%M") if last else "nie",
next_text,
info.get("status", "-"),
])
if rows:
print(_table(rows, ["Gruppe", "Aktiv", "Zeitplan", "Behalte (Anz./Zeit)",
"VMs", "Letzter Lauf", "Naechster", "Status"]))
else:
print("Keine Gruppen konfiguriert.")
errors = [(name, info.get("errors") or [])
for name, info in state.data.get("groups", {}).items()]
errors = [(name, msgs) for name, msgs in errors if msgs]
if errors:
print("\nLetzte Fehler:")
for name, messages in errors:
for message in messages:
print(" [%s] %s" % (name, message))
return 0
def cmd_list(args):
config = _load(args)
_require_pve()
proxmox = Proxmox()
guests = proxmox.inventory()
now = datetime.now()
prefix = config.globals.prefix
if args.group:
groups = []
for name in args.group:
group = config.group(name)
if group is None:
print("Unbekannte Gruppe: %s" % name, file=sys.stderr)
return 2
groups.append(group)
else:
groups = config.groups
interesting = {}
for group in groups:
for guest in select_guests(group, guests):
interesting[guest.vmid] = guest
rows = []
for vmid in sorted(interesting):
guest = interesting[vmid]
try:
snapshots = proxmox.list_snapshots(guest)
except ProxmoxError as exc:
print("%s: %s" % (guest.label, exc), file=sys.stderr)
continue
for snap in sorted(snapshots, key=lambda s: s.snaptime, reverse=True):
parsed = parse_name(prefix, snap.name)
if parsed is None and not args.all:
continue
created = (datetime.fromtimestamp(snap.snaptime) if snap.snaptime
else (parsed or {}).get("created"))
rows.append([
guest.vmid,
truncate(guest.name, 20),
snap.name if snap.complete else "! " + snap.name,
(parsed or {}).get("slug", "-"),
created.strftime("%d.%m.%y %H:%M") if created else "?",
_age(created, now) if created else "?",
truncate(snap.description.replace("\n", " "), 44),
])
if rows:
print(_table(rows, ["VMID", "Name", "Snapshot", "Gruppe", "Erstellt",
"Alter", "Beschreibung"]))
print("\n%d Snapshot(s)%s" % (len(rows), "" if args.all else " von pvesnap"))
if any(str(row[2]).startswith("! ") for row in rows):
print("Mit '!' markierte Eintraege sind unvollstaendig (abgebrochener "
"Lauf) und enthalten keine Daten.\n"
"Aufraeumen: qm|pct delsnapshot <vmid> <name> --force")
else:
print("Keine passenden Snapshots gefunden.")
return 0
def cmd_vms(args):
config = _load(args)
_require_pve()
guests = Proxmox().inventory()
assignment = {}
for group in config.groups:
for guest in select_guests(group, guests):
assignment.setdefault(guest.vmid, []).append(group.name)
rows = [[guest.vmid,
"LXC" if guest.type == "lxc" else "VM",
truncate(guest.name, 28),
guest.node,
guest.status,
",".join(guest.tags) or "-",
guest.pool or "-",
", ".join(assignment.get(guest.vmid, [])) or "-"]
for guest in guests]
if rows:
print(_table(rows, ["VMID", "Typ", "Name", "Node", "Status", "Tags",
"Pool", "Gruppen"]))
else:
print("Keine VMs/Container gefunden.")
return 0
def cmd_check(args):
try:
config = load_config(args.config)
except ConfigError as exc:
print(str(exc), file=sys.stderr)
return 1
problems = config.validate()
if problems:
print("Konfiguration fehlerhaft:", file=sys.stderr)
for problem in problems:
print(" - %s" % problem, file=sys.stderr)
return 1
environment = check_environment(require_pve=pvesh_available())
if world_readable_secret(config):
environment = list(environment) + ["%s enthaelt das SMTP-Passwort und ist fuer alle "
"lesbar - bitte: chmod 600 %s"
% (config.path, config.path)]
if environment:
print("Hinweise zur Umgebung:", file=sys.stderr)
for problem in environment:
print(" - %s" % problem, file=sys.stderr)
print("", file=sys.stderr)
print("Konfiguration in Ordnung: %d Gruppe(n)." % len(config.groups))
for group in config.groups:
print(" - %s: %s, behalte %s / %s%s"
% (group.name, describe(group),
group.keep_count or "unbegrenzt", format_duration(group.keep_time),
"" if group.enabled else " [deaktiviert]"))
return 0
def cmd_mail_test(args):
from .mail import MailError, send_test_mail
mail = load_config(args.config).mail
problems = mail.problems()
if problems:
print("Mail-Einstellungen unvollstaendig:", file=sys.stderr)
for problem in problems:
print(" - %s" % problem, file=sys.stderr)
return 1
try:
send_test_mail(mail)
except MailError as exc:
print("Test-Mail fehlgeschlagen: %s" % exc, file=sys.stderr)
return 1
print("Test-Mail an %s gesendet." % ", ".join(mail.recipients))
return 0
def cmd_config(args):
from .tui import run_editor
return run_editor(args.config)
# ---------------------------------------------------------------------------
# Argumente
# ---------------------------------------------------------------------------
def build_parser():
parser = argparse.ArgumentParser(
prog="pvesnap",
description="Automatische Proxmox-Snapshots mit Gruppen, Zeitplan und Vorhaltezeit.")
parser.add_argument("-c", "--config", default=os.environ.get("PVESNAP_CONFIG",
DEFAULT_CONFIG_PATH),
help="Pfad zur INI-Datei (Vorgabe: %(default)s)")
parser.add_argument("-n", "--dry-run", action="store_true",
help="nichts wirklich anlegen oder loeschen, nur anzeigen")
parser.add_argument("-v", "--verbose", action="store_true", help="ausfuehrliche Ausgabe")
parser.add_argument("-V", "--version", action="version",
version="pvesnap %s" % __version__)
sub = parser.add_subparsers(dest="command")
p_daemon = sub.add_parser("daemon", help="Dienst im Vordergrund starten (fuer systemd)")
p_daemon.set_defaults(func=cmd_daemon)
p_run = sub.add_parser("run", help="faellige Gruppen jetzt ausfuehren")
p_run.add_argument("-g", "--group", action="append", help="nur diese Gruppe (mehrfach moeglich)")
p_run.add_argument("-f", "--force", action="store_true",
help="unabhaengig vom Zeitplan ausfuehren")
p_run.add_argument("--no-prune", action="store_true", help="nicht aufraeumen")
p_run.set_defaults(func=cmd_run)
p_prune = sub.add_parser("prune", help="nur alte Snapshots aufraeumen")
p_prune.add_argument("-g", "--group", action="append", help="nur diese Gruppe")
p_prune.set_defaults(func=cmd_prune)
p_status = sub.add_parser("status", help="Uebersicht ueber Gruppen und Termine")
p_status.set_defaults(func=cmd_status)
p_list = sub.add_parser("list", help="vorhandene Snapshots anzeigen")
p_list.add_argument("-g", "--group", action="append", help="nur diese Gruppe")
p_list.add_argument("-a", "--all", action="store_true",
help="auch fremde/manuelle Snapshots anzeigen")
p_list.set_defaults(func=cmd_list)
p_vms = sub.add_parser("vms", help="alle VMs/Container und ihre Gruppen anzeigen")
p_vms.set_defaults(func=cmd_vms)
p_check = sub.add_parser("check", help="Konfiguration pruefen")
p_check.set_defaults(func=cmd_check)
p_mail = sub.add_parser("mail-test", help="Test-Mail mit den Einstellungen aus [mail] senden")
p_mail.set_defaults(func=cmd_mail_test)
for name in ("config", "edit"):
p_config = sub.add_parser(name, help="Konfiguration im ncurses-Editor bearbeiten")
p_config.set_defaults(func=cmd_config)
return parser
def main(argv=None):
parser = build_parser()
args = parser.parse_args(argv)
if not getattr(args, "func", None):
args.command = "status"
args.func = cmd_status
setup_logging("DEBUG" if args.verbose else "INFO")
try:
return args.func(args)
except ConfigError as exc:
print(str(exc), file=sys.stderr)
return 2
except ProxmoxError as exc:
print("Proxmox-Fehler: %s" % exc, file=sys.stderr)
return 3
except RuntimeError as exc:
print(str(exc), file=sys.stderr)
return 4
except KeyboardInterrupt:
print("\nAbgebrochen.", file=sys.stderr)
return 130
if __name__ == "__main__": # pragma: no cover
sys.exit(main())