first commit
This commit is contained in:
+430
@@ -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())
|
||||
Reference in New Issue
Block a user