commit 87f1b4d555c82b85bab6b49cf8156ca45457e81f Author: duffyduck Date: Thu Jul 30 16:52:58 2026 +0200 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..780d653 --- /dev/null +++ b/README.md @@ -0,0 +1,268 @@ +# pvesnap — automatische Snapshots für Proxmox VE + +Ein systemd-Dienst, der nach frei definierbaren Zeitplänen Snapshots von VMs und +Containern anlegt und alte Snapshots nach einer einstellbaren Vorhaltezeit wieder +löscht. Alles wird über **eine INI-Datei** gesteuert, die sich auch bequem in einem +**ncurses-Editor** bearbeiten lässt. + +* Beliebig viele **Gruppen** — z. B. „diese VMs stündlich, jene täglich, der Rest monatlich“ +* **VM-Auswahl** nach VMID, Name (mit `*`-Mustern), Tag, Pool oder einfach „alle“ +* **Vorhaltezeit** je Gruppe nach Anzahl *und/oder* Alter +* Jeder Snapshot bekommt eine **Beschreibung**, an der er sich wiedererkennen lässt +* Läuft cluster-weit (alle Nodes) über `pvesh` +* Nur Python-Standardbibliothek — keine zusätzlichen Pakete nötig + +--- + +## Installation + +Auf dem Proxmox-Host als root: + +```bash +git clone pvesnap && cd pvesnap +./install.sh +``` + +Das Skript legt an: + +| Pfad | Inhalt | +|---|---| +| `/usr/lib/pvesnap/` | Programmcode | +| `/usr/local/bin/pvesnap` | Startbefehl | +| `/etc/pvesnap/pvesnap.conf` | Konfiguration (wird bei Updates **nicht** überschrieben) | +| `/var/lib/pvesnap/state.json` | merkt sich die letzten Läufe | +| `/etc/systemd/system/pvesnap.service` | systemd-Unit | + +In der mitgelieferten Beispielkonfiguration sind alle Gruppen auf `enabled = no` +gesetzt — es passiert also erst etwas, wenn du eine Gruppe aktivierst. + +**Deinstallation:** + +```bash +./uninstall.sh # Programm und Dienst entfernen, Konfiguration bleibt +./uninstall.sh --purge # zusätzlich /etc/pvesnap und /var/lib/pvesnap löschen +``` + +Bereits angelegte Snapshots werden dabei **nie** angerührt. + +--- + +## Konfiguration im ncurses-Editor + +```bash +pvesnap config +``` + +* **Gruppenliste** — `Enter` bearbeiten, `n` neu, `c` kopieren, `d` löschen, + `Leertaste` an/aus, `g` globale Einstellungen, `v` Übersicht welche VM in + welcher Gruppe landet, `s` speichern, `q` Ende +* **Gruppe bearbeiten** — Feld auswählen, `Enter` ändern, `Leertaste` umschalten. + Ganz unten steht live, auf wie viele Gäste die Auswahl gerade zutrifft. +* **VM-Auswahl** (`v`) — Liste aller VMs und Container mit Node, Status und Tags; + `Leertaste` auswählen, `a` alle, `n` keine, `i` umkehren, `/` filtern, + `Enter` übernehmen. + +> Beim Speichern wird die INI-Datei neu geschrieben. Eigene Kommentare und ein +> `[defaults]`-Abschnitt gehen dabei verloren (die Werte bleiben inhaltlich +> erhalten, sie stehen danach in jeder Gruppe einzeln). Eine Sicherung wird als +> `pvesnap.conf.bak` abgelegt. + +--- + +## Konfiguration von Hand + +```ini +[global] +prefix = auto # Namenspräfix aller pvesnap-Snapshots +check_interval = 60s # wie oft der Dienst nach Fälligem schaut +state_file = /var/lib/pvesnap/state.json +log_level = INFO +task_timeout = 15m +run_on_start = no # beim Start sofort einen Durchlauf machen? +dry_run = no # yes = nichts wirklich tun, nur protokollieren +description = pvesnap | Gruppe: {group} | erstellt: {datetime} | Vorhaltezeit: {keep_time} + +[defaults] # Vorgaben für alle Gruppen (optional) +skip_stopped = no + +[group:stündlich] +interval = 1h +align = yes # an der Uhr ausrichten: 00:00, 01:00, ... +keep_count = 24 # höchstens 24 Snapshots je VM +keep_time = 2d # nichts älter als 2 Tage +tags = stuendlich # alle VMs mit diesem Tag +skip_stopped = yes + +[group:täglich] +schedule = daily +at = 02:30 +keep_count = 14 +keep_time = 21d +names = web-*, db-* + +[group:monatlich] +schedule = monthly +day_of_month = 1 +at = 04:00 +keep_count = 6 +all = yes +exclude_tags = nosnap +``` + +Nach jeder Änderung: + +```bash +pvesnap check # Konfiguration prüfen +systemctl reload pvesnap # Dienst übernimmt die Änderung ohne Neustart +``` + +### Zeitplan + +Entweder ein Intervall … + +```ini +interval = 30m # 30m, 1h, 6h, 2d12h, 1w ... +align = yes # yes: an der Uhr ausgerichtet (00:00, 00:30, 01:00, ...) + # no: 30 Minuten nach dem letzten Lauf +``` + +… oder ein fester Termin: + +| `schedule` | zusätzliche Angaben | Beispiel | +|---|---|---| +| `hourly` | `minute = 15` | jede Stunde um :15 | +| `daily` | `at = 02:30` | täglich um 02:30 | +| `weekly` | `at`, `day_of_week = so` | sonntags um 03:00 | +| `monthly` | `at`, `day_of_month = 1` | am 1. jedes Monats | +| `yearly` | `at`, `day_of_month`, `month` | einmal jährlich | + +`day_of_month = 31` wird in kürzeren Monaten automatisch auf den letzten Tag +gezogen. Läuft der Host zum Termin nicht, wird der Lauf beim nächsten Start +nachgeholt. + +### Vorhaltezeit + +```ini +keep_count = 24 # höchstens 24 Snapshots je VM und Gruppe (0 = unbegrenzt) +keep_time = 7d # nichts älter als 7 Tage (0 = unbegrenzt) +keep_min = 1 # so viele bleiben in jedem Fall stehen +``` + +Beides ist kombinierbar — gelöscht wird, was *eine* der beiden Grenzen reißt. +Mindestens eine der beiden Angaben muss gesetzt sein, sonst würde die Zahl der +Snapshots unbegrenzt wachsen (`pvesnap check` weist darauf hin). + +### Welche VMs? + +Alle Angaben wirken als **ODER**, Ausschlüsse gewinnen immer: + +```ini +all = yes # alle VMs und Container +vmids = 100,101,105-110 # nach ID, auch Bereiche +names = web-*, db-0? # nach Name, mit Platzhaltern +tags = prod, wichtig # nach Proxmox-Tag +pools = Kunden # nach Proxmox-Pool +types = qemu # nur VMs (bzw. lxc = nur Container; leer = beides) + +exclude_vmids = 999 +exclude_names = *-test +exclude_tags = nosnap +``` + +### Snapshot-Optionen + +```ini +vmstate = yes # RAM mitsichern (nur QEMU und nur bei laufender VM) +skip_stopped = yes # gestoppte Gäste überspringen +description = Sicherung von {name} ({vmid}) vom {date} +``` + +Platzhalter der Beschreibung: `{group}` `{group_slug}` `{vmid}` `{name}` `{node}` +`{type}` `{pool}` `{tags}` `{date}` `{time}` `{datetime}` `{timestamp}` +`{keep_time}` `{keep_count}` `{schedule}` + +--- + +## Namensschema und Sicherheit + +``` +auto-taeglich-20260730-023000 + │ │ │ └── Uhrzeit + │ │ └────────── Datum + │ └──────────────────── Kurzname der Gruppe + └─────────────────────────── prefix aus [global] +``` + +pvesnap löscht **ausschließlich** Snapshots, deren Name exakt auf dieses Muster +passt und deren Gruppen-Kurzname zur jeweiligen Gruppe gehört. Von Hand oder von +anderen Werkzeugen angelegte Snapshots bleiben garantiert unangetastet. + +Umlaute und Sonderzeichen im Gruppennamen werden für den Kurznamen umgeschrieben +(`täglich` → `taeglich`). Wenn zwei Gruppen denselben Kurznamen ergäben, meldet +das `pvesnap check`. + +--- + +## Befehle + +```bash +pvesnap status # Übersicht: Gruppen, letzte und nächste Läufe +pvesnap vms # alle VMs und die Gruppen, in denen sie stecken +pvesnap list # vorhandene pvesnap-Snapshots (-a = auch fremde) +pvesnap check # Konfiguration prüfen +pvesnap config # ncurses-Editor + +pvesnap run # jetzt fällige Gruppen ausführen +pvesnap run --force -g täglich # eine Gruppe sofort ausführen +pvesnap run --force --dry-run # Probelauf, ändert nichts +pvesnap prune -g stündlich # nur aufräumen, keine neuen Snapshots + +systemctl status pvesnap +systemctl reload pvesnap # Konfiguration neu einlesen (SIGHUP) +journalctl -u pvesnap -f # Protokoll mitlesen +``` + +Ein manueller `pvesnap run` ist auch möglich, während der Dienst läuft — beide +teilen sich eine Sperre und kommen sich nicht in die Quere. + +Mit `-c /pfad/zur.conf` lässt sich jederzeit eine andere Konfiguration verwenden, +z. B. zum Ausprobieren. + +--- + +## Hinweise aus der Praxis + +* **Snapshot ist kein Backup.** Snapshots liegen auf demselben Storage wie die VM. + Für echte Sicherungen zusätzlich `vzdump` / Proxmox Backup Server verwenden. +* **Speicherplatz:** Jeder Snapshot hält alte Blöcke fest. Bei schreibfreudigen + VMs lieber kurze Vorhaltezeiten wählen und den Storage im Auge behalten. +* **Nicht jedes Storage kann Snapshots** — LVM-thick und Verzeichnis-Storage mit + `raw`-Images können es nicht. Solche VMs melden einen Fehler im Protokoll; die + übrigen laufen normal weiter. +* **`vmstate = yes`** friert die VM kurz ein und braucht Platz in Höhe des + zugewiesenen RAM. Für regelmäßige Läufe meist unnötig. +* **Gruppe umbenennen:** Der neue Name bekommt einen eigenen Kurznamen. Snapshots + unter dem alten Namen werden dann nicht mehr automatisch aufgeräumt — vorher + aufräumen lassen oder die alten Snapshots von Hand entfernen. +* **Zeitumstellung:** Es wird mit lokaler Zeit gerechnet. Ein täglicher Termin um + 02:30 kann in der Umstellungsnacht ausfallen oder doppelt anstehen. + +--- + +## Aufbau + +``` +pvesnap/ + config.py INI lesen und schreiben, Gruppen- und Globaleinstellungen + schedule.py Berechnung des nächsten Termins + naming.py Namensschema und Beschreibungs-Vorlagen + proxmox.py pvesh-Anbindung (Inventar, Snapshots anlegen/löschen) + engine.py Auswahl der Gäste, Anlegen, Aufräumen + daemon.py Hauptschleife, Signale, Sperren + state.py merkt sich die letzten Läufe + tui.py ncurses-Editor + cli.py Kommandozeile +config/pvesnap.conf.example +systemd/pvesnap.service +install.sh uninstall.sh +``` diff --git a/config/pvesnap.conf.example b/config/pvesnap.conf.example new file mode 100644 index 0000000..bfe2c73 --- /dev/null +++ b/config/pvesnap.conf.example @@ -0,0 +1,112 @@ +# ===================================================================== +# pvesnap - Konfiguration +# ===================================================================== +# +# Nach jeder Aenderung: systemctl reload pvesnap +# Pruefen: pvesnap check +# Komfortabel bearbeiten: pvesnap config (ncurses-Editor) +# +# Zeitangaben ueberall: 30m 1h 6h 2d12h 1w (0 = unbegrenzt) +# --------------------------------------------------------------------- + +[global] +# Praefix aller von pvesnap angelegten Snapshots. Nur Snapshots, die mit +# diesem Praefix beginnen, werden jemals automatisch geloescht - von Hand +# angelegte Snapshots bleiben also garantiert unangetastet. +# Namensschema: --- +prefix = auto + +# Wie oft der Dienst prueft, ob eine Gruppe faellig ist. +check_interval = 60s + +# Merkt sich, wann welche Gruppe zuletzt gelaufen ist. +state_file = /var/lib/pvesnap/state.json + +log_level = INFO +# log_file = /var/log/pvesnap.log + +# Maximale Wartezeit auf einen einzelnen Snapshot-Task in Proxmox. +task_timeout = 15m + +# yes = beim Dienststart sofort einen Durchlauf machen, +# no = auf den naechsten regulaeren Termin warten. +run_on_start = no + +# yes = nichts wirklich anlegen/loeschen, nur protokollieren. +dry_run = no + +# Beschreibung, die an jedem Snapshot haengt (in der Proxmox-Oberflaeche +# sichtbar). Platzhalter: +# {group} {group_slug} {vmid} {name} {node} {type} {pool} {tags} +# {date} {time} {datetime} {timestamp} {keep_time} {keep_count} {schedule} +description = pvesnap | Gruppe: {group} | erstellt: {datetime} | Vorhaltezeit: {keep_time} | max: {keep_count} + + +# --------------------------------------------------------------------- +# [defaults] gilt als Vorgabe fuer *alle* Gruppen und kann in jeder +# Gruppe einzeln ueberschrieben werden. +# --------------------------------------------------------------------- +[defaults] +enabled = yes +skip_stopped = no +vmstate = no + + +# --------------------------------------------------------------------- +# Gruppen - beliebig viele, Abschnittsname: [group:NAME] +# --------------------------------------------------------------------- +# +# Zeitplan (entweder ... oder): +# interval = 30m alle 30 Minuten +# align = yes an der Uhr ausrichten (00:00, 00:30, 01:00 ...) +# oder +# schedule = hourly + minute = 15 +# schedule = daily + at = 02:30 +# schedule = weekly + at = 03:00, day_of_week = so +# schedule = monthly + at = 04:00, day_of_month = 1 +# schedule = yearly + at = 04:00, day_of_month = 1, month = 1 +# +# Vorhaltezeit (beides kombinierbar, 0 = unbegrenzt): +# keep_count = 24 hoechstens 24 Snapshots je VM +# keep_time = 7d nichts aelter als 7 Tage +# keep_min = 1 so viele bleiben immer stehen +# +# Auswahl der Gaeste (alles kombinierbar, wirkt als ODER): +# all = yes +# vmids = 100,101,105-110 +# names = web-*, db-* +# tags = prod, wichtig +# pools = Kunden +# types = qemu (oder lxc; leer = beides) +# exclude_vmids = 999 +# exclude_names = *-test +# exclude_tags = nosnap +# --------------------------------------------------------------------- + +[group:stuendlich] +enabled = no +interval = 1h +align = yes +keep_count = 24 +keep_time = 2d +tags = stuendlich +skip_stopped = yes + +[group:taeglich] +enabled = no +schedule = daily +at = 02:30 +keep_count = 14 +keep_time = 21d +names = web-*, db-* + +[group:monatlich] +enabled = no +schedule = monthly +day_of_month = 1 +at = 04:00 +keep_count = 6 +keep_time = 400d +all = yes +exclude_tags = nosnap +description = Monatssicherung {name} ({vmid}) vom {date} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..8ec0e3b --- /dev/null +++ b/install.sh @@ -0,0 +1,133 @@ +#!/bin/bash +# --------------------------------------------------------------------- +# pvesnap - Installation +# +# ./install.sh installieren, Dienst aktivieren und starten +# ./install.sh --no-start installieren, aber Dienst nicht starten +# ./install.sh --force auch ohne erkanntes Proxmox VE installieren +# --------------------------------------------------------------------- +set -euo pipefail + +LIB_DIR="/usr/lib/pvesnap" +BIN="/usr/local/bin/pvesnap" +CONF_DIR="/etc/pvesnap" +CONF="${CONF_DIR}/pvesnap.conf" +STATE_DIR="/var/lib/pvesnap" +DOC_DIR="/usr/share/doc/pvesnap" +UNIT="/etc/systemd/system/pvesnap.service" +SRC="$(cd "$(dirname "$0")" && pwd)" + +START=1 +FORCE=0 +for arg in "$@"; do + case "$arg" in + --no-start) START=0 ;; + --force) FORCE=1 ;; + -h|--help) sed -n '2,8p' "$0" | sed 's/^#[[:space:]]\{0,1\}//'; exit 0 ;; + *) echo "Unbekannte Option: $arg" >&2; exit 1 ;; + esac +done + +info() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m==>\033[0m %s\n' "$*" >&2; } +fail() { printf '\033[1;31m==>\033[0m %s\n' "$*" >&2; exit 1; } + +[ "$(id -u)" -eq 0 ] || fail "Bitte als root ausfuehren (sudo ./install.sh)." + +command -v python3 >/dev/null 2>&1 || fail "python3 wird benoetigt." +python3 - <<'PY' || fail "Python 3.7 oder neuer wird benoetigt." +import sys +sys.exit(0 if sys.version_info >= (3, 7) else 1) +PY + +if ! command -v pvesh >/dev/null 2>&1; then + if [ "$FORCE" -eq 1 ]; then + warn "pvesh nicht gefunden - installiere trotzdem (--force)." + START=0 + else + fail "pvesh nicht gefunden. pvesnap gehoert auf einen Proxmox-VE-Host. + Mit --force laesst sich die Installation trotzdem erzwingen." + fi +fi + +command -v systemctl >/dev/null 2>&1 || fail "systemd wird benoetigt." +[ -d "${SRC}/pvesnap" ] || fail "Verzeichnis 'pvesnap' nicht gefunden (Aufruf aus dem Projektordner?)." + +# --- Programm --------------------------------------------------------- +info "Installiere Programm nach ${LIB_DIR}" +rm -rf "${LIB_DIR}/pvesnap" +install -d -m 0755 "${LIB_DIR}" +cp -r "${SRC}/pvesnap" "${LIB_DIR}/pvesnap" +find "${LIB_DIR}/pvesnap" -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null || true +chmod -R go-w "${LIB_DIR}/pvesnap" + +info "Installiere Startbefehl ${BIN}" +cat > "${BIN}" </dev/null 2>&1; then + info "Dienst beim Systemstart aktiviert" +else + warn "Dienst konnte nicht aktiviert werden - bitte 'systemctl enable pvesnap' pruefen." +fi + +if [ "$START" -eq 1 ]; then + info "Starte Dienst" + systemctl restart pvesnap.service + sleep 1 + systemctl --no-pager --lines=5 status pvesnap.service || true +else + warn "Dienst wurde nicht gestartet." +fi + +cat < (2, 30). Auch '2', '2:5', '02:30:00' werden akzeptiert.""" + value = str(text).strip() + match = re.match(r"^(\d{1,2})(?::(\d{1,2}))?(?::(\d{1,2}))?$", value) + if not match: + raise ValueError("ungueltige Uhrzeit: %r (erwartet HH:MM)" % text) + hour = int(match.group(1)) + minute = int(match.group(2) or 0) + if hour > 23 or minute > 59: + raise ValueError("ungueltige Uhrzeit: %r" % text) + return hour, minute + + +@dataclass +class Group: + """Eine Snapshot-Gruppe: Zeitplan + Vorhaltezeit + VM-Auswahl.""" + + name: str + enabled: bool = True + + # --- Zeitplan ------------------------------------------------------- + interval: int = 0 # Sekunden; > 0 = intervallbasiert + schedule: str = "" # hourly/daily/weekly/monthly/yearly + at: str = "00:00" # Uhrzeit fuer daily/weekly/monthly/yearly + minute: int = 0 # Minute fuer schedule = hourly + day_of_week: int = 0 # 0 = Montag (weekly) + day_of_month: int = 1 # 1..31 (monthly/yearly) + month: int = 1 # 1..12 (yearly) + align: bool = True # Intervalle an der Uhr ausrichten + + # --- Vorhaltezeit --------------------------------------------------- + keep_count: int = 0 # max. Anzahl je VM (0 = unbegrenzt) + keep_time: int = 0 # max. Alter in Sekunden (0 = unbegrenzt) + keep_min: int = 0 # so viele bleiben immer stehen + + # --- Auswahl der Gaeste --------------------------------------------- + all: bool = False + vmids: list = field(default_factory=list) + names: list = field(default_factory=list) # Glob-Muster + tags: list = field(default_factory=list) + pools: list = field(default_factory=list) + types: list = field(default_factory=list) # qemu / lxc + exclude_vmids: list = field(default_factory=list) + exclude_names: list = field(default_factory=list) + exclude_tags: list = field(default_factory=list) + + # --- Snapshot-Optionen ---------------------------------------------- + vmstate: bool = False # RAM mitsichern (nur QEMU, nur laufend) + skip_stopped: bool = False # gestoppte Gaeste ueberspringen + description: str = "" # Vorlage; leer = globale Vorlage + + @property + def slug(self): + """Kurzform des Gruppennamens, taucht im Snapshot-Namen auf.""" + return slugify(self.name) + + def selects_anything(self): + return bool(self.all or self.vmids or self.names or self.tags or self.pools) + + def validate(self): + problems = [] + if not self.name.strip(): + problems.append("Gruppe ohne Namen") + if self.interval <= 0 and not self.schedule: + problems.append("[%s] weder 'interval' noch 'schedule' gesetzt" % self.name) + if self.interval > 0 and self.schedule: + problems.append("[%s] 'interval' und 'schedule' schliessen sich aus" % self.name) + if self.schedule and self.schedule not in SCHEDULE_KINDS: + problems.append("[%s] unbekannter Zeitplan %r (erlaubt: %s)" + % (self.name, self.schedule, ", ".join(SCHEDULE_KINDS))) + if self.interval > 0 and self.interval < 60: + problems.append("[%s] 'interval' muss mindestens 60s betragen" % self.name) + if not self.selects_anything(): + problems.append("[%s] keine VMs ausgewaehlt (all/vmids/names/tags/pools)" % self.name) + if self.keep_count <= 0 and self.keep_time <= 0: + problems.append("[%s] weder 'keep_count' noch 'keep_time' gesetzt - " + "Snapshots wuerden nie geloescht" % self.name) + for kind in self.types: + if kind not in ("qemu", "lxc"): + problems.append("[%s] unbekannter Typ %r (erlaubt: qemu, lxc)" % (self.name, kind)) + try: + parse_time_of_day(self.at) + except ValueError as exc: + problems.append("[%s] %s" % (self.name, exc)) + if not 1 <= self.day_of_month <= 31: + problems.append("[%s] 'day_of_month' muss zwischen 1 und 31 liegen" % self.name) + return problems + + +@dataclass +class GlobalConfig: + prefix: str = "auto" + check_interval: int = 60 + state_file: str = "/var/lib/pvesnap/state.json" + log_level: str = "INFO" + log_file: str = "" + dry_run: bool = False + task_timeout: int = 900 + run_on_start: bool = False + description: str = DEFAULT_DESCRIPTION + lock_file: str = "/run/pvesnap.lock" + + def validate(self): + problems = [] + if not re.match(r"^[A-Za-z][A-Za-z0-9]{0,15}$", self.prefix): + problems.append("[global] 'prefix' muss mit einem Buchstaben beginnen und darf " + "nur Buchstaben/Ziffern enthalten (max. 16 Zeichen)") + if self.check_interval < 5: + problems.append("[global] 'check_interval' muss mindestens 5 Sekunden betragen") + if self.log_level.upper() not in ("DEBUG", "INFO", "WARNING", "ERROR"): + problems.append("[global] unbekannter 'log_level': %s" % self.log_level) + return problems + + +@dataclass +class Config: + globals: GlobalConfig = field(default_factory=GlobalConfig) + groups: list = field(default_factory=list) + defaults: dict = field(default_factory=dict) + path: str = DEFAULT_CONFIG_PATH + + def group(self, name): + for group in self.groups: + if group.name.lower() == str(name).lower(): + return group + return None + + def validate(self): + from .naming import effective_slug + + problems = list(self.globals.validate()) + seen = {} + for group in self.groups: + problems.extend(group.validate()) + # Der Kurzname wird im Snapshot-Namen ggf. gekuerzt - Kollisionen + # muessen also auf der gekuerzten Form geprueft werden. + slug = effective_slug(self.globals.prefix, group.slug) + if slug in seen: + problems.append("Gruppen %r und %r ergeben denselben Kurznamen %r - " + "bitte unterscheidbarer benennen" % (seen[slug], group.name, slug)) + seen[slug] = group.name + return problems + + +# --------------------------------------------------------------------------- +# Lesen +# --------------------------------------------------------------------------- + +_GROUP_KEYS = { + "enabled", "interval", "schedule", "at", "minute", "day_of_week", "day_of_month", + "month", "align", "keep_count", "keep_time", "keep_min", "all", "vmids", "names", + "tags", "pools", "types", "exclude_vmids", "exclude_names", "exclude_tags", + "vmstate", "skip_stopped", "description", +} + +# Deutsche Schreibweisen als Synonyme, damit die INI lesbar bleibt. +_KEY_ALIASES = { + "aktiv": "enabled", "aktiviert": "enabled", + "intervall": "interval", + "zeitplan": "schedule", + "uhrzeit": "at", "zeit": "at", + "wochentag": "day_of_week", + "monatstag": "day_of_month", + "monat": "month", + "anzahl": "keep_count", "max_anzahl": "keep_count", "behalte_anzahl": "keep_count", + "vorhaltezeit": "keep_time", "behalte_zeit": "keep_time", "max_alter": "keep_time", + "mindestens": "keep_min", + "alle": "all", + "namen": "names", + "typen": "types", + "beschreibung": "description", + "ausschluss_vmids": "exclude_vmids", + "ausschluss_namen": "exclude_names", + "gestoppte_ueberspringen": "skip_stopped", +} + + +def _canonical_key(key): + key = key.strip().lower().replace("-", "_") + return _KEY_ALIASES.get(key, key) + + +def _normalize_section(section): + return {_canonical_key(k): v for k, v in section.items()} + + +def load_config(path=DEFAULT_CONFIG_PATH): + """Liest die INI-Datei und gibt eine geprueft-parsierte Config zurueck.""" + if not os.path.exists(path): + raise ConfigError("Konfigurationsdatei nicht gefunden: %s" % path) + + parser = configparser.ConfigParser(interpolation=None) + parser.optionxform = str # Gross-/Kleinschreibung selbst behandeln + try: + with open(path, "r", encoding="utf-8") as handle: + parser.read_file(handle) + except (configparser.Error, OSError) as exc: + raise ConfigError("Konfiguration nicht lesbar (%s): %s" % (path, exc)) + + config = Config(path=path) + errors = [] + + # [global] + for name in parser.sections(): + if name.strip().lower() in ("global", "allgemein", "main"): + config.globals = _parse_global(_normalize_section(parser[name]), errors) + break + + # [defaults] - Vorgabewerte fuer alle Gruppen + defaults = {} + for name in parser.sections(): + if name.strip().lower() in ("defaults", "default", "vorgaben", "standard"): + defaults = _normalize_section(parser[name]) + break + config.defaults = dict(defaults) + + # [group:NAME] + for name in parser.sections(): + match = GROUP_SECTION_RE.match(name.strip()) + if not match: + continue + group_name = match.group(1).strip() + merged = dict(defaults) + merged.update(_normalize_section(parser[name])) + config.groups.append(_parse_group(group_name, merged, errors)) + + if errors: + raise ConfigError("Fehler in %s:\n - %s" % (path, "\n - ".join(errors))) + return config + + +def _parse_global(section, errors): + result = GlobalConfig() + + def take(key, parser_fn, target=None): + if key not in section: + return + try: + setattr(result, target or key, parser_fn(section[key])) + except ValueError as exc: + errors.append("[global] %s: %s" % (key, exc)) + + take("prefix", lambda v: str(v).strip()) + take("check_interval", lambda v: parse_duration(v, default_unit="s")) + take("state_file", lambda v: str(v).strip()) + take("log_level", lambda v: str(v).strip().upper()) + take("log_file", lambda v: str(v).strip()) + take("lock_file", lambda v: str(v).strip()) + take("dry_run", parse_bool) + take("task_timeout", lambda v: parse_duration(v, default_unit="s")) + take("run_on_start", parse_bool) + take("description", lambda v: str(v).strip()) + # Synonyme + if "description_template" in section: + result.description = str(section["description_template"]).strip() + if "testlauf" in section: + try: + result.dry_run = parse_bool(section["testlauf"]) + except ValueError as exc: + errors.append("[global] testlauf: %s" % exc) + + for key in section: + if key not in ("prefix", "check_interval", "state_file", "log_level", "log_file", + "lock_file", "dry_run", "task_timeout", "run_on_start", "description", + "description_template", "testlauf"): + errors.append("[global] unbekannter Schluessel: %s" % key) + return result + + +def _parse_group(name, section, errors): + group = Group(name=name) + prefix = "[group:%s]" % name + + def take(key, parser_fn, target=None): + if key not in section: + return + raw = section[key] + try: + setattr(group, target or key, parser_fn(raw)) + except ValueError as exc: + errors.append("%s %s: %s" % (prefix, key, exc)) + + take("enabled", parse_bool) + take("interval", lambda v: parse_duration(v, default_unit="m")) + take("schedule", lambda v: str(v).strip().lower()) + take("at", lambda v: "%02d:%02d" % parse_time_of_day(v)) + take("minute", lambda v: int(str(v).strip())) + take("day_of_week", parse_weekday) + take("day_of_month", lambda v: int(str(v).strip())) + take("month", lambda v: int(str(v).strip())) + take("align", parse_bool) + + take("keep_count", lambda v: max(0, int(str(v).strip()))) + take("keep_time", lambda v: parse_duration(v, default_unit="d")) + take("keep_min", lambda v: max(0, int(str(v).strip()))) + + take("all", parse_bool) + take("vmids", parse_vmid_list) + take("names", parse_list) + take("tags", lambda v: [t.lower() for t in parse_list(v)]) + take("pools", parse_list) + take("types", lambda v: [t.lower() for t in parse_list(v)]) + take("exclude_vmids", parse_vmid_list) + take("exclude_names", parse_list) + take("exclude_tags", lambda v: [t.lower() for t in parse_list(v)]) + + take("vmstate", parse_bool) + take("skip_stopped", parse_bool) + take("description", lambda v: str(v).strip()) + + # Bequemlichkeit: "schedule = 1h" wird als Intervall verstanden. + if group.schedule and group.schedule not in SCHEDULE_KINDS and not group.interval: + try: + group.interval = parse_duration(group.schedule, default_unit="m") + group.schedule = "" + except ValueError: + pass + + for key in section: + if key not in _GROUP_KEYS: + errors.append("%s unbekannter Schluessel: %s" % (prefix, key)) + + return group + + +# --------------------------------------------------------------------------- +# Schreiben (wird vom ncurses-Editor benutzt) +# --------------------------------------------------------------------------- + +_HEADER = """\ +# pvesnap - Konfiguration +# +# Diese Datei wurde vom Konfigurationseditor geschrieben ("pvesnap config"). +# Sie kann jederzeit auch von Hand bearbeitet werden. +# +# Zeitangaben: 30m, 1h, 6h, 2d12h, 1w ... (0 / "nie" = unbegrenzt) +# Nach Aenderungen: systemctl reload pvesnap +""" + + +def _group_to_lines(group, globals_): + lines = ["[group:%s]" % group.name] + lines.append("enabled = %s" % format_bool(group.enabled)) + if group.schedule: + lines.append("schedule = %s" % group.schedule) + if group.schedule == "hourly": + lines.append("minute = %d" % group.minute) + else: + lines.append("at = %s" % group.at) + if group.schedule == "weekly": + lines.append("day_of_week = %s" % WEEKDAY_NAMES[group.day_of_week].lower()) + if group.schedule in ("monthly", "yearly"): + lines.append("day_of_month = %d" % group.day_of_month) + if group.schedule == "yearly": + lines.append("month = %d" % group.month) + else: + lines.append("interval = %s" % format_duration(group.interval, zero="1h")) + lines.append("align = %s" % format_bool(group.align)) + + lines.append("keep_count = %d" % group.keep_count) + lines.append("keep_time = %s" % format_duration(group.keep_time, zero="0")) + if group.keep_min: + lines.append("keep_min = %d" % group.keep_min) + + if group.all: + lines.append("all = yes") + if group.vmids: + lines.append("vmids = %s" % format_vmid_list(group.vmids)) + if group.names: + lines.append("names = %s" % ", ".join(group.names)) + if group.tags: + lines.append("tags = %s" % ", ".join(group.tags)) + if group.pools: + lines.append("pools = %s" % ", ".join(group.pools)) + if group.types: + lines.append("types = %s" % ", ".join(group.types)) + if group.exclude_vmids: + lines.append("exclude_vmids = %s" % format_vmid_list(group.exclude_vmids)) + if group.exclude_names: + lines.append("exclude_names = %s" % ", ".join(group.exclude_names)) + if group.exclude_tags: + lines.append("exclude_tags = %s" % ", ".join(group.exclude_tags)) + + if group.vmstate: + lines.append("vmstate = yes") + if group.skip_stopped: + lines.append("skip_stopped = yes") + if group.description and group.description != globals_.description: + lines.append("description = %s" % group.description) + return lines + + +def dump_config(config): + """Erzeugt den kompletten INI-Text zu einer Config.""" + g = config.globals + lines = [_HEADER, "[global]"] + lines.append("prefix = %s" % g.prefix) + lines.append("check_interval = %s" % format_duration(g.check_interval, zero="60s")) + lines.append("state_file = %s" % g.state_file) + if g.lock_file != GlobalConfig.lock_file: + lines.append("lock_file = %s" % g.lock_file) + lines.append("log_level = %s" % g.log_level) + if g.log_file: + lines.append("log_file = %s" % g.log_file) + lines.append("task_timeout = %s" % format_duration(g.task_timeout, zero="900s")) + lines.append("run_on_start = %s" % format_bool(g.run_on_start)) + lines.append("dry_run = %s" % format_bool(g.dry_run)) + lines.append("description = %s" % g.description) + lines.append("") + + for group in config.groups: + lines.extend(_group_to_lines(group, g)) + lines.append("") + + return "\n".join(lines).rstrip() + "\n" + + +def save_config(config, path=None): + """Schreibt die Config atomar; legt vorher eine .bak-Kopie an.""" + target = path or config.path + directory = os.path.dirname(os.path.abspath(target)) + os.makedirs(directory, exist_ok=True) + + if os.path.exists(target): + try: + with open(target, "r", encoding="utf-8") as handle: + previous = handle.read() + with open(target + ".bak", "w", encoding="utf-8") as handle: + handle.write(previous) + except OSError: + pass # Backup ist Kuer, kein Muss + + tmp = target + ".tmp" + with open(tmp, "w", encoding="utf-8") as handle: + handle.write(dump_config(config)) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp, target) + return target + + +def clone_group(group, new_name): + return replace(group, name=new_name, + vmids=list(group.vmids), names=list(group.names), + tags=list(group.tags), pools=list(group.pools), + types=list(group.types), exclude_vmids=list(group.exclude_vmids), + exclude_names=list(group.exclude_names), + exclude_tags=list(group.exclude_tags)) diff --git a/pvesnap/daemon.py b/pvesnap/daemon.py new file mode 100644 index 0000000..3eff233 --- /dev/null +++ b/pvesnap/daemon.py @@ -0,0 +1,232 @@ +"""Der eigentliche Dienst: Zeitplan ueberwachen und Gruppen ausfuehren.""" + +from __future__ import annotations + +import errno +import fcntl +import logging +import os +import signal +import threading +import time +from datetime import datetime + +from .config import ConfigError, load_config +from .engine import run_group +from .proxmox import Proxmox, ProxmoxError +from .schedule import next_due +from .state import State + +log = logging.getLogger("pvesnap.daemon") + + +class SingleInstanceLock: + """Dateisperre. + + Zwei Sperren sind im Spiel: + * .daemon haelt der Dienst waehrend seiner gesamten Laufzeit, + damit er nicht doppelt startet. + * wird nur waehrend der eigentlichen Snapshot-Arbeit + gehalten - so kann man jederzeit auch von Hand `pvesnap run` aufrufen, + ohne dass sich die Laeufe in die Quere kommen. + """ + + def __init__(self, path, wait=0, busy_message=None): + self.path = path + self.wait = wait + self.busy_message = busy_message or "pvesnap laeuft bereits (Sperrdatei %s)" % path + self._handle = None + + def __enter__(self): + # Die Datei anzulegen muss sofort klappen - fehlende Rechte sind ein + # harter Fehler und kein "gerade belegt". + try: + os.makedirs(os.path.dirname(os.path.abspath(self.path)), exist_ok=True) + self._handle = open(self.path, "w") + except OSError as exc: + self._handle = None + raise RuntimeError("Sperrdatei %s nicht nutzbar: %s" % (self.path, exc)) + + deadline = time.monotonic() + max(0, self.wait) + while True: + try: + fcntl.flock(self._handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + self._handle.write("%d\n" % os.getpid()) + self._handle.flush() + return self + except OSError as exc: + busy = exc.errno in (errno.EAGAIN, errno.EWOULDBLOCK, errno.EACCES) + if not busy or time.monotonic() >= deadline: + self._handle.close() + self._handle = None + if busy: + raise RuntimeError(self.busy_message) + raise RuntimeError("Sperrdatei %s nicht nutzbar: %s" % (self.path, exc)) + time.sleep(1.0) + + def __exit__(self, *_exc): + if self._handle: + try: + fcntl.flock(self._handle, fcntl.LOCK_UN) + except OSError: + pass + self._handle.close() + self._handle = None + return False + + +class Daemon: + def __init__(self, config_path, dry_run=False): + self.config_path = config_path + self.dry_run_override = dry_run + self.config = None + self.state = None + self._stop = threading.Event() + self._reload = threading.Event() + self._wake = threading.Event() # bricht das Warten bei Signalen ab + + # -- Signale ---------------------------------------------------------- + + def install_signal_handlers(self): + signal.signal(signal.SIGTERM, self._on_stop) + signal.signal(signal.SIGINT, self._on_stop) + signal.signal(signal.SIGHUP, self._on_reload) + + def _on_stop(self, signum, _frame): + log.info("Signal %s empfangen - beende nach dem aktuellen Durchlauf", signum) + self._stop.set() + self._wake.set() + + def _on_reload(self, _signum, _frame): + log.info("SIGHUP empfangen - Konfiguration wird neu geladen") + self._reload.set() + self._wake.set() + + # -- Konfiguration ---------------------------------------------------- + + def load(self): + config = load_config(self.config_path) + problems = config.validate() + if problems: + raise ConfigError("Konfiguration fehlerhaft:\n - %s" % "\n - ".join(problems)) + if self.dry_run_override: + config.globals.dry_run = True + self.config = config + if self.state is None or self.state.path != config.globals.state_file: + self.state = State(config.globals.state_file).load() + return config + + def _reload_if_requested(self): + if not self._reload.is_set(): + return + self._reload.clear() + try: + self.load() + log.info("Konfiguration neu geladen: %d Gruppe(n)", len(self.config.groups)) + except ConfigError as exc: + log.error("Neuladen fehlgeschlagen, behalte alte Konfiguration: %s", exc) + + # -- Hauptschleife ---------------------------------------------------- + + def run(self): + self.load() + self.install_signal_handlers() + + globals_ = self.config.globals + log.info("pvesnap gestartet (%d Gruppe(n), Praefix '%s'%s)", + len(self.config.groups), globals_.prefix, + ", TESTLAUF" if globals_.dry_run else "") + + daemon_lock = SingleInstanceLock( + globals_.lock_file + ".daemon", + busy_message="Der pvesnap-Dienst laeuft bereits (%s.daemon)" % globals_.lock_file) + with daemon_lock: + while not self._stop.is_set(): + self._wake.clear() + self._reload_if_requested() + try: + self.tick() + except ProxmoxError as exc: + log.error("Proxmox nicht erreichbar: %s", exc) + except Exception: # pragma: no cover - Dienst darf nie sterben + log.exception("Unerwarteter Fehler im Durchlauf") + self._sleep() + + if self.state: + self.state.save() + log.info("pvesnap beendet") + + def tick(self, now=None): + """Einmal alle Gruppen pruefen und faellige ausfuehren.""" + now = now or datetime.now() + config = self.config + state = self.state + state.prune_unknown([g.name for g in config.groups]) + + due = [] + for group in config.groups: + if not group.enabled: + continue + last = state.last_run(group.name) + if last is None and not config.globals.run_on_start: + # Erster Start: nicht sofort feuern, sondern auf den naechsten + # regulaeren Termin warten. + state.record_run(group.name, now, created=0, deleted=0) + log.info("Gruppe '%s': erster Termin am %s", group.name, + next_due(group, now, now).strftime("%Y-%m-%d %H:%M:%S")) + continue + if last is None or next_due(group, last, now) <= now: + due.append(group) + + if not due: + state.save() + return [] + + proxmox = Proxmox(dry_run=config.globals.dry_run, + task_timeout=config.globals.task_timeout) + + results = [] + # Die Arbeitssperre wird nur waehrend des Laufs gehalten, damit + # `pvesnap run` von Hand weiterhin moeglich bleibt. + work_lock = SingleInstanceLock( + config.globals.lock_file, wait=300, + busy_message="Ein anderer pvesnap-Lauf ist noch aktiv - " + "dieser Durchlauf wird uebersprungen") + try: + with work_lock: + guests = proxmox.inventory(refresh=True) + for group in due: + if self._stop.is_set(): + break + result = run_group(proxmox, config, group, now=datetime.now(), + guests=guests) + state.record_run(group.name, datetime.now(), + created=len(result.created), + deleted=len(result.deleted), errors=result.errors) + results.append(result) + except RuntimeError as exc: + log.warning("%s", exc) + + state.save() + return results + + def _sleep(self): + """Bis zum naechsten Termin schlafen, hoechstens aber check_interval.""" + interval = self.config.globals.check_interval + now = datetime.now() + wait = float(interval) + for group in self.config.groups: + if not group.enabled: + continue + last = self.state.last_run(group.name) + if last is None: + continue + try: + remaining = (next_due(group, last, now) - now).total_seconds() + except ValueError: + continue + wait = min(wait, max(1.0, remaining)) + # Auf Signale reagieren wir sofort - das Event bricht das Warten ab. + # Geleert wird es am Anfang des Schleifendurchlaufs, damit ein Signal + # waehrend tick() nicht verloren geht. + self._wake.wait(max(1.0, min(wait, float(interval)))) diff --git a/pvesnap/engine.py b/pvesnap/engine.py new file mode 100644 index 0000000..40b0a43 --- /dev/null +++ b/pvesnap/engine.py @@ -0,0 +1,181 @@ +"""Kernlogik: Gaeste auswaehlen, Snapshots anlegen, alte Snapshots aufraeumen.""" + +from __future__ import annotations + +import fnmatch +import logging +from dataclasses import dataclass, field +from datetime import datetime + +from .naming import build_name, effective_slug, parse_name, render_description +from .proxmox import ProxmoxError + +log = logging.getLogger("pvesnap.engine") + + +# --------------------------------------------------------------------------- +# Auswahl der Gaeste +# --------------------------------------------------------------------------- + +def _matches_any_glob(text, patterns): + lowered = (text or "").lower() + return any(fnmatch.fnmatch(lowered, pattern.lower()) for pattern in patterns) + + +def select_guests(group, guests): + """Alle Gaeste, auf die die Auswahlregeln der Gruppe zutreffen.""" + wanted_vmids = set(group.vmids) + wanted_tags = set(group.tags) + wanted_pools = set(p.lower() for p in group.pools) + + excluded_vmids = set(group.exclude_vmids) + excluded_tags = set(group.exclude_tags) + + selected = [] + for guest in guests: + if group.types and guest.type not in group.types: + continue + + if group.all: + hit = True + else: + hit = (guest.vmid in wanted_vmids + or (group.names and _matches_any_glob(guest.name, group.names)) + or (wanted_tags and wanted_tags.intersection(guest.tags)) + or (wanted_pools and guest.pool.lower() in wanted_pools)) + if not hit: + continue + + if guest.vmid in excluded_vmids: + continue + if group.exclude_names and _matches_any_glob(guest.name, group.exclude_names): + continue + if excluded_tags and excluded_tags.intersection(guest.tags): + continue + + selected.append(guest) + return selected + + +# --------------------------------------------------------------------------- +# Vorhaltezeit +# --------------------------------------------------------------------------- + +def managed_snapshots(prefix, group, snapshots): + """Nur die Snapshots, die pvesnap fuer *diese* Gruppe angelegt hat.""" + slug = effective_slug(prefix, group.slug) + result = [] + for snap in snapshots: + parsed = parse_name(prefix, snap.name) + if not parsed or parsed["slug"] != slug: + continue + created = (datetime.fromtimestamp(snap.snaptime) if snap.snaptime + else parsed["created"]) + result.append((snap, created)) + result.sort(key=lambda item: item[1], reverse=True) # neueste zuerst + return result + + +def plan_prune(prefix, group, snapshots, now): + """Welche Snapshots sollen weg? Gibt eine Liste von (Snapshot, Grund) zurueck.""" + doomed = [] + for index, (snap, created) in enumerate(managed_snapshots(prefix, group, snapshots)): + if index < group.keep_min: + continue + age = (now - created).total_seconds() + if group.keep_count > 0 and index >= group.keep_count: + doomed.append((snap, "Anzahl > %d" % group.keep_count)) + elif group.keep_time > 0 and age > group.keep_time: + doomed.append((snap, "aelter als Vorhaltezeit")) + return doomed + + +# --------------------------------------------------------------------------- +# Ausfuehrung +# --------------------------------------------------------------------------- + +@dataclass +class GroupResult: + group: str + matched: int = 0 + created: list = field(default_factory=list) + deleted: list = field(default_factory=list) + skipped: list = field(default_factory=list) + errors: list = field(default_factory=list) + + @property + def ok(self): + return not self.errors + + def summary(self): + return ("Gruppe '%s': %d Gast/Gaeste, %d Snapshot(s) angelegt, " + "%d geloescht, %d Fehler" + % (self.group, self.matched, len(self.created), + len(self.deleted), len(self.errors))) + + +def run_group(proxmox, config, group, now=None, create=True, prune=True, guests=None): + """Legt fuer eine Gruppe Snapshots an und raeumt alte weg.""" + now = now or datetime.now() + prefix = config.globals.prefix + result = GroupResult(group=group.name) + + if guests is None: + guests = proxmox.inventory() + selected = select_guests(group, guests) + result.matched = len(selected) + + if not selected: + log.warning("Gruppe '%s': keine passenden Gaeste gefunden", group.name) + return result + + snapshot_name = build_name(prefix, group.slug, now) + template = group.description or config.globals.description + + for guest in selected: + if group.skip_stopped and not guest.running: + result.skipped.append("%s (gestoppt)" % guest.label) + log.info("Gruppe '%s': %s uebersprungen (gestoppt)", group.name, guest.label) + continue + + if create: + description = render_description(template, group, guest, now, prefix) + try: + proxmox.create_snapshot( + guest, snapshot_name, description, + vmstate=group.vmstate and guest.running, + ) + result.created.append("%s:%s" % (guest.vmid, snapshot_name)) + log.info("Gruppe '%s': Snapshot '%s' fuer %s angelegt", + group.name, snapshot_name, guest.label) + except ProxmoxError as exc: + message = "%s: Snapshot fehlgeschlagen: %s" % (guest.label, exc) + result.errors.append(message) + log.error("Gruppe '%s': %s", group.name, message) + continue # ohne neuen Snapshot nicht aufraeumen + + if prune: + try: + _prune_guest(proxmox, config, group, guest, now, result) + except ProxmoxError as exc: + message = "%s: Aufraeumen fehlgeschlagen: %s" % (guest.label, exc) + result.errors.append(message) + log.error("Gruppe '%s': %s", group.name, message) + + log.info(result.summary()) + return result + + +def _prune_guest(proxmox, config, group, guest, now, result): + prefix = config.globals.prefix + snapshots = proxmox.list_snapshots(guest) + for snap, reason in plan_prune(prefix, group, snapshots, now): + try: + proxmox.delete_snapshot(guest, snap.name) + result.deleted.append("%s:%s" % (guest.vmid, snap.name)) + log.info("Gruppe '%s': Snapshot '%s' von %s geloescht (%s)", + group.name, snap.name, guest.label, reason) + except ProxmoxError as exc: + message = "%s: '%s' nicht loeschbar: %s" % (guest.label, snap.name, exc) + result.errors.append(message) + log.error("Gruppe '%s': %s", group.name, message) diff --git a/pvesnap/naming.py b/pvesnap/naming.py new file mode 100644 index 0000000..486bc44 --- /dev/null +++ b/pvesnap/naming.py @@ -0,0 +1,88 @@ +"""Namensschema der von pvesnap verwalteten Snapshots. + +Ein Name sieht so aus: auto-taeglich-20260730-023000 + ^^^^ ^^^^^^^^ ^^^^^^^^ ^^^^^^ + | | Datum Uhrzeit + | Kurzname der Gruppe + Praefix aus [global] + +Nur Snapshots, die exakt auf dieses Muster passen, werden von pvesnap +angefasst. Von Hand angelegte Snapshots bleiben damit garantiert unberuehrt. +""" + +from __future__ import annotations + +import re +from datetime import datetime + +# Proxmox erlaubt fuer Snapshot-Namen nur begrenzte Laenge - wir bleiben +# bewusst deutlich darunter. +MAX_NAME_LEN = 40 +_STAMP_LEN = len("-20260730-023000") + + +def max_slug_len(prefix): + return max(1, MAX_NAME_LEN - len(prefix) - _STAMP_LEN) + + +def effective_slug(prefix, slug): + """Der Kurzname, wie er tatsaechlich im Snapshot-Namen landet.""" + return slug[: max_slug_len(prefix)] + + +def build_name(prefix, slug, moment): + return "%s-%s-%s" % (prefix, effective_slug(prefix, slug), + moment.strftime("%Y%m%d-%H%M%S")) + + +def name_pattern(prefix, slug=None): + part = re.escape(effective_slug(prefix, slug)) if slug else r"[A-Za-z0-9]+" + return re.compile(r"^%s-(%s)-(\d{8})-(\d{6})$" % (re.escape(prefix), part)) + + +def parse_name(prefix, name): + """Zerlegt einen Snapshot-Namen; gibt None zurueck, wenn er nicht zu uns gehoert.""" + match = name_pattern(prefix).match(str(name)) + if not match: + return None + slug, date_part, time_part = match.groups() + try: + created = datetime.strptime(date_part + time_part, "%Y%m%d%H%M%S") + except ValueError: + return None + return {"slug": slug, "created": created} + + +def render_description(template, group, guest, moment, prefix): + """Setzt die Platzhalter der Beschreibungs-Vorlage ein.""" + from .util import format_duration # lokal, um Zyklen zu vermeiden + + values = { + "prefix": prefix, + "group": group.name, + "group_slug": group.slug, + "vmid": guest.vmid, + "name": guest.name or "", + "type": "LXC" if guest.type == "lxc" else "VM", + "node": guest.node or "", + "pool": guest.pool or "", + "tags": ",".join(guest.tags), + "date": moment.strftime("%Y-%m-%d"), + "time": moment.strftime("%H:%M:%S"), + "datetime": moment.strftime("%Y-%m-%d %H:%M:%S"), + "timestamp": int(moment.timestamp()), + "keep_time": format_duration(group.keep_time), + "keep_count": group.keep_count if group.keep_count else "unbegrenzt", + "schedule": _schedule_text(group), + } + try: + text = str(template).format(**values) + except (KeyError, IndexError, ValueError): + # Unbekannter Platzhalter: lieber eine brauchbare Beschreibung als ein Abbruch. + text = "pvesnap | Gruppe: %s | erstellt: %s" % (group.name, values["datetime"]) + return text.strip()[:1024] + + +def _schedule_text(group): + from .schedule import describe + return describe(group) diff --git a/pvesnap/proxmox.py b/pvesnap/proxmox.py new file mode 100644 index 0000000..d977df3 --- /dev/null +++ b/pvesnap/proxmox.py @@ -0,0 +1,202 @@ +"""Duenne Schicht um `pvesh` - Inventar lesen, Snapshots anlegen/loeschen. + +Bewusst ueber `pvesh` statt `qm`/`pct`: damit funktioniert alles auch fuer +Gaeste, die auf einem anderen Node des Clusters laufen. +""" + +from __future__ import annotations + +import json +import logging +import shutil +import subprocess +import time +from dataclasses import dataclass, field + +log = logging.getLogger("pvesnap.proxmox") + +PVESH = "/usr/bin/pvesh" + + +class ProxmoxError(Exception): + """Ein pvesh-Aufruf ist fehlgeschlagen.""" + + +@dataclass +class Guest: + vmid: int + name: str = "" + type: str = "qemu" # qemu | lxc + node: str = "" + status: str = "unknown" + tags: list = field(default_factory=list) + pool: str = "" + + @property + def running(self): + return self.status == "running" + + @property + def label(self): + return "%s %d (%s)" % ("LXC" if self.type == "lxc" else "VM", + self.vmid, self.name or "?") + + +@dataclass +class Snapshot: + name: str + description: str = "" + snaptime: int = 0 + parent: str = "" + + +def pvesh_available(): + return shutil.which(PVESH) is not None or shutil.which("pvesh") is not None + + +def _pvesh_binary(): + return shutil.which(PVESH) or shutil.which("pvesh") or PVESH + + +class Proxmox: + """Zugriff auf die Proxmox-API ueber das Kommando `pvesh`.""" + + def __init__(self, dry_run=False, task_timeout=900, command_timeout=60): + self.dry_run = dry_run + self.task_timeout = task_timeout + self.command_timeout = command_timeout + self._inventory_cache = None + + # -- unterste Ebene --------------------------------------------------- + + def _run(self, args, timeout=None): + command = [_pvesh_binary()] + args + log.debug("pvesh: %s", " ".join(command)) + try: + proc = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + timeout=timeout or self.command_timeout) + except FileNotFoundError: + raise ProxmoxError("'pvesh' nicht gefunden - laeuft pvesnap wirklich " + "auf einem Proxmox-VE-Host?") + except subprocess.TimeoutExpired: + raise ProxmoxError("Zeitueberschreitung bei: %s" % " ".join(command)) + + stdout = proc.stdout.decode("utf-8", "replace").strip() + stderr = proc.stderr.decode("utf-8", "replace").strip() + if proc.returncode != 0: + raise ProxmoxError((stderr or stdout or "unbekannter Fehler").splitlines()[0]) + return stdout, stderr + + def _json(self, args): + stdout, _ = self._run(args + ["--output-format", "json"]) + if not stdout: + return None + try: + return json.loads(stdout) + except ValueError: + raise ProxmoxError("unerwartete Antwort von pvesh: %s" % stdout[:200]) + + # -- Inventar --------------------------------------------------------- + + def inventory(self, refresh=False): + """Alle VMs und Container des Clusters.""" + if self._inventory_cache is not None and not refresh: + return self._inventory_cache + + data = self._json(["get", "/cluster/resources", "--type", "vm"]) or [] + guests = [] + for entry in data: + if entry.get("type") not in ("qemu", "lxc"): + continue + raw_tags = entry.get("tags") or "" + tags = [t.strip().lower() for t in str(raw_tags).replace(",", ";").split(";") + if t.strip()] + guests.append(Guest( + vmid=int(entry.get("vmid")), + name=entry.get("name") or "", + type=entry.get("type"), + node=entry.get("node") or "", + status=entry.get("status") or "unknown", + tags=tags, + pool=entry.get("pool") or "", + )) + guests.sort(key=lambda g: g.vmid) + self._inventory_cache = guests + return guests + + @staticmethod + def local_node(): + import socket + return socket.gethostname().split(".")[0] + + # -- Snapshots -------------------------------------------------------- + + def _base_path(self, guest): + return "/nodes/%s/%s/%d/snapshot" % (guest.node, guest.type, guest.vmid) + + def list_snapshots(self, guest): + data = self._json(["get", self._base_path(guest)]) or [] + result = [] + for entry in data: + name = entry.get("name") + if not name or name == "current": + continue # "current" ist kein echter Snapshot + result.append(Snapshot( + name=name, + description=(entry.get("description") or "").strip(), + snaptime=int(entry.get("snaptime") or 0), + parent=entry.get("parent") or "", + )) + result.sort(key=lambda s: s.snaptime) + return result + + def create_snapshot(self, guest, name, description="", vmstate=False): + args = ["create", self._base_path(guest), "--snapname", name] + if description: + args += ["--description", description] + if vmstate and guest.type == "qemu": + args += ["--vmstate", "1"] + if self.dry_run: + log.info("[TESTLAUF] wuerde Snapshot anlegen: %s -> %s", guest.label, name) + return None + stdout, _ = self._run(args) + return self._wait_task(guest.node, stdout, "Snapshot %s fuer %s" % (name, guest.label)) + + def delete_snapshot(self, guest, name): + if self.dry_run: + log.info("[TESTLAUF] wuerde Snapshot loeschen: %s -> %s", guest.label, name) + return None + stdout, _ = self._run(["delete", "%s/%s" % (self._base_path(guest), name)]) + return self._wait_task(guest.node, stdout, + "Loeschen von %s bei %s" % (name, guest.label)) + + # -- Task-Verfolgung -------------------------------------------------- + + def _wait_task(self, node, output, what): + """pvesh liefert bei Snapshot-Aktionen eine UPID; darauf warten wir.""" + upid = self._extract_upid(output) + if not upid: + return None + + deadline = time.time() + self.task_timeout + delay = 0.5 + while True: + status = self._json(["get", "/nodes/%s/tasks/%s/status" % (node, upid)]) or {} + if status.get("status") == "stopped": + exit_status = status.get("exitstatus") or "unbekannt" + if exit_status != "OK": + raise ProxmoxError("%s fehlgeschlagen: %s" % (what, exit_status)) + return upid + if time.time() > deadline: + raise ProxmoxError("%s: Zeitueberschreitung nach %ds (Task laeuft weiter: %s)" + % (what, self.task_timeout, upid)) + time.sleep(delay) + delay = min(delay * 1.5, 5.0) + + @staticmethod + def _extract_upid(output): + for line in (output or "").splitlines(): + candidate = line.strip().strip('"') + if candidate.startswith("UPID:"): + return candidate + return None diff --git a/pvesnap/schedule.py b/pvesnap/schedule.py new file mode 100644 index 0000000..daf3515 --- /dev/null +++ b/pvesnap/schedule.py @@ -0,0 +1,133 @@ +"""Berechnung der naechsten Ausfuehrungszeit einer Gruppe (lokale Zeit).""" + +from __future__ import annotations + +import calendar +from datetime import datetime, timedelta + +from .config import WEEKDAY_NAMES, parse_time_of_day +from .util import format_duration + +_EPOCH = datetime(1970, 1, 1) + + +def _midnight(moment): + return moment.replace(hour=0, minute=0, second=0, microsecond=0) + + +def _aligned_after(moment, interval): + """Naechster an der Uhr ausgerichteter Zeitpunkt nach `moment`. + + Intervalle, die glatt in einen Tag passen (5m, 15m, 1h, 6h, 12h), werden an + Mitternacht verankert - 6h ergibt also 00:00, 06:00, 12:00, 18:00. + Alles andere wird an der Epoche verankert. + """ + if interval <= 86400 and 86400 % interval == 0: + anchor = _midnight(moment) + else: + anchor = _EPOCH + elapsed = (moment - anchor).total_seconds() + steps = int(elapsed // interval) + 1 + return anchor + timedelta(seconds=steps * interval) + + +def _at_time(day, hour, minute): + return day.replace(hour=hour, minute=minute, second=0, microsecond=0) + + +def _clamp_day(year, month, day): + return min(day, calendar.monthrange(year, month)[1]) + + +def next_due(group, last_run, now): + """Wann ist die Gruppe das naechste Mal faellig? + + `last_run` ist der letzte tatsaechliche Lauf (oder None). Liegt das + Ergebnis in der Vergangenheit, ist die Gruppe sofort faellig - so werden + Laeufe nachgeholt, die waehrend eines Neustarts ausgefallen sind. + """ + if group.interval > 0: + if last_run is None: + base = now + else: + base = last_run + if group.align: + return _aligned_after(base, group.interval) + return base + timedelta(seconds=group.interval) + + base = last_run if last_run is not None else now - timedelta(seconds=1) + kind = group.schedule + + if kind == "hourly": + minute = max(0, min(59, group.minute)) + candidate = base.replace(minute=minute, second=0, microsecond=0) + while candidate <= base: + candidate += timedelta(hours=1) + return candidate + + hour, minute = parse_time_of_day(group.at) + + if kind == "daily": + candidate = _at_time(base, hour, minute) + while candidate <= base: + candidate += timedelta(days=1) + return candidate + + if kind == "weekly": + candidate = _at_time(base, hour, minute) + shift = (group.day_of_week - candidate.weekday()) % 7 + candidate += timedelta(days=shift) + while candidate <= base: + candidate += timedelta(days=7) + return candidate + + if kind == "monthly": + year, month = base.year, base.month + for _ in range(60): + day = _clamp_day(year, month, group.day_of_month) + candidate = _at_time(datetime(year, month, day), hour, minute) + if candidate > base: + return candidate + month += 1 + if month > 12: + month, year = 1, year + 1 + raise ValueError("kein monatlicher Termin ermittelbar") + + if kind == "yearly": + year = base.year + month = max(1, min(12, group.month)) + for _ in range(5): + day = _clamp_day(year, month, group.day_of_month) + candidate = _at_time(datetime(year, month, day), hour, minute) + if candidate > base: + return candidate + year += 1 + raise ValueError("kein jaehrlicher Termin ermittelbar") + + raise ValueError("unbekannter Zeitplan: %r" % kind) + + +def is_due(group, last_run, now, run_on_start=False): + if not group.enabled: + return False + if last_run is None and run_on_start: + return True + return next_due(group, last_run, now) <= now + + +def describe(group): + """Zeitplan der Gruppe als kurzer, lesbarer Text.""" + if group.interval > 0: + text = "alle %s" % format_duration(group.interval) + return text + (" (an der Uhr ausgerichtet)" if group.align else "") + if group.schedule == "hourly": + return "stuendlich zur Minute %02d" % group.minute + if group.schedule == "daily": + return "taeglich um %s" % group.at + if group.schedule == "weekly": + return "woechentlich %s um %s" % (WEEKDAY_NAMES[group.day_of_week], group.at) + if group.schedule == "monthly": + return "monatlich am %d. um %s" % (group.day_of_month, group.at) + if group.schedule == "yearly": + return "jaehrlich am %d.%d. um %s" % (group.day_of_month, group.month, group.at) + return "kein Zeitplan" diff --git a/pvesnap/state.py b/pvesnap/state.py new file mode 100644 index 0000000..01482ed --- /dev/null +++ b/pvesnap/state.py @@ -0,0 +1,90 @@ +"""Persistenter Zustand: wann lief welche Gruppe zuletzt?""" + +from __future__ import annotations + +import json +import logging +import os +from datetime import datetime + +log = logging.getLogger("pvesnap.state") + +_TIME_FORMAT = "%Y-%m-%dT%H:%M:%S" + + +class State: + def __init__(self, path): + self.path = path + self.data = {"version": 1, "groups": {}} + self._dirty = False + + # -- Laden / Speichern ------------------------------------------------ + + def load(self): + if not os.path.exists(self.path): + return self + try: + with open(self.path, "r", encoding="utf-8") as handle: + data = json.load(handle) + if isinstance(data, dict) and isinstance(data.get("groups"), dict): + self.data = data + self.data.setdefault("version", 1) + except (OSError, ValueError) as exc: + log.warning("Zustandsdatei %s nicht lesbar (%s) - starte mit leerem Zustand", + self.path, exc) + return self + + def save(self, force=False): + if not self._dirty and not force: + return + directory = os.path.dirname(os.path.abspath(self.path)) + try: + os.makedirs(directory, exist_ok=True) + tmp = self.path + ".tmp" + with open(tmp, "w", encoding="utf-8") as handle: + json.dump(self.data, handle, indent=2, ensure_ascii=False) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp, self.path) + self._dirty = False + except OSError as exc: + log.error("Zustand konnte nicht gespeichert werden (%s): %s", self.path, exc) + + # -- Zugriff ---------------------------------------------------------- + + def _entry(self, group_name): + return self.data["groups"].setdefault(group_name, {}) + + def last_run(self, group_name): + raw = self._entry(group_name).get("last_run") + if not raw: + return None + try: + return datetime.strptime(raw, _TIME_FORMAT) + except ValueError: + return None + + def record_run(self, group_name, moment, created=0, deleted=0, errors=None): + entry = self._entry(group_name) + entry["last_run"] = moment.strftime(_TIME_FORMAT) + entry["created"] = created + entry["deleted"] = deleted + entry["errors"] = list(errors or []) + entry["status"] = "error" if errors else "ok" + entry["total_runs"] = int(entry.get("total_runs") or 0) + 1 + self._dirty = True + + def info(self, group_name): + return dict(self._entry(group_name)) + + def forget(self, group_name): + if group_name in self.data["groups"]: + del self.data["groups"][group_name] + self._dirty = True + + def prune_unknown(self, known_names): + known = set(known_names) + for name in [n for n in self.data["groups"] if n not in known]: + del self.data["groups"][name] + self._dirty = True diff --git a/pvesnap/tui.py b/pvesnap/tui.py new file mode 100644 index 0000000..acc71f5 --- /dev/null +++ b/pvesnap/tui.py @@ -0,0 +1,889 @@ +"""ncurses-Editor fuer die pvesnap-Konfiguration. + +Aufruf: pvesnap config +""" + +from __future__ import annotations + +import curses +import os + +from .config import (WEEKDAY_NAMES, Config, ConfigError, Group, clone_group, + load_config, parse_time_of_day, save_config) +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_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)"), +] + +C_HEADER, C_FOOTER, C_SEL, C_WARN, C_OK, C_DIM = 1, 2, 3, 4, 5, 6 + + +# --------------------------------------------------------------------------- +# Zeichen-Helfer +# --------------------------------------------------------------------------- + +def _put(win, y, x, text, attr=0): + """addstr, das bei zu kleinem Terminal nicht abstuerzt.""" + height, width = win.getmaxyx() + if y < 0 or y >= height or x >= width: + return + try: + win.addstr(y, x, str(text)[: max(0, width - x - 1)], attr) + except curses.error: + pass + + +def _fill(win, y, text, attr): + height, width = win.getmaxyx() + if y < 0 or y >= height: + return + try: + win.addstr(y, 0, str(text).ljust(width - 1)[: width - 1], attr) + except curses.error: + pass + + +def _read_key(win): + try: + return win.get_wch() + except curses.error: + return None + except KeyboardInterrupt: + return "\x1b" + + +def _is_enter(key): + return key in ("\n", "\r", curses.KEY_ENTER) + + +def _is_escape(key): + return key == "\x1b" + + +def _is_backspace(key): + return key in ("\x7f", "\b", "\x08", curses.KEY_BACKSPACE) + + +class Editor: + def __init__(self, path): + self.path = path + self.config = None + self.dirty = False + self._guests = None + self._inventory_error = "" + + # -- 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)) + _fill(win, height - 1, " " + keys, curses.color_pair(C_FOOTER)) + return height, width + + def _message(self, win, text, error=False): + height, _ = win.getmaxyx() + attr = curses.color_pair(C_WARN if error else C_OK) | curses.A_BOLD + _fill(win, height - 2, " " + str(text) + " [Taste druecken]", attr) + win.refresh() + _read_key(win) + + def _confirm(self, win, question): + height, _ = win.getmaxyx() + _fill(win, height - 2, " %s [j/n]" % question, + curses.color_pair(C_WARN) | curses.A_BOLD) + win.refresh() + while True: + key = _read_key(win) + if key in ("j", "J", "y", "Y"): + return True + if key in ("n", "N") or _is_escape(key): + return False + + def _prompt(self, win, label, initial=""): + """Einzeilige Eingabe am unteren Rand. None = abgebrochen.""" + height, width = win.getmaxyx() + buffer = list(str(initial)) + curses.curs_set(1) + try: + while True: + text = "".join(buffer) + prompt = " %s: " % label + visible = width - len(prompt) - 2 + shown = text[-visible:] if visible > 0 and len(text) > visible else text + _fill(win, height - 2, prompt + shown, curses.color_pair(C_SEL)) + try: + win.move(height - 2, min(width - 2, len(prompt) + len(shown))) + except curses.error: + pass + win.refresh() + + key = _read_key(win) + if key is None: + continue + if _is_enter(key): + return "".join(buffer).strip() + if _is_escape(key): + return None + if _is_backspace(key): + if buffer: + buffer.pop() + continue + if key == "\x15": # Strg-U + buffer = [] + continue + if isinstance(key, str) and key.isprintable(): + buffer.append(key) + finally: + curses.curs_set(0) + + def _choose(self, win, title, entries, current=0): + """Kleines Auswahlfenster. entries: Liste von (wert, beschriftung).""" + height, width = win.getmaxyx() + box_h = min(len(entries) + 4, height - 2) + box_w = min(max([len(title)] + [len(e[1]) for e in entries]) + 8, width - 2) + top = max(0, (height - box_h) // 2) + left = max(0, (width - box_w) // 2) + box = curses.newwin(box_h, box_w, top, left) + box.keypad(True) + index = max(0, min(current, len(entries) - 1)) + + while True: + box.erase() + box.box() + _put(box, 0, 2, " %s " % title, curses.A_BOLD) + for row, (_value, label) in enumerate(entries[: box_h - 4]): + attr = curses.color_pair(C_SEL) if row == index else 0 + _put(box, row + 2, 2, " " + label.ljust(box_w - 5), attr) + box.refresh() + + key = _read_key(box) + if key in (curses.KEY_UP, "k"): + index = (index - 1) % len(entries) + elif key in (curses.KEY_DOWN, "j"): + index = (index + 1) % len(entries) + elif _is_enter(key): + return entries[index][0] + elif _is_escape(key) or key == "q": + return None + + # -- Hauptschleife ---------------------------------------------------- + + def run(self, stdscr): + curses.curs_set(0) + if hasattr(curses, "set_escdelay"): + curses.set_escdelay(25) + try: + curses.start_color() + curses.use_default_colors() + curses.init_pair(C_HEADER, curses.COLOR_WHITE, curses.COLOR_BLUE) + curses.init_pair(C_FOOTER, curses.COLOR_BLACK, curses.COLOR_WHITE) + curses.init_pair(C_SEL, curses.COLOR_BLACK, curses.COLOR_CYAN) + curses.init_pair(C_WARN, curses.COLOR_WHITE, curses.COLOR_RED) + curses.init_pair(C_OK, curses.COLOR_BLACK, curses.COLOR_GREEN) + curses.init_pair(C_DIM, -1, -1) + except curses.error: + pass + 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))) + height, width = self._frame( + win, "Gruppen", + "Datei: %s" % self.path, + "Enter Bearbeiten | n Neu | c Kopieren | d Loeschen | Leer An/Aus | " + "g Global | v VMs | s Speichern | q Ende") + + _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 == "v": + self.screen_overview(win) + elif key == "p": + self._show_problems(win) + elif key == "s": + self._save(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): + 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 + self._message(win, "Gespeichert: %s (systemctl reload pvesnap nicht vergessen)" + % target) + except OSError as exc: + self._message(win, "Speichern fehlgeschlagen: %s" % exc, error=True) + + # -- 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)") + + # --- 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"), + ("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 == "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: 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 _fill_row(win, y, x, text, width, attr): + _put(win, y, x, str(text).ljust(max(0, width - x - 1))[: max(0, width - x - 1)], attr) + + +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 diff --git a/pvesnap/util.py b/pvesnap/util.py new file mode 100644 index 0000000..defe81f --- /dev/null +++ b/pvesnap/util.py @@ -0,0 +1,162 @@ +"""Kleine Hilfsfunktionen fuer pvesnap (Dauer-Parsing, Slugs, Listen).""" + +from __future__ import annotations + +import re +import unicodedata + +_DURATION_UNITS = { + "s": 1, "sec": 1, "secs": 1, "second": 1, "seconds": 1, + "sek": 1, "sekunde": 1, "sekunden": 1, + "m": 60, "min": 60, "mins": 60, "minute": 60, "minutes": 60, "minuten": 60, + "h": 3600, "hour": 3600, "hours": 3600, "std": 3600, "stunde": 3600, "stunden": 3600, + "d": 86400, "day": 86400, "days": 86400, "t": 86400, "tag": 86400, "tage": 86400, + "w": 604800, "week": 604800, "weeks": 604800, "woche": 604800, "wochen": 604800, + "mo": 2592000, "month": 2592000, "months": 2592000, "monat": 2592000, "monate": 2592000, + "y": 31536000, "year": 31536000, "years": 31536000, + "j": 31536000, "jahr": 31536000, "jahre": 31536000, +} + +_DURATION_TOKEN = re.compile(r"(\d+(?:[.,]\d+)?)\s*([a-zäöü]*)") + +_NEVER = {"0", "never", "nie", "unbegrenzt", "unlimited", "off", "aus", "keine", "-"} + + +def parse_duration(text, default_unit="s"): + """'2d12h' -> 216000. 0 / 'nie' / 'unbegrenzt' -> 0 (= keine Begrenzung).""" + if text is None: + raise ValueError("leere Zeitangabe") + s = str(text).strip().lower() + if not s: + raise ValueError("leere Zeitangabe") + if s in _NEVER: + return 0 + + total = 0.0 + pos = 0 + found = False + for match in _DURATION_TOKEN.finditer(s): + if match.start() != pos: + raise ValueError("ungueltige Zeitangabe: %r" % text) + pos = match.end() + value = float(match.group(1).replace(",", ".")) + unit = match.group(2) or default_unit + if unit not in _DURATION_UNITS: + raise ValueError("unbekannte Zeiteinheit %r in %r" % (unit, text)) + total += value * _DURATION_UNITS[unit] + found = True + + if not found or pos != len(s): + raise ValueError("ungueltige Zeitangabe: %r" % text) + return int(round(total)) + + +def format_duration(seconds, zero="unbegrenzt"): + """216000 -> '2d12h'. Ergebnis ist wieder von parse_duration lesbar.""" + seconds = int(seconds or 0) + if seconds <= 0: + return zero + parts = [] + for unit, size in (("w", 604800), ("d", 86400), ("h", 3600), ("m", 60), ("s", 1)): + if seconds >= size: + count, seconds = divmod(seconds, size) + parts.append("%d%s" % (count, unit)) + return "".join(parts) + + +def slugify(text, maxlen=20): + """Gruppenname -> kurzer, PVE-tauglicher Bezeichner (nur a-z0-9, beginnt mit Buchstabe).""" + # Umlaute vor der Normalisierung ersetzen - NFKD wuerde sie sonst zerlegen. + lowered = str(text).lower() + for umlaut, replacement in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss")): + lowered = lowered.replace(umlaut, replacement) + ascii_only = unicodedata.normalize("NFKD", lowered).encode("ascii", "ignore").decode("ascii") + slug = re.sub(r"[^a-z0-9]+", "", ascii_only) + if not slug: + slug = "grp" + if not slug[0].isalpha(): + slug = "g" + slug + return slug[:maxlen] + + +_TRUE = {"1", "yes", "y", "true", "on", "ja", "j", "an", "ein", "enabled", "aktiv"} +_FALSE = {"0", "no", "n", "false", "off", "nein", "aus", "disabled", "inaktiv", ""} + + +def parse_bool(text, default=None): + if isinstance(text, bool): + return text + value = str(text).strip().lower() + if value in _TRUE: + return True + if value in _FALSE: + return default if (value == "" and default is not None) else False + raise ValueError("ungueltiger Ja/Nein-Wert: %r" % text) + + +def format_bool(value): + return "yes" if value else "no" + + +def parse_list(text): + """Kommagetrennte (oder per Semikolon/Whitespace getrennte) Liste -> Liste von Strings.""" + if not text: + return [] + if isinstance(text, (list, tuple)): + items = list(text) + else: + items = re.split(r"[,;\s]+", str(text)) + return [item.strip() for item in items if item and item.strip()] + + +def parse_vmid_list(text): + """'100,101,105-108' -> [100, 101, 105, 106, 107, 108].""" + result = [] + for token in parse_list(text): + match = re.match(r"^(\d+)\s*-\s*(\d+)$", token) + if match: + start, end = int(match.group(1)), int(match.group(2)) + if end < start: + start, end = end, start + if end - start > 100000: + raise ValueError("VMID-Bereich zu gross: %r" % token) + result.extend(range(start, end + 1)) + elif token.isdigit(): + result.append(int(token)) + else: + raise ValueError("ungueltige VMID: %r" % token) + return sorted(set(result)) + + +def format_vmid_list(vmids): + """[100,101,102,105] -> '100-102,105' (Umkehrung von parse_vmid_list).""" + ids = sorted(set(int(v) for v in vmids)) + if not ids: + return "" + chunks = [] + start = previous = ids[0] + for vmid in ids[1:]: + if vmid == previous + 1: + previous = vmid + continue + chunks.append((start, previous)) + start = previous = vmid + chunks.append((start, previous)) + out = [] + for begin, end in chunks: + if end - begin >= 2: + out.append("%d-%d" % (begin, end)) + else: + out.extend(str(v) for v in range(begin, end + 1)) + return ",".join(out) + + +def truncate(text, width): + text = str(text) + if width <= 0: + return "" + if len(text) <= width: + return text + if width <= 1: + return text[:width] + return text[: width - 1] + "…" diff --git a/systemd/pvesnap.service b/systemd/pvesnap.service new file mode 100644 index 0000000..5c01efc --- /dev/null +++ b/systemd/pvesnap.service @@ -0,0 +1,32 @@ +[Unit] +Description=pvesnap - automatische Proxmox-Snapshots +Documentation=file:/usr/share/doc/pvesnap/README.md +After=network-online.target pve-cluster.service pvedaemon.service +Wants=pve-cluster.service pvedaemon.service + +[Service] +Type=simple +ExecStart=/usr/bin/python3 -m pvesnap --config /etc/pvesnap/pvesnap.conf daemon +ExecReload=/bin/kill -HUP $MAINPID +Environment=PYTHONPATH=/usr/lib/pvesnap +Environment=PYTHONUNBUFFERED=1 +Restart=on-failure +RestartSec=30 +TimeoutStopSec=300 + +# pvesh benoetigt root-Rechte. +User=root +StateDirectory=pvesnap +RuntimeDirectory=pvesnap + +# Moderate Absicherung - der Dienst muss auf pvesh und /etc/pve zugreifen. +NoNewPrivileges=yes +PrivateTmp=yes +ProtectHome=yes +ProtectSystem=full +ProtectKernelTunables=yes +ProtectControlGroups=yes +RestrictRealtime=yes + +[Install] +WantedBy=multi-user.target diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..f1a0e2c --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,91 @@ +#!/bin/bash +# --------------------------------------------------------------------- +# pvesnap - Deinstallation +# +# ./uninstall.sh Programm und Dienst entfernen, +# Konfiguration und Zustand bleiben erhalten +# ./uninstall.sh --purge zusaetzlich /etc/pvesnap und /var/lib/pvesnap +# entfernen +# +# Bereits angelegte Snapshots werden NIE angeruehrt. +# --------------------------------------------------------------------- +set -euo pipefail + +LIB_DIR="/usr/lib/pvesnap" +BIN="/usr/local/bin/pvesnap" +CONF_DIR="/etc/pvesnap" +STATE_DIR="/var/lib/pvesnap" +DOC_DIR="/usr/share/doc/pvesnap" +UNIT="/etc/systemd/system/pvesnap.service" + +PURGE=0 +YES=0 +for arg in "$@"; do + case "$arg" in + --purge) PURGE=1 ;; + -y|--yes) YES=1 ;; + -h|--help) sed -n '2,11p' "$0" | sed 's/^#[[:space:]]\{0,1\}//'; exit 0 ;; + *) echo "Unbekannte Option: $arg" >&2; exit 1 ;; + esac +done + +info() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m==>\033[0m %s\n' "$*" >&2; } +fail() { printf '\033[1;31m==>\033[0m %s\n' "$*" >&2; exit 1; } + +[ "$(id -u)" -eq 0 ] || fail "Bitte als root ausfuehren (sudo ./uninstall.sh)." + +if [ "$YES" -eq 0 ]; then + if [ "$PURGE" -eq 1 ]; then + printf 'pvesnap inklusive Konfiguration und Zustand entfernen? [j/N] ' + else + printf 'pvesnap entfernen (Konfiguration bleibt erhalten)? [j/N] ' + fi + read -r answer + case "$answer" in + j|J|y|Y) ;; + *) echo "Abgebrochen."; exit 0 ;; + esac +fi + +if systemctl list-unit-files 2>/dev/null | grep -q '^pvesnap\.service'; then + info "Stoppe und deaktiviere Dienst" + systemctl stop pvesnap.service 2>/dev/null || true + systemctl disable pvesnap.service 2>/dev/null || true +fi + +if [ -f "$UNIT" ]; then + info "Entferne $UNIT" + rm -f "$UNIT" +fi +systemctl daemon-reload 2>/dev/null || true +systemctl reset-failed pvesnap.service 2>/dev/null || true + +if [ -e "$BIN" ]; then + info "Entferne $BIN" + rm -f "$BIN" +fi +if [ -d "$LIB_DIR" ]; then + info "Entferne $LIB_DIR" + rm -rf "$LIB_DIR" +fi +rm -rf "$DOC_DIR" +rm -f /run/pvesnap.lock /run/pvesnap.lock.daemon + +if [ "$PURGE" -eq 1 ]; then + info "Entferne Konfiguration und Zustand" + rm -rf "$CONF_DIR" "$STATE_DIR" +else + warn "Behalten: $CONF_DIR und $STATE_DIR (mit --purge ebenfalls entfernen)" +fi + +cat < / qm delsnapshot + pct listsnapshot / pct delsnapshot +EOF