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>
429 lines
15 KiB
Python
429 lines
15 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
|
|
from .daemon import Daemon, SingleInstanceLock
|
|
from .engine import run_group, select_guests
|
|
from .naming import parse_name
|
|
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,
|
|
(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"))
|
|
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
|
|
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_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)
|
|
|
|
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())
|