first commit
This commit is contained in:
@@ -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 <dieses-repo> 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
|
||||
```
|
||||
@@ -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>-<gruppe>-<JJJJMMTT>-<HHMMSS>
|
||||
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}
|
||||
Executable
+133
@@ -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}" <<EOF
|
||||
#!/bin/sh
|
||||
# von install.sh erzeugt
|
||||
PYTHONPATH="${LIB_DIR}\${PYTHONPATH:+:\$PYTHONPATH}" exec /usr/bin/python3 -m pvesnap "\$@"
|
||||
EOF
|
||||
chmod 0755 "${BIN}"
|
||||
|
||||
# --- Konfiguration ----------------------------------------------------
|
||||
install -d -m 0755 "${CONF_DIR}" "${STATE_DIR}" "${DOC_DIR}"
|
||||
if [ -f "${CONF}" ]; then
|
||||
info "Vorhandene Konfiguration bleibt unveraendert: ${CONF}"
|
||||
install -m 0644 "${SRC}/config/pvesnap.conf.example" "${CONF_DIR}/pvesnap.conf.example"
|
||||
else
|
||||
info "Lege Beispielkonfiguration an: ${CONF}"
|
||||
install -m 0640 "${SRC}/config/pvesnap.conf.example" "${CONF}"
|
||||
install -m 0644 "${SRC}/config/pvesnap.conf.example" "${CONF_DIR}/pvesnap.conf.example"
|
||||
NEW_CONFIG=1
|
||||
fi
|
||||
if [ -f "${SRC}/README.md" ]; then
|
||||
install -m 0644 "${SRC}/README.md" "${DOC_DIR}/README.md"
|
||||
fi
|
||||
|
||||
# --- systemd ----------------------------------------------------------
|
||||
info "Installiere systemd-Unit ${UNIT}"
|
||||
install -m 0644 "${SRC}/systemd/pvesnap.service" "${UNIT}"
|
||||
systemctl daemon-reload
|
||||
|
||||
info "Pruefe Konfiguration"
|
||||
if ! "${BIN}" --config "${CONF}" check; then
|
||||
warn "Die Konfiguration ist noch nicht vollstaendig - Dienst wird nicht gestartet."
|
||||
START=0
|
||||
fi
|
||||
|
||||
if systemctl enable pvesnap.service >/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 <<EOF
|
||||
|
||||
Fertig.
|
||||
|
||||
Konfiguration bearbeiten : pvesnap config (ncurses-Editor)
|
||||
nano ${CONF}
|
||||
Konfiguration pruefen : pvesnap check
|
||||
Uebersicht : pvesnap status
|
||||
VMs und Gruppen : pvesnap vms
|
||||
Testlauf ohne Aenderung : pvesnap run --force --dry-run
|
||||
Nach Aenderungen : systemctl reload pvesnap
|
||||
Protokoll : journalctl -u pvesnap -f
|
||||
EOF
|
||||
|
||||
if [ "${NEW_CONFIG:-0}" -eq 1 ]; then
|
||||
cat <<EOF
|
||||
|
||||
Hinweis: In der Beispielkonfiguration sind alle Gruppen mit "enabled = no"
|
||||
angelegt. Erst nach dem Aktivieren einer Gruppe werden Snapshots erstellt.
|
||||
EOF
|
||||
fi
|
||||
@@ -0,0 +1,8 @@
|
||||
"""pvesnap - automatische Snapshots fuer Proxmox VE.
|
||||
|
||||
Gruppenbasierte Zeitplanung und Vorhaltezeiten, konfiguriert ueber eine
|
||||
INI-Datei, betrieben als systemd-Dienst.
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__all__ = ["__version__"]
|
||||
@@ -0,0 +1,6 @@
|
||||
import sys
|
||||
|
||||
from .cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+430
@@ -0,0 +1,430 @@
|
||||
"""Kommandozeile von pvesnap."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
from . import __version__
|
||||
from .config import DEFAULT_CONFIG_PATH, ConfigError, load_config
|
||||
from .daemon import Daemon, SingleInstanceLock
|
||||
from .engine import run_group, select_guests
|
||||
from .naming import parse_name
|
||||
from .proxmox import Proxmox, ProxmoxError, pvesh_available
|
||||
from .schedule import describe, next_due
|
||||
from .state import State
|
||||
from .util import format_duration, truncate
|
||||
|
||||
log = logging.getLogger("pvesnap")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hilfsfunktionen
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def setup_logging(level, log_file=""):
|
||||
root = logging.getLogger("pvesnap")
|
||||
root.setLevel(getattr(logging, str(level).upper(), logging.INFO))
|
||||
for handler in list(root.handlers):
|
||||
root.removeHandler(handler)
|
||||
|
||||
# Unter systemd steht der Zeitstempel schon im Journal.
|
||||
plain = os.environ.get("JOURNAL_STREAM") or os.environ.get("INVOCATION_ID")
|
||||
fmt = "%(levelname)s %(message)s" if plain else "%(asctime)s %(levelname)s %(message)s"
|
||||
|
||||
stream = logging.StreamHandler(sys.stderr)
|
||||
stream.setFormatter(logging.Formatter(fmt, datefmt="%Y-%m-%d %H:%M:%S"))
|
||||
root.addHandler(stream)
|
||||
|
||||
if log_file:
|
||||
try:
|
||||
handler = logging.FileHandler(log_file, encoding="utf-8")
|
||||
handler.setFormatter(logging.Formatter(
|
||||
"%(asctime)s %(levelname)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S"))
|
||||
root.addHandler(handler)
|
||||
except OSError as exc:
|
||||
root.warning("Logdatei %s nicht beschreibbar: %s", log_file, exc)
|
||||
|
||||
|
||||
def _load(args):
|
||||
config = load_config(args.config)
|
||||
problems = config.validate()
|
||||
if problems:
|
||||
raise ConfigError("Konfiguration fehlerhaft:\n - %s" % "\n - ".join(problems))
|
||||
if args.dry_run:
|
||||
config.globals.dry_run = True
|
||||
setup_logging("DEBUG" if args.verbose else config.globals.log_level,
|
||||
config.globals.log_file)
|
||||
return config
|
||||
|
||||
|
||||
def _table(rows, headers):
|
||||
if not rows:
|
||||
return ""
|
||||
widths = [len(h) for h in headers]
|
||||
for row in rows:
|
||||
for index, cell in enumerate(row):
|
||||
widths[index] = max(widths[index], len(str(cell)))
|
||||
lines = [" ".join(h.ljust(widths[i]) for i, h in enumerate(headers)).rstrip()]
|
||||
lines.append(" ".join("-" * widths[i] for i in range(len(headers))))
|
||||
for row in rows:
|
||||
lines.append(" ".join(str(cell).ljust(widths[i])
|
||||
for i, cell in enumerate(row)).rstrip())
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _age(moment, now):
|
||||
seconds = int((now - moment).total_seconds())
|
||||
if seconds < 0:
|
||||
return "in " + format_duration(-seconds, zero="0s")
|
||||
return format_duration(seconds, zero="0s")
|
||||
|
||||
|
||||
def _require_pve():
|
||||
if not pvesh_available():
|
||||
raise ProxmoxError("'pvesh' nicht gefunden - dieser Befehl muss auf einem "
|
||||
"Proxmox-VE-Host ausgefuehrt werden.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Befehle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cmd_daemon(args):
|
||||
config = _load(args)
|
||||
daemon = Daemon(args.config, dry_run=args.dry_run)
|
||||
daemon.config = config
|
||||
daemon.run()
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_run(args):
|
||||
config = _load(args)
|
||||
_require_pve()
|
||||
state = State(config.globals.state_file).load()
|
||||
now = datetime.now()
|
||||
|
||||
if args.group:
|
||||
wanted = []
|
||||
for name in args.group:
|
||||
group = config.group(name)
|
||||
if group is None:
|
||||
print("Unbekannte Gruppe: %s" % name, file=sys.stderr)
|
||||
return 2
|
||||
wanted.append(group)
|
||||
else:
|
||||
wanted = [g for g in config.groups if g.enabled]
|
||||
|
||||
if not args.force:
|
||||
due = []
|
||||
for group in wanted:
|
||||
last = state.last_run(group.name)
|
||||
if last is None or next_due(group, last, now) <= now:
|
||||
due.append(group)
|
||||
wanted = due
|
||||
|
||||
if not wanted:
|
||||
print("Zurzeit ist keine Gruppe faellig. (--force erzwingt den Lauf)")
|
||||
return 0
|
||||
|
||||
proxmox = Proxmox(dry_run=config.globals.dry_run,
|
||||
task_timeout=config.globals.task_timeout)
|
||||
guests = proxmox.inventory()
|
||||
|
||||
failed = False
|
||||
with SingleInstanceLock(config.globals.lock_file, wait=600,
|
||||
busy_message="Ein anderer pvesnap-Lauf ist gerade aktiv. "
|
||||
"Bitte spaeter erneut versuchen."):
|
||||
for group in wanted:
|
||||
result = run_group(proxmox, config, group, now=datetime.now(),
|
||||
prune=not args.no_prune, guests=guests)
|
||||
if not config.globals.dry_run:
|
||||
state.record_run(group.name, datetime.now(),
|
||||
created=len(result.created),
|
||||
deleted=len(result.deleted), errors=result.errors)
|
||||
print(result.summary())
|
||||
failed = failed or bool(result.errors)
|
||||
state.save()
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
def cmd_prune(args):
|
||||
config = _load(args)
|
||||
_require_pve()
|
||||
groups = []
|
||||
for name in (args.group or [g.name for g in config.groups]):
|
||||
group = config.group(name)
|
||||
if group is None:
|
||||
print("Unbekannte Gruppe: %s" % name, file=sys.stderr)
|
||||
return 2
|
||||
groups.append(group)
|
||||
|
||||
proxmox = Proxmox(dry_run=config.globals.dry_run,
|
||||
task_timeout=config.globals.task_timeout)
|
||||
guests = proxmox.inventory()
|
||||
failed = False
|
||||
with SingleInstanceLock(config.globals.lock_file, wait=600,
|
||||
busy_message="Ein anderer pvesnap-Lauf ist gerade aktiv. "
|
||||
"Bitte spaeter erneut versuchen."):
|
||||
for group in groups:
|
||||
result = run_group(proxmox, config, group, now=datetime.now(),
|
||||
create=False, prune=True, guests=guests)
|
||||
print(result.summary())
|
||||
failed = failed or bool(result.errors)
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
def cmd_status(args):
|
||||
config = _load(args)
|
||||
state = State(config.globals.state_file).load()
|
||||
now = datetime.now()
|
||||
|
||||
guests = None
|
||||
if pvesh_available():
|
||||
try:
|
||||
guests = Proxmox().inventory()
|
||||
except ProxmoxError as exc:
|
||||
print("Hinweis: Inventar nicht lesbar (%s)\n" % exc, file=sys.stderr)
|
||||
|
||||
print("pvesnap %s - Konfiguration: %s" % (__version__, config.path))
|
||||
print("Praefix: %s Zustand: %s%s\n"
|
||||
% (config.globals.prefix, config.globals.state_file,
|
||||
" [TESTLAUF aktiv]" if config.globals.dry_run else ""))
|
||||
|
||||
rows = []
|
||||
for group in config.groups:
|
||||
last = state.last_run(group.name)
|
||||
info = state.info(group.name)
|
||||
if group.enabled:
|
||||
try:
|
||||
upcoming = next_due(group, last, now)
|
||||
next_text = upcoming.strftime("%d.%m. %H:%M")
|
||||
if upcoming <= now:
|
||||
next_text = "faellig"
|
||||
except ValueError:
|
||||
next_text = "?"
|
||||
else:
|
||||
next_text = "-"
|
||||
|
||||
matched = "?" if guests is None else str(len(select_guests(group, guests)))
|
||||
rows.append([
|
||||
group.name,
|
||||
"ja" if group.enabled else "nein",
|
||||
describe(group),
|
||||
"%s / %s" % (group.keep_count or "unbegr.", format_duration(group.keep_time)),
|
||||
matched,
|
||||
last.strftime("%d.%m. %H:%M") if last else "nie",
|
||||
next_text,
|
||||
info.get("status", "-"),
|
||||
])
|
||||
|
||||
if rows:
|
||||
print(_table(rows, ["Gruppe", "Aktiv", "Zeitplan", "Behalte (Anz./Zeit)",
|
||||
"VMs", "Letzter Lauf", "Naechster", "Status"]))
|
||||
else:
|
||||
print("Keine Gruppen konfiguriert.")
|
||||
|
||||
errors = [(name, info.get("errors") or [])
|
||||
for name, info in state.data.get("groups", {}).items()]
|
||||
errors = [(name, msgs) for name, msgs in errors if msgs]
|
||||
if errors:
|
||||
print("\nLetzte Fehler:")
|
||||
for name, messages in errors:
|
||||
for message in messages:
|
||||
print(" [%s] %s" % (name, message))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_list(args):
|
||||
config = _load(args)
|
||||
_require_pve()
|
||||
proxmox = Proxmox()
|
||||
guests = proxmox.inventory()
|
||||
now = datetime.now()
|
||||
prefix = config.globals.prefix
|
||||
|
||||
if args.group:
|
||||
groups = []
|
||||
for name in args.group:
|
||||
group = config.group(name)
|
||||
if group is None:
|
||||
print("Unbekannte Gruppe: %s" % name, file=sys.stderr)
|
||||
return 2
|
||||
groups.append(group)
|
||||
else:
|
||||
groups = config.groups
|
||||
|
||||
interesting = {}
|
||||
for group in groups:
|
||||
for guest in select_guests(group, guests):
|
||||
interesting[guest.vmid] = guest
|
||||
|
||||
rows = []
|
||||
for vmid in sorted(interesting):
|
||||
guest = interesting[vmid]
|
||||
try:
|
||||
snapshots = proxmox.list_snapshots(guest)
|
||||
except ProxmoxError as exc:
|
||||
print("%s: %s" % (guest.label, exc), file=sys.stderr)
|
||||
continue
|
||||
for snap in sorted(snapshots, key=lambda s: s.snaptime, reverse=True):
|
||||
parsed = parse_name(prefix, snap.name)
|
||||
if parsed is None and not args.all:
|
||||
continue
|
||||
created = (datetime.fromtimestamp(snap.snaptime) if snap.snaptime
|
||||
else (parsed or {}).get("created"))
|
||||
rows.append([
|
||||
guest.vmid,
|
||||
truncate(guest.name, 20),
|
||||
snap.name,
|
||||
(parsed or {}).get("slug", "-"),
|
||||
created.strftime("%d.%m.%y %H:%M") if created else "?",
|
||||
_age(created, now) if created else "?",
|
||||
truncate(snap.description.replace("\n", " "), 44),
|
||||
])
|
||||
|
||||
if rows:
|
||||
print(_table(rows, ["VMID", "Name", "Snapshot", "Gruppe", "Erstellt",
|
||||
"Alter", "Beschreibung"]))
|
||||
print("\n%d Snapshot(s)%s" % (len(rows), "" if args.all else " von pvesnap"))
|
||||
else:
|
||||
print("Keine passenden Snapshots gefunden.")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_vms(args):
|
||||
config = _load(args)
|
||||
_require_pve()
|
||||
guests = Proxmox().inventory()
|
||||
assignment = {}
|
||||
for group in config.groups:
|
||||
for guest in select_guests(group, guests):
|
||||
assignment.setdefault(guest.vmid, []).append(group.name)
|
||||
|
||||
rows = [[guest.vmid,
|
||||
"LXC" if guest.type == "lxc" else "VM",
|
||||
truncate(guest.name, 28),
|
||||
guest.node,
|
||||
guest.status,
|
||||
",".join(guest.tags) or "-",
|
||||
guest.pool or "-",
|
||||
", ".join(assignment.get(guest.vmid, [])) or "-"]
|
||||
for guest in guests]
|
||||
if rows:
|
||||
print(_table(rows, ["VMID", "Typ", "Name", "Node", "Status", "Tags",
|
||||
"Pool", "Gruppen"]))
|
||||
else:
|
||||
print("Keine VMs/Container gefunden.")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_check(args):
|
||||
try:
|
||||
config = load_config(args.config)
|
||||
except ConfigError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
problems = config.validate()
|
||||
if problems:
|
||||
print("Konfiguration fehlerhaft:", file=sys.stderr)
|
||||
for problem in problems:
|
||||
print(" - %s" % problem, file=sys.stderr)
|
||||
return 1
|
||||
print("Konfiguration in Ordnung: %d Gruppe(n)." % len(config.groups))
|
||||
for group in config.groups:
|
||||
print(" - %s: %s, behalte %s / %s%s"
|
||||
% (group.name, describe(group),
|
||||
group.keep_count or "unbegrenzt", format_duration(group.keep_time),
|
||||
"" if group.enabled else " [deaktiviert]"))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_config(args):
|
||||
from .tui import run_editor
|
||||
return run_editor(args.config)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Argumente
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_parser():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="pvesnap",
|
||||
description="Automatische Proxmox-Snapshots mit Gruppen, Zeitplan und Vorhaltezeit.")
|
||||
parser.add_argument("-c", "--config", default=os.environ.get("PVESNAP_CONFIG",
|
||||
DEFAULT_CONFIG_PATH),
|
||||
help="Pfad zur INI-Datei (Vorgabe: %(default)s)")
|
||||
parser.add_argument("-n", "--dry-run", action="store_true",
|
||||
help="nichts wirklich anlegen oder loeschen, nur anzeigen")
|
||||
parser.add_argument("-v", "--verbose", action="store_true", help="ausfuehrliche Ausgabe")
|
||||
parser.add_argument("-V", "--version", action="version",
|
||||
version="pvesnap %s" % __version__)
|
||||
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
|
||||
p_daemon = sub.add_parser("daemon", help="Dienst im Vordergrund starten (fuer systemd)")
|
||||
p_daemon.set_defaults(func=cmd_daemon)
|
||||
|
||||
p_run = sub.add_parser("run", help="faellige Gruppen jetzt ausfuehren")
|
||||
p_run.add_argument("-g", "--group", action="append", help="nur diese Gruppe (mehrfach moeglich)")
|
||||
p_run.add_argument("-f", "--force", action="store_true",
|
||||
help="unabhaengig vom Zeitplan ausfuehren")
|
||||
p_run.add_argument("--no-prune", action="store_true", help="nicht aufraeumen")
|
||||
p_run.set_defaults(func=cmd_run)
|
||||
|
||||
p_prune = sub.add_parser("prune", help="nur alte Snapshots aufraeumen")
|
||||
p_prune.add_argument("-g", "--group", action="append", help="nur diese Gruppe")
|
||||
p_prune.set_defaults(func=cmd_prune)
|
||||
|
||||
p_status = sub.add_parser("status", help="Uebersicht ueber Gruppen und Termine")
|
||||
p_status.set_defaults(func=cmd_status)
|
||||
|
||||
p_list = sub.add_parser("list", help="vorhandene Snapshots anzeigen")
|
||||
p_list.add_argument("-g", "--group", action="append", help="nur diese Gruppe")
|
||||
p_list.add_argument("-a", "--all", action="store_true",
|
||||
help="auch fremde/manuelle Snapshots anzeigen")
|
||||
p_list.set_defaults(func=cmd_list)
|
||||
|
||||
p_vms = sub.add_parser("vms", help="alle VMs/Container und ihre Gruppen anzeigen")
|
||||
p_vms.set_defaults(func=cmd_vms)
|
||||
|
||||
p_check = sub.add_parser("check", help="Konfiguration pruefen")
|
||||
p_check.set_defaults(func=cmd_check)
|
||||
|
||||
for name in ("config", "edit"):
|
||||
p_config = sub.add_parser(name, help="Konfiguration im ncurses-Editor bearbeiten")
|
||||
p_config.set_defaults(func=cmd_config)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
if not getattr(args, "func", None):
|
||||
args.command = "status"
|
||||
args.func = cmd_status
|
||||
|
||||
setup_logging("DEBUG" if args.verbose else "INFO")
|
||||
try:
|
||||
return args.func(args)
|
||||
except ConfigError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 2
|
||||
except ProxmoxError as exc:
|
||||
print("Proxmox-Fehler: %s" % exc, file=sys.stderr)
|
||||
return 3
|
||||
except RuntimeError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 4
|
||||
except KeyboardInterrupt:
|
||||
print("\nAbgebrochen.", file=sys.stderr)
|
||||
return 130
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,501 @@
|
||||
"""Einlesen und Schreiben der pvesnap-INI-Datei."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import configparser
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field, replace
|
||||
|
||||
from .util import (format_bool, format_duration, format_vmid_list, parse_bool,
|
||||
parse_duration, parse_list, parse_vmid_list, slugify)
|
||||
|
||||
DEFAULT_CONFIG_PATH = "/etc/pvesnap/pvesnap.conf"
|
||||
|
||||
GROUP_SECTION_RE = re.compile(r"^(?:group|gruppe)\s*[:\.]\s*(.+)$", re.IGNORECASE)
|
||||
|
||||
SCHEDULE_KINDS = ("hourly", "daily", "weekly", "monthly", "yearly")
|
||||
|
||||
WEEKDAYS = {
|
||||
"mon": 0, "monday": 0, "mo": 0, "montag": 0,
|
||||
"tue": 1, "tuesday": 1, "di": 1, "dienstag": 1, "tues": 1,
|
||||
"wed": 2, "wednesday": 2, "mi": 2, "mittwoch": 2,
|
||||
"thu": 3, "thursday": 3, "do": 3, "donnerstag": 3, "thur": 3, "thurs": 3,
|
||||
"fri": 4, "friday": 4, "fr": 4, "freitag": 4,
|
||||
"sat": 5, "saturday": 5, "sa": 5, "samstag": 5,
|
||||
"sun": 6, "sunday": 6, "so": 6, "sonntag": 6,
|
||||
}
|
||||
|
||||
WEEKDAY_NAMES = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
|
||||
|
||||
DEFAULT_DESCRIPTION = (
|
||||
"pvesnap | Gruppe: {group} | erstellt: {datetime} | "
|
||||
"Vorhaltezeit: {keep_time} | max: {keep_count}"
|
||||
)
|
||||
|
||||
|
||||
class ConfigError(Exception):
|
||||
"""Fehler beim Lesen oder Pruefen der Konfiguration."""
|
||||
|
||||
|
||||
def parse_weekday(text):
|
||||
value = str(text).strip().lower()
|
||||
if value in WEEKDAYS:
|
||||
return WEEKDAYS[value]
|
||||
if value.isdigit():
|
||||
number = int(value)
|
||||
if 0 <= number <= 6:
|
||||
return number
|
||||
if number == 7:
|
||||
return 6
|
||||
raise ValueError("ungueltiger Wochentag: %r" % text)
|
||||
|
||||
|
||||
def parse_time_of_day(text):
|
||||
"""'02:30' -> (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))
|
||||
@@ -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:
|
||||
* <lock_file>.daemon haelt der Dienst waehrend seiner gesamten Laufzeit,
|
||||
damit er nicht doppelt startet.
|
||||
* <lock_file> 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))))
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
+889
@@ -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
|
||||
+162
@@ -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] + "…"
|
||||
@@ -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
|
||||
Executable
+91
@@ -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 <<EOF
|
||||
|
||||
pvesnap wurde entfernt.
|
||||
|
||||
Bereits vorhandene Snapshots bleiben bestehen. Auflisten und loeschen lassen
|
||||
sie sich weiterhin in der Proxmox-Oberflaeche oder mit:
|
||||
|
||||
qm listsnapshot <VMID> / qm delsnapshot <VMID> <NAME>
|
||||
pct listsnapshot <VMID> / pct delsnapshot <VMID> <NAME>
|
||||
EOF
|
||||
Reference in New Issue
Block a user