first commit

This commit is contained in:
duffyduck
2026-07-30 16:52:58 +02:00
commit 87f1b4d555
17 changed files with 3558 additions and 0 deletions
+430
View File
@@ -0,0 +1,430 @@
"""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(dry_run=config.globals.dry_run,
task_timeout=config.globals.task_timeout)
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(dry_run=config.globals.dry_run,
task_timeout=config.globals.task_timeout)
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())