"""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 .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, (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 environment = check_environment(require_pve=pvesh_available()) 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_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())