Files
proxmox-snapshot-service/pvesnap/tui.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

1032 lines
43 KiB
Python

"""ncurses-Editor fuer die pvesnap-Konfiguration.
Aufruf: pvesnap config
"""
from __future__ import annotations
import curses
import os
import subprocess
from .curses_util import (C_DIM, C_FOOTER, C_HEADER, C_OK, C_SEL, C_WARN,
choose, confirm, fill, fill_row, init_colors, is_enter,
is_escape, keybar, message, prompt, put, read_key)
from .config import (WEEKDAY_NAMES, Config, ConfigError, Group, clone_group,
load_config, parse_time_of_day, save_config)
from .mail import MailError, send_test_mail
from .naming import effective_slug
from .proxmox import Proxmox, ProxmoxError, pvesh_available
from .schedule import describe
from .util import (format_duration, format_vmid_list, parse_duration, parse_free_space,
parse_list, parse_vmid_list, truncate)
SCHEDULE_CHOICES = [
("interval", "Intervall (z.B. alle 30 Minuten)"),
("hourly", "stuendlich"),
("daily", "taeglich"),
("weekly", "woechentlich"),
("monthly", "monatlich"),
("yearly", "jaehrlich"),
]
TYPE_CHOICES = [
([], "alle (VMs und Container)"),
(["qemu"], "nur VMs (QEMU/KVM)"),
(["lxc"], "nur Container (LXC)"),
]
SERVICE = "pvesnap.service"
MAIL_SECURITY_TEXT = {
"starttls": "STARTTLS (TLS, meist Port 587)",
"ssl": "SSL/TLS von Anfang an (meist Port 465)",
"none": "keine (nur fuer interne Relays ohne Anmeldung)",
}
# Tastenleiste der Gruppenliste: (Taste, lange Beschriftung, kurze Beschriftung)
GROUP_KEYS = [
("Enter", "Bearbeiten", "Bearb."),
("n", "Neu", "Neu"),
("c", "Kopieren", "Kopie"),
("d", "Loeschen", "Loesch"),
("Leer", "An/Aus", "An/Aus"),
("g", "Global", "Global"),
("m", "Mail", "Mail"),
("v", "VMs", "VMs"),
("s", "Speichern", "Speich."),
("r", "Dienst neu laden", "Reload"),
("q", "Ende", "Ende"),
]
# ---------------------------------------------------------------------------
# systemd
# ---------------------------------------------------------------------------
def _systemctl(*args, **kwargs):
"""Ruft systemctl auf und gibt (Rueckgabewert, Ausgabe) zurueck."""
try:
proc = subprocess.run(["systemctl"] + list(args), stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, timeout=kwargs.get("timeout", 30))
return proc.returncode, proc.stdout.decode("utf-8", "replace").strip()
except FileNotFoundError:
return 127, "systemctl nicht gefunden"
except subprocess.TimeoutExpired:
return 124, "Zeitueberschreitung beim Aufruf von systemctl"
def service_state():
"""('geladen?', 'Zustand') des pvesnap-Dienstes, z.B. (True, 'active')."""
code, output = _systemctl("show", "-p", "LoadState", "-p", "ActiveState", SERVICE)
if code not in (0, 3):
return None, output.splitlines()[0] if output else "unbekannt"
values = {}
for line in output.splitlines():
if "=" in line:
key, _, value = line.partition("=")
values[key.strip()] = value.strip()
loaded = values.get("LoadState") == "loaded"
return loaded, values.get("ActiveState", "unbekannt")
def service_text():
loaded, state = service_state()
if loaded is None:
return "Dienst: %s" % state
if not loaded:
return "Dienst: nicht installiert"
return {"active": "Dienst: laeuft",
"inactive": "Dienst: gestoppt",
"failed": "Dienst: FEHLER",
"activating": "Dienst: startet"}.get(state, "Dienst: %s" % state)
class Editor:
def __init__(self, path):
self.path = path
self.config = None
self.dirty = False
self._guests = None
self._inventory_error = ""
self._service_cache = None
# -- Daten ------------------------------------------------------------
def load(self):
if os.path.exists(self.path):
self.config = load_config(self.path)
else:
self.config = Config(path=self.path)
self.dirty = True
def guests(self, refresh=False):
"""Inventar der VMs; wird nur bei Bedarf einmal geholt."""
if self._guests is not None and not refresh:
return self._guests
self._guests = []
self._inventory_error = ""
if not pvesh_available():
self._inventory_error = ("'pvesh' nicht gefunden - VM-Liste nur auf einem "
"Proxmox-Host verfuegbar.")
return self._guests
try:
self._guests = Proxmox().inventory(refresh=True)
except ProxmoxError as exc:
self._inventory_error = str(exc)
return self._guests
# -- Rahmen -----------------------------------------------------------
def _frame(self, win, title, subtitle, keys):
win.erase()
height, width = win.getmaxyx()
fill(win, 0, " pvesnap - %s" % title, curses.color_pair(C_HEADER) | curses.A_BOLD)
marker = "*" if self.dirty else " "
fill(win, 1, " %s%s" % (marker, subtitle), curses.color_pair(C_DIM))
lines = list(keys) if isinstance(keys, (list, tuple)) else [keys]
for offset, line in enumerate(reversed(lines)):
fill(win, height - 1 - offset, " " + line, curses.color_pair(C_FOOTER))
return height, width
def _message(self, win, text, error=False):
return message(win, text, error)
def _confirm(self, win, question):
return confirm(win, question)
def _prompt(self, win, label, initial="", secret=False):
return prompt(win, label, initial, secret)
def _choose(self, win, title, entries, current=0):
return choose(win, title, entries, current)
# -- Hauptschleife ----------------------------------------------------
def run(self, stdscr):
curses.curs_set(0)
if hasattr(curses, "set_escdelay"):
curses.set_escdelay(25)
init_colors()
stdscr.keypad(True)
self.screen_groups(stdscr)
# -- Bildschirm: Gruppenliste ----------------------------------------
def screen_groups(self, win):
index = 0
while True:
groups = self.config.groups
index = max(0, min(index, max(0, len(groups) - 1)))
_, width_now = win.getmaxyx()
status = self._service_text()
# Der Dienststatus soll immer sichtbar bleiben - notfalls wird der
# Pfad vorne gekuerzt.
room = max(12, width_now - len(status) - 15)
path_text = (self.path if len(self.path) <= room
else "…" + self.path[-(room - 1):])
height, width = self._frame(
win, "Gruppen", "Datei: %s [%s]" % (path_text, status),
keybar(width_now, GROUP_KEYS))
put(win, 3, 2, "Gruppe".ljust(18) + "Aktiv " + "Zeitplan".ljust(30)
+ "Behalte".ljust(20) + "Auswahl", curses.A_BOLD)
put(win, 4, 2, "-" * max(0, width - 4), curses.color_pair(C_DIM))
if not groups:
put(win, 6, 4, "Noch keine Gruppe angelegt - mit 'n' eine erste anlegen.")
put(win, 8, 4, "Beispiel: 'stuendlich' alle 1h, 24 Snapshots behalten;")
put(win, 9, 4, " 'taeglich' um 02:30, 14 Tage Vorhaltezeit.")
visible = max(1, height - 8)
start = max(0, min(index - visible // 2, max(0, len(groups) - visible)))
for row, group in enumerate(groups[start:start + visible]):
position = start + row
attr = curses.color_pair(C_SEL) if position == index else 0
keep = "%s / %s" % (group.keep_count or "unbegr.",
format_duration(group.keep_time))
line = ("%s%s%s%s%s"
% (truncate(group.name, 17).ljust(18),
("ja" if group.enabled else "nein").ljust(7),
truncate(describe(group), 29).ljust(30),
truncate(keep, 19).ljust(20),
truncate(self._selection_text(group), max(4, width - 80))))
fill_row(win, 5 + row, 2, line, width, attr)
problems = self.config.validate()
if problems:
fill(win, height - 2, " %d Hinweis(e) - 'p' zeigt Details"
% len(problems), curses.color_pair(C_WARN))
win.refresh()
key = read_key(win)
if key in (curses.KEY_UP, "k"):
index -= 1
elif key in (curses.KEY_DOWN, "j"):
index += 1
elif key == curses.KEY_HOME:
index = 0
elif key == curses.KEY_END:
index = len(groups) - 1
elif is_enter(key) and groups:
self.screen_group(win, groups[index])
elif key == "n":
self._new_group(win)
index = len(self.config.groups) - 1
elif key == "c" and groups:
name = self._prompt(win, "Name der Kopie", groups[index].name + "-kopie")
if name:
self.config.groups.append(clone_group(groups[index], name))
self.dirty = True
elif key == "d" and groups:
if self._confirm(win, "Gruppe '%s' wirklich loeschen?" % groups[index].name):
del self.config.groups[index]
self.dirty = True
elif key == " " and groups:
groups[index].enabled = not groups[index].enabled
self.dirty = True
elif key == "g":
self.screen_globals(win)
elif key == "m":
self.screen_mail(win)
elif key == "v":
self.screen_overview(win)
elif key == "p":
self._show_problems(win)
elif key == "s":
self._save(win)
elif key in ("r", "R"):
self._reload_service(win)
elif key in ("q", "Q") or is_escape(key):
if self.dirty and not self._confirm(win, "Ungespeicherte Aenderungen verwerfen?"):
continue
return
def _selection_text(self, group):
parts = []
if group.all:
parts.append("alle VMs")
if group.vmids:
parts.append("IDs: " + format_vmid_list(group.vmids))
if group.names:
parts.append("Namen: " + ", ".join(group.names))
if group.tags:
parts.append("Tags: " + ", ".join(group.tags))
if group.pools:
parts.append("Pools: " + ", ".join(group.pools))
return "; ".join(parts) or "(nichts ausgewaehlt)"
def _new_group(self, win):
name = self._prompt(win, "Name der neuen Gruppe", "")
if not name:
return
if self.config.group(name):
self._message(win, "Eine Gruppe mit diesem Namen gibt es bereits.", error=True)
return
group = Group(name=name, interval=3600, keep_count=24, keep_time=2 * 86400)
self.config.groups.append(group)
self.dirty = True
self.screen_group(win, group)
def _show_problems(self, win):
problems = self.config.validate()
height, width = self._frame(win, "Hinweise zur Konfiguration", self.path,
"beliebige Taste = zurueck")
if not problems:
put(win, 4, 4, "Alles in Ordnung.", curses.color_pair(C_OK))
for row, problem in enumerate(problems[: height - 6]):
put(win, 4 + row, 4, truncate("- " + problem, width - 6))
win.refresh()
read_key(win)
def _save(self, win, offer_reload=True):
problems = self.config.validate()
if problems:
if not self._confirm(win, "%d Hinweis(e) - trotzdem speichern?" % len(problems)):
return
try:
target = save_config(self.config, self.path)
self.dirty = False
except OSError as exc:
self._message(win, "Speichern fehlgeschlagen: %s" % exc, error=True)
return
# Direkt anbieten, die Aenderung auch scharf zu schalten.
loaded, state = service_state()
if offer_reload and loaded and state == "active":
if self._confirm(win, "Gespeichert. Dienst jetzt neu laden?"):
self._reload_service(win)
return
self._message(win, "Gespeichert: %s (wirksam nach 'r' oder "
"'systemctl reload pvesnap')" % target)
else:
self._message(win, "Gespeichert: %s" % target)
# -- Dienst steuern ---------------------------------------------------
def _service_text(self, refresh=False):
if self._service_cache is None or refresh:
self._service_cache = service_text()
return self._service_cache
def _reload_service(self, win):
"""Taste 'r': Konfiguration speichern und den Dienst neu laden."""
if self.dirty:
if self._confirm(win, "Es gibt ungespeicherte Aenderungen - erst speichern?"):
self._save(win, offer_reload=False)
if self.dirty:
return # Speichern abgebrochen oder fehlgeschlagen
loaded, state = service_state()
if loaded is None:
self._message(win, "systemd nicht erreichbar: %s" % state, error=True)
return
if not loaded:
self._message(win, "Der Dienst %s ist nicht installiert - bitte install.sh "
"ausfuehren." % SERVICE, error=True)
return
if os.geteuid() != 0:
self._message(win, "Dazu werden root-Rechte benoetigt - bitte 'sudo pvesnap "
"config' verwenden.", error=True)
return
if state == "active":
code, output = self._run_service_action(win, "reload", "neu geladen")
if code != 0:
first = (output.splitlines() or ["Fehler %d" % code])[0]
if self._confirm(win, "Fehlgeschlagen: %s - stattdessen neu starten?"
% truncate(first, 50)):
self._run_service_action(win, "restart", "neu gestartet")
else:
if self._confirm(win, "Der Dienst laeuft nicht - jetzt starten?"):
self._run_service_action(win, "start", "gestartet")
def _run_service_action(self, win, action, past_tense):
height, _ = win.getmaxyx()
fill(win, height - 2, " systemctl %s %s ..." % (action, SERVICE),
curses.color_pair(C_SEL))
win.refresh()
code, output = _systemctl(action, SERVICE)
self._service_cache = None
if code == 0:
self._message(win, "Dienst %s - %s" % (past_tense, self._service_text(refresh=True)))
else:
first = (output.splitlines() or ["Rueckgabewert %d" % code])[0]
self._message(win, "systemctl %s fehlgeschlagen: %s" % (action, first), error=True)
return code, output
# -- Bildschirm: eine Gruppe -----------------------------------------
def screen_group(self, win, group):
index = 0
top = 0
while True:
fields = self._group_fields(group)
index = max(0, min(index, len(fields) - 1))
while fields[index][0] == "-" and index < len(fields) - 1:
index += 1
height, width = self._frame(
win, "Gruppe: %s" % group.name,
"Kurzname im Snapshot: %s-%s-JJJJMMTT-HHMMSS"
% (self.config.globals.prefix,
effective_slug(self.config.globals.prefix, group.slug)),
"Enter Aendern | Leer Umschalten | v VM-Auswahl | q zurueck")
visible = height - 5
if index < top:
top = index
if index >= top + visible:
top = index - visible + 1
for row in range(visible):
position = top + row
if position >= len(fields):
break
kind, label, value, _handler = fields[position]
if kind == "-":
put(win, 3 + row, 2, "-- %s " % label + "-" * max(0, width - len(label) - 8),
curses.color_pair(C_DIM) | curses.A_BOLD)
continue
attr = curses.color_pair(C_SEL) if position == index else 0
fill_row(win, 3 + row, 2,
truncate(label, 30).ljust(32) + truncate(value, max(4, width - 40)),
width, attr)
win.refresh()
key = read_key(win)
if key in (curses.KEY_UP, "k"):
index = self._step(fields, index, -1)
elif key in (curses.KEY_DOWN, "j"):
index = self._step(fields, index, +1)
elif key == curses.KEY_NPAGE:
index = self._step(fields, min(index + 10, len(fields) - 1), +1, land=True)
elif key == curses.KEY_PPAGE:
index = self._step(fields, max(index - 10, 0), -1, land=True)
elif is_enter(key) or key == " ":
handler = fields[index][3]
if handler:
handler(win)
self.dirty = True
elif key == "v":
self._pick_vms(win, group)
elif key in ("q", "Q") or is_escape(key):
return
@staticmethod
def _step(fields, index, direction, land=False):
position = index if land else index + direction
while 0 <= position < len(fields) and fields[position][0] == "-":
position += direction
if position < 0 or position >= len(fields):
return index
return position
def _group_fields(self, group):
"""Liste von (art, beschriftung, angezeigter_wert, handler)."""
fields = []
def text_field(label, getter, setter, hint=""):
def handler(win):
value = self._prompt(win, hint or label, getter())
if value is not None:
setter(value)
fields.append(("f", label, getter(), handler))
def bool_field(label, getter, setter):
def handler(_win):
setter(not getter())
fields.append(("f", label, "ja" if getter() else "nein", handler))
def int_field(label, getter, setter, zero_text=""):
def handler(win):
raw = self._prompt(win, label, str(getter()))
if raw is None:
return
try:
setter(max(0, int(raw.strip() or 0)))
except ValueError:
self._message(win, "Bitte eine ganze Zahl eingeben.", error=True)
value = getter()
fields.append(("f", label, zero_text if (value == 0 and zero_text) else str(value),
handler))
def duration_field(label, getter, setter):
def handler(win):
raw = self._prompt(win, label + " (z.B. 30m, 6h, 7d, 0=unbegrenzt)",
format_duration(getter(), zero="0"))
if raw is None:
return
try:
setter(parse_duration(raw, default_unit="m"))
except ValueError as exc:
self._message(win, str(exc), error=True)
fields.append(("f", label, format_duration(getter()), handler))
def list_field(label, getter, setter, hint=""):
def handler(win):
raw = self._prompt(win, hint or label, ", ".join(getter()))
if raw is not None:
setter(parse_list(raw))
fields.append(("f", label, ", ".join(getter()) or "-", handler))
# --- Allgemein ---
fields.append(("-", "Allgemein", "", None))
def set_name(value):
if value:
group.name = value
text_field("Name", lambda: group.name, set_name)
bool_field("Aktiv", lambda: group.enabled,
lambda v: setattr(group, "enabled", v))
# --- Zeitplan ---
fields.append(("-", "Zeitplan", "", None))
current_kind = group.schedule or "interval"
def change_kind(win):
values = [c[0] for c in SCHEDULE_CHOICES]
chosen = self._choose(win, "Zeitplan", SCHEDULE_CHOICES,
values.index(current_kind))
if chosen is None:
return
if chosen == "interval":
group.schedule = ""
group.interval = group.interval or 3600
else:
group.schedule = chosen
group.interval = 0
fields.append(("f", "Art des Zeitplans",
dict(SCHEDULE_CHOICES)[current_kind], change_kind))
if current_kind == "interval":
duration_field("Intervall", lambda: group.interval,
lambda v: setattr(group, "interval", max(60, v)))
bool_field("An der Uhr ausrichten", lambda: group.align,
lambda v: setattr(group, "align", v))
elif current_kind == "hourly":
int_field("Minute (0-59)", lambda: group.minute,
lambda v: setattr(group, "minute", min(59, v)))
else:
def set_time(value):
try:
group.at = "%02d:%02d" % parse_time_of_day(value)
except ValueError:
pass
text_field("Uhrzeit (HH:MM)", lambda: group.at, set_time)
if current_kind == "weekly":
def change_day(win):
entries = [(i, name) for i, name in enumerate(
["Montag", "Dienstag", "Mittwoch", "Donnerstag",
"Freitag", "Samstag", "Sonntag"])]
chosen = self._choose(win, "Wochentag", entries, group.day_of_week)
if chosen is not None:
group.day_of_week = chosen
fields.append(("f", "Wochentag",
WEEKDAY_NAMES[group.day_of_week], change_day))
if current_kind in ("monthly", "yearly"):
int_field("Tag im Monat (1-31)", lambda: group.day_of_month,
lambda v: setattr(group, "day_of_month", max(1, min(31, v))))
if current_kind == "yearly":
int_field("Monat (1-12)", lambda: group.month,
lambda v: setattr(group, "month", max(1, min(12, v))))
fields.append(("f", "Naechster Termin (Vorschau)", _preview_next(group), None))
# --- Vorhaltezeit ---
fields.append(("-", "Vorhaltezeit", "", None))
int_field("Anzahl behalten (je VM)", lambda: group.keep_count,
lambda v: setattr(group, "keep_count", v), zero_text="unbegrenzt")
duration_field("Maximales Alter", lambda: group.keep_time,
lambda v: setattr(group, "keep_time", v))
int_field("Mindestens behalten", lambda: group.keep_min,
lambda v: setattr(group, "keep_min", v), zero_text="0 (keine Untergrenze)")
# --- Platz ---
fields.append(("-", "Platz vor dem Snapshot", "", None))
def set_min_free(win):
raw = self._prompt(win, "Mindestens frei (z.B. 10%, 20G, 0=keine Pruefung)",
group.space_min_free)
if raw is None:
return
try:
group.space_min_free = parse_free_space(raw)
except ValueError as exc:
self._message(win, str(exc), error=True)
fields.append(("f", "Mindestens frei",
"keine Pruefung" if group.space_min_free == "0"
else group.space_min_free, set_min_free))
int_field("Bei Platzmangel behalten", lambda: group.space_keep,
lambda v: setattr(group, "space_keep", v))
# --- Auswahl ---
fields.append(("-", "Welche VMs?", "", None))
bool_field("Alle VMs/Container", lambda: group.all,
lambda v: setattr(group, "all", v))
fields.append(("f", "VMs aus Liste waehlen ...",
format_vmid_list(group.vmids) or "-",
lambda win: self._pick_vms(win, group)))
list_field("Namensmuster (z.B. web-*)", lambda: group.names,
lambda v: setattr(group, "names", v))
list_field("Tags", lambda: group.tags,
lambda v: setattr(group, "tags", [t.lower() for t in v]))
list_field("Pools", lambda: group.pools,
lambda v: setattr(group, "pools", v))
def change_types(win):
labels = [(index, text) for index, (_v, text) in enumerate(TYPE_CHOICES)]
current = 0
for index, (value, _text) in enumerate(TYPE_CHOICES):
if sorted(value) == sorted(group.types):
current = index
chosen = self._choose(win, "Welche Gasttypen?", labels, current)
if chosen is not None:
group.types = list(TYPE_CHOICES[chosen][0])
current_type_text = "alle (VMs und Container)"
for value, text in TYPE_CHOICES:
if sorted(value) == sorted(group.types):
current_type_text = text
fields.append(("f", "Gasttypen", current_type_text, change_types))
def set_exclude_vmids(win):
raw = self._prompt(win, "Ausgeschlossene VMIDs (z.B. 900,905-910)",
format_vmid_list(group.exclude_vmids))
if raw is None:
return
try:
group.exclude_vmids = parse_vmid_list(raw)
except ValueError as exc:
self._message(win, str(exc), error=True)
fields.append(("f", "Ausgeschlossene VMIDs",
format_vmid_list(group.exclude_vmids) or "-", set_exclude_vmids))
list_field("Ausgeschlossene Namensmuster", lambda: group.exclude_names,
lambda v: setattr(group, "exclude_names", v))
list_field("Ausgeschlossene Tags", lambda: group.exclude_tags,
lambda v: setattr(group, "exclude_tags", [t.lower() for t in v]))
matched = self._match_count(group)
fields.append(("f", "Trifft aktuell zu auf", matched, None))
# --- Optionen ---
fields.append(("-", "Snapshot-Optionen", "", None))
bool_field("RAM mitsichern (nur laufende VMs)", lambda: group.vmstate,
lambda v: setattr(group, "vmstate", v))
bool_field("Gestoppte ueberspringen", lambda: group.skip_stopped,
lambda v: setattr(group, "skip_stopped", v))
text_field("Beschreibung (Vorlage)",
lambda: group.description or self.config.globals.description,
lambda v: setattr(group, "description", v),
hint="Beschreibung, Platzhalter: {group} {vmid} {name} {datetime} "
"{keep_time} {keep_count}")
return fields
def _match_count(self, group):
guests = self.guests()
if not guests:
return self._inventory_error or "unbekannt"
from .engine import select_guests
selected = select_guests(group, guests)
if not selected:
return "0 Gaeste"
preview = ", ".join("%d" % g.vmid for g in selected[:8])
return "%d Gast/Gaeste (%s%s)" % (len(selected), preview,
" ..." if len(selected) > 8 else "")
# -- Bildschirm: VM-Auswahl ------------------------------------------
def _pick_vms(self, win, group):
guests = self.guests()
if not guests:
self._message(win, self._inventory_error or "Keine VMs gefunden.", error=True)
return
chosen = set(group.vmids)
index, top, filter_text = 0, 0, ""
while True:
shown = [g for g in guests if self._matches_filter(g, filter_text)]
index = max(0, min(index, max(0, len(shown) - 1)))
height, width = self._frame(
win, "VMs fuer Gruppe '%s'" % group.name,
"%d ausgewaehlt%s" % (len(chosen),
(" Filter: " + filter_text) if filter_text else ""),
"Leer Auswaehlen | a Alle | n Keine | i Umkehren | / Filter | "
"Enter Uebernehmen | q Abbruch")
put(win, 3, 2, " VMID Typ Name".ljust(46) + "Node".ljust(14)
+ "Status".ljust(10) + "Tags", curses.A_BOLD)
visible = max(1, height - 6)
if index < top:
top = index
if index >= top + visible:
top = index - visible + 1
for row in range(visible):
position = top + row
if position >= len(shown):
break
guest = shown[position]
attr = curses.color_pair(C_SEL) if position == index else 0
mark = "[x]" if guest.vmid in chosen else "[ ]"
line = ("%s %s %s %s%s%s%s"
% (mark,
str(guest.vmid).rjust(6),
("LXC" if guest.type == "lxc" else "VM ").ljust(4),
truncate(guest.name or "-", 26).ljust(28),
truncate(guest.node, 12).ljust(14),
truncate(guest.status, 9).ljust(10),
truncate(",".join(guest.tags) or "-", 20)))
fill_row(win, 4 + row, 2, line, width, attr)
win.refresh()
key = read_key(win)
if key in (curses.KEY_UP, "k"):
index -= 1
elif key in (curses.KEY_DOWN, "j"):
index += 1
elif key == curses.KEY_NPAGE:
index += visible
elif key == curses.KEY_PPAGE:
index -= visible
elif key == curses.KEY_HOME:
index = 0
elif key == curses.KEY_END:
index = len(shown) - 1
elif key == " " and shown:
vmid = shown[index].vmid
if vmid in chosen:
chosen.discard(vmid)
else:
chosen.add(vmid)
index += 1
elif key == "a":
chosen.update(g.vmid for g in shown)
elif key == "n":
chosen.difference_update(g.vmid for g in shown)
elif key == "i":
for guest in shown:
if guest.vmid in chosen:
chosen.discard(guest.vmid)
else:
chosen.add(guest.vmid)
elif key == "/":
entered = self._prompt(win, "Filter (Name, VMID oder Tag)", filter_text)
filter_text = entered or ""
index = top = 0
elif key == "r":
self.guests(refresh=True)
guests = self._guests
elif is_enter(key):
group.vmids = sorted(chosen)
self.dirty = True
return
elif key in ("q", "Q") or is_escape(key):
return
index = max(0, min(index, max(0, len(shown) - 1)))
@staticmethod
def _matches_filter(guest, text):
if not text:
return True
needle = text.lower()
return (needle in (guest.name or "").lower()
or needle in str(guest.vmid)
or needle in " ".join(guest.tags)
or needle in (guest.pool or "").lower()
or needle in (guest.node or "").lower())
# -- Bildschirm: globale Einstellungen -------------------------------
def screen_globals(self, win):
globals_ = self.config.globals
index = 0
while True:
fields = [
("Praefix im Snapshot-Namen", globals_.prefix, "text", "prefix"),
("Pruefintervall des Dienstes", format_duration(globals_.check_interval),
"duration", "check_interval"),
("Zustandsdatei", globals_.state_file, "text", "state_file"),
("Protokollstufe", globals_.log_level, "level", "log_level"),
("Zusaetzliche Logdatei", globals_.log_file or "-", "text", "log_file"),
("Zeitlimit je Snapshot-Task", format_duration(globals_.task_timeout),
"duration", "task_timeout"),
("Wiederholungen bei belegter Sperre", str(globals_.retries),
"int", "retries"),
("Wartezeit vor Wiederholung", format_duration(globals_.retry_delay),
"duration", "retry_delay"),
("Pause zwischen zwei VMs", format_duration(globals_.pause_between, zero="keine"),
"duration", "pause_between"),
("Geduld nach Loeschen bei Platzmangel",
format_duration(globals_.space_settle, zero="keine"),
"duration", "space_settle"),
("Beim Start sofort ausfuehren", "ja" if globals_.run_on_start else "nein",
"bool", "run_on_start"),
("Testlauf (nichts wirklich tun)", "ja" if globals_.dry_run else "nein",
"bool", "dry_run"),
("Standard-Beschreibung", globals_.description, "text", "description"),
]
index = max(0, min(index, len(fields) - 1))
height, width = self._frame(win, "Globale Einstellungen", self.path,
"Enter Aendern | q zurueck")
for row, (label, value, _kind, _key) in enumerate(fields):
attr = curses.color_pair(C_SEL) if row == index else 0
fill_row(win, 4 + row, 2,
truncate(label, 32).ljust(34) + truncate(str(value), max(4, width - 42)),
width, attr)
put(win, 6 + len(fields), 2,
"Platzhalter der Beschreibung: {group} {vmid} {name} {node} {type} "
"{datetime} {date} {time} {keep_time} {keep_count} {schedule}",
curses.color_pair(C_DIM))
win.refresh()
key = read_key(win)
if key in (curses.KEY_UP, "k"):
index -= 1
elif key in (curses.KEY_DOWN, "j"):
index += 1
elif is_enter(key) or key == " ":
label, _value, kind, attribute = fields[index]
self._edit_global(win, label, kind, attribute)
elif key in ("q", "Q") or is_escape(key):
return
def _edit_global(self, win, label, kind, attribute):
globals_ = self.config.globals
if kind == "bool":
setattr(globals_, attribute, not getattr(globals_, attribute))
self.dirty = True
return
if kind == "level":
entries = [(lvl, lvl) for lvl in ("DEBUG", "INFO", "WARNING", "ERROR")]
chosen = self._choose(win, "Protokollstufe", entries)
if chosen:
globals_.log_level = chosen
self.dirty = True
return
if kind == "int":
raw = self._prompt(win, label, str(getattr(globals_, attribute)))
if raw is None:
return
try:
setattr(globals_, attribute, max(0, int(raw.strip() or 0)))
self.dirty = True
except ValueError:
self._message(win, "Bitte eine ganze Zahl eingeben.", error=True)
return
if kind == "duration":
raw = self._prompt(win, label, format_duration(getattr(globals_, attribute),
zero="0"))
if raw is None:
return
try:
setattr(globals_, attribute, parse_duration(raw, default_unit="s"))
self.dirty = True
except ValueError as exc:
self._message(win, str(exc), error=True)
return
raw = self._prompt(win, label, getattr(globals_, attribute))
if raw is None:
return
setattr(globals_, attribute, raw)
self.dirty = True
# -- Bildschirm: Mail-Alarm ------------------------------------------
def screen_mail(self, win):
mail = self.config.mail
index = 0
while True:
fields = [
("Alarm per Mail", "ja" if mail.enabled else "nein", "enabled"),
("SMTP-Server", mail.server or "-", "server"),
("Verschluesselung", MAIL_SECURITY_TEXT.get(mail.security, mail.security),
"security"),
("Port", "%d%s" % (mail.effective_port, "" if mail.port else " (automatisch)"),
"port"),
("Zertifikat pruefen", "ja" if mail.tls_verify else "nein", "tls_verify"),
("Benutzer", mail.user or "- (ohne Anmeldung)", "user"),
("Passwort", "(gesetzt)" if mail.password else "-", "password"),
("Absender", mail.sender or "-", "sender"),
("Empfaenger", ", ".join(mail.recipients) or "-", "recipients"),
]
index = max(0, min(index, len(fields) - 1))
_height, width = self._frame(win, "Mail-Alarm", self.path,
"Enter Aendern | t Test-Mail | q zurueck")
for row, (label, value, _attribute) in enumerate(fields):
attr = curses.color_pair(C_SEL) if row == index else 0
fill_row(win, 4 + row, 2,
truncate(label, 32).ljust(34) + truncate(str(value), max(4, width - 42)),
width, attr)
put(win, 6 + len(fields), 2,
"Meldet, wenn wegen Platzmangels ein Snapshot ausfaellt.",
curses.color_pair(C_DIM))
win.refresh()
key = read_key(win)
if key in (curses.KEY_UP, "k"):
index -= 1
elif key in (curses.KEY_DOWN, "j"):
index += 1
elif is_enter(key) or key == " ":
self._edit_mail(win, fields[index][0], fields[index][2])
elif key == "t":
self._send_test_mail(win)
elif key in ("q", "Q") or is_escape(key):
return
def _edit_mail(self, win, label, attribute):
mail = self.config.mail
if attribute in ("enabled", "tls_verify"):
setattr(mail, attribute, not getattr(mail, attribute))
elif attribute == "security":
chosen = self._choose(win, label, list(MAIL_SECURITY_TEXT.items()))
if chosen is None:
return
mail.security = chosen
elif attribute == "port":
raw = self._prompt(win, "Port (0 = automatisch)", str(mail.port))
if raw is None:
return
if not raw.isdigit() or int(raw) > 65535:
self._message(win, "Bitte einen Port zwischen 0 und 65535 eingeben.", error=True)
return
mail.port = int(raw)
elif attribute == "password":
raw = self._prompt(win, "Passwort (leer = unveraendert, - = entfernen)", "",
secret=True)
if not raw:
return
mail.password = "" if raw == "-" else raw
elif attribute == "recipients":
raw = self._prompt(win, "Empfaenger (mehrere mit Komma)",
", ".join(mail.recipients))
if raw is None:
return
mail.recipients = parse_list(raw)
else:
raw = self._prompt(win, label, getattr(mail, attribute))
if raw is None:
return
setattr(mail, attribute, raw)
self.dirty = True
def _send_test_mail(self, win):
problems = self.config.mail.problems()
if problems:
self._message(win, problems[0], error=True)
return
height, _width = win.getmaxyx()
fill(win, height - 2, " Test-Mail wird gesendet ...", curses.color_pair(C_SEL))
win.refresh()
try:
send_test_mail(self.config.mail)
self._message(win, "Test-Mail an %s gesendet."
% ", ".join(self.config.mail.recipients))
except MailError as exc:
self._message(win, "Test-Mail fehlgeschlagen: %s" % exc, error=True)
# -- Bildschirm: Uebersicht ------------------------------------------
def screen_overview(self, win):
from .engine import select_guests
guests = self.guests()
top = 0
while True:
height, width = self._frame(win, "Uebersicht: welche VM in welcher Gruppe?",
self._inventory_error or "%d Gaeste" % len(guests),
"Pfeiltasten Blaettern | r Neu laden | q zurueck")
put(win, 3, 2, "VMID".ljust(8) + "Name".ljust(26) + "Status".ljust(10) + "Gruppen",
curses.A_BOLD)
rows = []
for guest in guests:
names = [g.name for g in self.config.groups if guest in select_guests(g, [guest])]
rows.append((guest, names))
visible = max(1, height - 6)
top = max(0, min(top, max(0, len(rows) - visible)))
for row in range(visible):
position = top + row
if position >= len(rows):
break
guest, names = rows[position]
attr = 0 if names else curses.color_pair(C_DIM) | curses.A_DIM
put(win, 4 + row, 2,
truncate(str(guest.vmid), 7).ljust(8)
+ truncate(guest.name or "-", 24).ljust(26)
+ truncate(guest.status, 9).ljust(10)
+ truncate(", ".join(names) or "(keine Gruppe)", max(4, width - 48)),
attr)
win.refresh()
key = read_key(win)
if key == curses.KEY_DOWN:
top += 1
elif key == curses.KEY_UP:
top -= 1
elif key == curses.KEY_NPAGE:
top += visible
elif key == curses.KEY_PPAGE:
top -= visible
elif key == "r":
guests = self.guests(refresh=True)
elif key in ("q", "Q") or is_escape(key):
return
top = max(0, top)
def _preview_next(group):
"""Zeigt den naechsten Termin, damit man die Einstellung sofort pruefen kann."""
from datetime import datetime
from .schedule import next_due
try:
moment = next_due(group, None, datetime.now())
except ValueError:
return "-"
tage = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
return "%s %s (%s)" % (tage[moment.weekday()],
moment.strftime("%d.%m.%Y %H:%M"), describe(group))
def run_editor(path):
editor = Editor(path)
try:
editor.load()
except ConfigError as exc:
print(str(exc))
return 2
curses.wrapper(editor.run)
if editor.dirty:
print("Achtung: Aenderungen wurden NICHT gespeichert.")
return 1
return 0