Handbuch auf MkDocs-Basis, mit Bildschirmfotos aus dem Programm

26 Kapitel in vier Teilen, in der Reihenfolge, in der man sie braucht:
erst sichern, dann Dateien holen, dann ganze Maschinen wiederherstellen,
dahinter der Nachschlagteil.

Gebaut wird mit MkDocs + Material. Bewusst ohne Netzabhaengigkeiten:
keine Schriften vom CDN (font: false), Volltextsuche mit deutschem
Stemming liegt neben den Seiten. Im Notfall steht vielleicht das halbe
Netz - dann nuetzt eine Doku im Internet nichts.

  handbuch/bauen.sh              baut handbuch/site/
  handbuch/bauen.sh ansehen      Vorschau auf 127.0.0.1:8000

install.sh nimmt das gebaute Handbuch mit nach
/usr/share/doc/pvesnap/handbuch/ - falls es vorliegt. Auf dem Host selbst
wird nichts gebaut, mkdocs gehoert nicht auf einen Hypervisor.

Die 28 Bildschirmfotos sind nicht abfotografiert, sondern erzeugt: Der
echte Programmcode laeuft in einem Pseudo-Terminal gegen einen erfundenen
Proxmox-Host (Attrappen fuer pvesh, perl und rbd), pyte baut den Bildschirm
nach, heraus faellt ein SVG. Damit stimmen sie garantiert mit dem Programm
ueberein, sind reproduzierbar und enthalten keine echten Daten. Die
Werkstatt dafuer liegt unter handbuch/werkstatt/ samt LIESMICH.md.

Nebenbei: die Schlussmeldung von install.sh warb noch mit --exchange,
das mit dem eingebauten Austauschlaufwerk weggefallen ist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
duffyduck
2026-08-09 12:39:30 +02:00
co-authored by Claude Opus 5
parent c484596702
commit 59e7224297
72 changed files with 7355 additions and 2 deletions
+139
View File
@@ -0,0 +1,139 @@
# Die Bildschirmfoto-Werkstatt
Die Bilder im Handbuch sind keine abfotografierten Terminals, sondern werden
erzeugt — aus dem **echten** Programmcode, gegen einen erfundenen Proxmox-Host.
```bash
./aufnehmen.sh # alle Bilder neu
./aufnehmen.sh explorer # nur die, deren Name "explorer" enthält
```
Ergebnis: `handbuch/docs/bilder/*.svg`
Das hat drei Gründe:
* **Sie stimmen.** Es läuft wirklich `pvesnap/recovery_ui.py`, nicht eine
Nachbildung. Ändert sich die Tastenleiste, ändert sich das Bild.
* **Sie sind reproduzierbar.** Derselbe Aufruf ergibt dasselbe Bild — gleiche
Daten, gleiche Uhrzeiten, gleiche Snapshot-Namen.
* **Kein echter Host wird angefasst.** Weder Daten noch Namen aus einer echten
Umgebung landen im Handbuch.
---
## Wie es funktioniert
```
scenes.py Liste aller Aufnahmen: Name, Szene, Tastenfolge, Titel
└─ probe.py startet eine Szene in einem Pseudo-Terminal
└─ stage.sh hängt die Kulisse an die richtigen Pfade (Namensraum)
└─ run.py biegt die Pfade im Programmcode um und ruft die Oberfläche auf
└─ pvesnap/… der echte Programmcode
└─ shot.py baut den Bildschirm nach und schreibt ihn als SVG
```
### `demo.py` — die Kulisse
Baut unter `werkstatt/demo/` ein erfundenes Rechenzentrum auf: zwei Nodes, zehn
Gäste, ein paar hundert Snapshots, zwei laufende Wiederherstellungen, drei
Austauschlaufwerke und einen Dateibaum für den Explorer.
Ein **fester Zeitpunkt** (`NOW`) sorgt dafür, dass zwei Aufnahmen derselben
Liste gleich aussehen — sonst würde die Beschreibung im Handbuch nicht mehr
zum Bild passen.
### `bin/pvesh`, `bin/perl`, `bin/rbd` — die Attrappen
pvesnap spricht mit Proxmox über genau drei Programme. Alle drei liegen hier als
kleine Python-Skripte, die aus `demo/state.json` antworten. Sie stehen im `PATH`
vor den echten.
Nichts davon verändert etwas — die schreibenden Aufrufe geben brav eine UPID
zurück und tun sonst nichts.
### `run.py` — die Pfade umbiegen
Setzt vor dem Start die Konstanten um, die sonst auf einen echten Host zeigen:
```python
recovery.REGISTRY = demo/lib/recovery.json
transfer.INDEX = demo/lib/transfer/index.json
transfer.CONF_ROOT = demo/pve/nodes
```
Der Programmcode selbst wird nicht angefasst. Die Bilder zeigen also wirklich
das, was auf dem Host auch zu sehen ist.
### `stage.sh` — die Pfade, die im Bild stehen
Der Explorer zeigt Pfade an. Im Handbuch sollen das die Pfade eines echten
Hosts sein (`/run/pvesnap/mnt/…`, `/root`, `/etc/pvesnap.conf`) und nicht die
Arbeitsverzeichnisse dieser Werkstatt.
Deshalb läuft jede Aufnahme in einem eigenen **Mount-Namensraum**
(`unshare -r -m`): Dort dürfen wir uns die Pfade hinbiegen, ohne am System
etwas zu verändern. `/etc` bekommt eine Overlay-Schicht, damit die
Konfiguration dort liegt, wo sie hingehört.
### `shot.py` — der Bildschirmnachbau
Startet das Programm in einem Pseudo-Terminal fester Größe (118 × 32), spielt
Tastendrücke ein, lässt [pyte](https://pypi.org/project/pyte/) den Bildschirm
nachbilden und schreibt das Ergebnis als SVG.
Zwei Feinheiten, die Zeit gekostet haben:
**`textLength` auf jedem Textstück.** Das SVG bettet keine Schrift ein — es
soll ja klein bleiben und offline funktionieren. Damit das Zeichenraster
trotzdem in jedem Browser sitzt, wird jede Zeichenkette fest vermessen.
**`NCURSES_NO_UTF8_ACS=1`.** ncurses zeichnet Rahmen sonst über den
Ersatzzeichensatz (`ESC ( 0`). pyte ignoriert das im UTF-8-Betrieb, und aus den
Linien würden Buchstaben (`lqqqk` statt `┌───┐`). Mit der Variablen liefert
ncurses echte Unicode-Rahmenzeichen.
---
## Eine Szene nachsehen
Zum Ausprobieren, ohne ein Bild zu schreiben — gibt den Bildschirm als Text aus:
```bash
../.venv/bin/python probe.py recovery
../.venv/bin/python probe.py recovery enter p
COLS=140 ROWS=40 ../.venv/bin/python probe.py explorer ab ab enter
```
Tastennamen: `ab auf links rechts enter tab esc space f2 f3 f5 f7 f9`,
alles andere wird als Zeichen geschickt.
Verfügbare Szenen stehen in `run.py` unter `SZENEN`.
---
## Eine Aufnahme hinzufügen
In `scenes.py` eine Zeile ergänzen:
```python
("recovery-neuerkram", "recovery", ["enter", "p"],
"root@pve1 — pvesnap-recovery"),
```
Fünftes Feld optional: Wartezeit in Sekunden, für Bildschirme, hinter denen
noch etwas arbeitet (sonst hält der Nachbau den letzten Stand fest, während im
Hintergrund noch ein Proxmox-Task läuft).
Braucht es dafür eine neue Szene, kommt sie in `run.py` dazu.
---
## Wenn etwas nicht klappt
| Symptom | Ursache |
|---|---|
| Bild ist leer oder zeigt einen Traceback | `scenes.py` meldet das selbst — mit `probe.py` nachsehen |
| Rahmen bestehen aus `lqqqk` | `NCURSES_NO_UTF8_ACS=1` fehlt in `probe.umgebung()` |
| Pfade zeigen ins Scratchpad | `unshare -r` ging nicht; `aufnehmen.sh` prüft das vorher |
| Pfeiltasten bewegen nichts | Es muss `\x1bOB` sein, nicht `\x1b[B` — curses schaltet auf den Anwendungsmodus |
| Ein Bildschirm hängt bei „bitte warten" | Eine Antwort der Attrappe fehlt; mit `log_level=DEBUG` sieht man den Aufruf |
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
# ---------------------------------------------------------------------
# Alle Bildschirmfotos fuers Handbuch neu aufnehmen.
#
# ./aufnehmen.sh alle
# ./aufnehmen.sh explorer nur die, deren Name das enthaelt
#
# Ergebnis: handbuch/docs/bilder/*.svg
# Wie das funktioniert, steht in LIESMICH.md.
# ---------------------------------------------------------------------
set -euo pipefail
HIER="$(cd "$(dirname "$0")" && pwd)"
HANDBUCH="$(dirname "${HIER}")"
VENV="${HANDBUCH}/.venv"
info() { printf '\033[36m%s\033[0m\n' "$*"; }
rot() { printf '\033[31m%s\033[0m\n' "$*" >&2; }
# unshare -r braucht Benutzer-Namensraeume; ohne sie stehen im Explorer die
# Arbeitspfade dieser Werkstatt statt der Pfade eines echten Hosts.
if ! unshare -r -m --propagation private true 2>/dev/null; then
rot "Benutzer-Namensraeume sind nicht verfuegbar (unshare -r schlaegt fehl)."
rot "Ohne sie stimmen die Pfade in den Explorer-Bildern nicht."
exit 1
fi
if [ ! -x "${VENV}/bin/python" ]; then
info "Richte Bau-Umgebung in ${VENV} ein ..."
python3 -m venv "${VENV}"
"${VENV}/bin/pip" install --quiet --upgrade pip
fi
if ! "${VENV}/bin/python" -c "import pyte" 2>/dev/null; then
info "Installiere pyte (Bildschirm-Nachbau) ..."
"${VENV}/bin/pip" install --quiet pyte
fi
info "Baue die Demo-Kulisse ..."
"${VENV}/bin/python" "${HIER}/demo.py" >/dev/null
info "Nehme auf ..."
"${VENV}/bin/python" "${HIER}/scenes.py" "$@"
echo
info "Fertig: ${HANDBUCH}/docs/bilder/"
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""Attrappe der Storage-Schicht.
pvesnap ruft `perl -e "<Skript>" <op> <args>` auf, um an PVE::Storage zu
kommen. Hier wird nur der Teil beantwortet, den die Bildschirmfotos brauchen -
Groessen und Pfade. Nichts wird angelegt oder geloescht.
"""
import sys
# Groessen der Datentraeger in der Kulisse, in Byte.
GROESSEN = {
"vm-100-disk-0": 60, "vm-101-disk-0": 400, "vm-102-disk-0": 2048,
"vm-105-disk-0": 250, "vm-130-disk-0": 80, "vm-120-disk-0": 120,
"subvol-110-disk-0": 16, "subvol-111-disk-0": 64,
"vm-9101-disk-0": 400, "vm-9102-disk-0": 250,
}
RAM = 32 * 1024 ** 3 # der gespeicherte Arbeitsspeicher von db01
def main(argv):
# argv: -e <skript> <op> <rest...>
rest = argv[2:] if len(argv) > 2 and argv[0] == "-e" else argv
if not rest:
return 1
op, args = rest[0], rest[1:]
if op == "size":
volid = args[0] if args else ""
name = volid.split(":", 1)[-1]
if "-state-" in name:
print("RESULT %d raw" % RAM)
else:
gigabyte = GROESSEN.get(name, 32)
print("RESULT %d raw" % (gigabyte * 1024 ** 3))
return 0
if op == "path":
volid = args[0] if args else ""
storage, _, name = volid.partition(":")
print("RESULT /dev/rbd/%s/%s" % (storage, name))
return 0
if op in ("activate", "deactivate", "free"):
print("RESULT ok")
return 0
if op == "clone":
volid = args[0] if args else ""
storage = volid.split(":", 1)[0]
print("RESULT %s:vm-%s-disk-0" % (storage, args[1] if len(args) > 1 else "0"))
return 0
if op == "alloc":
print("RESULT %s:vm-%s-disk-9" % (args[0], args[1]))
return 0
print("unbekannte Operation: %s" % op, file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""Attrappe von `pvesh` - beantwortet Abfragen aus demo/state.json.
Nur so viel, wie die Oberflaechen fuer die Bildschirmfotos brauchen. Alles,
was etwas veraendern wuerde, gibt brav eine UPID zurueck und tut nichts.
"""
import json
import os
import sys
STATE = os.environ.get("PVESNAP_DEMO_STATE", "")
def load():
with open(STATE, "r", encoding="utf-8") as handle:
return json.load(handle)
def main(argv):
if len(argv) < 2:
return 1
verb, path = argv[0], argv[1]
args = {}
rest = argv[2:]
index = 0
while index < len(rest):
if rest[index].startswith("--"):
key = rest[index][2:]
value = rest[index + 1] if index + 1 < len(rest) else ""
args[key] = value
index += 2
else:
index += 1
data = load()
parts = [p for p in path.strip("/").split("/") if p]
if verb == "get":
result = handle_get(parts, args, data)
if result is _MISS:
print("no such resource '%s'" % path, file=sys.stderr)
return 2
if args.get("output-format") == "json":
print(json.dumps(result))
else:
print(result)
return 0
# Alles Veraendernde: Proxmox liefert eine UPID, der Aufrufer wartet darauf.
node = parts[1] if len(parts) > 1 and parts[0] == "nodes" else "pve1"
if parts[-1] == "spiceproxy":
print(json.dumps({
"type": "spice", "host": "pvespiceproxy:63f4a2b1:100:pve1::abc",
"proxy": "http://10.20.0.11", "tls-port": 61000,
"password": "aG9jaGdlaGVpbQ==", "ca": "-----BEGIN CERTIFICATE-----\\n"
"MIIFxTCCA62gAwIBAgIB...\\n-----END CERTIFICATE-----\\n",
"host-subject": "OU=PVE Cluster Node,O=Proxmox Virtual Environment,"
"CN=pve1.hausnetz.lan",
"delete-this-file": 1, "secure-attention": "Ctrl+Alt+Ins",
"toggle-fullscreen": "Shift+F11", "release-cursor": "Ctrl+Alt+R",
"title": "VM 9102 - warenwirtschaft-w",
}))
return 0
print("UPID:%s:00001A2B:0BC3D4E5:68970000:qmsnapshot:100:root@pam:" % node)
return 0
_MISS = object()
def handle_get(parts, args, data):
if parts == ["cluster", "resources"]:
return data["resources"]
if len(parts) == 2 and parts[0] == "storage":
return {"ceph-vm": {"type": "rbd", "pool": "vmdaten", "shared": 1,
"content": "images", "storage": "ceph-vm",
"krbd": 0, "monhost": "10.20.0.11 10.20.0.12"},
"ceph-ct": {"type": "rbd", "pool": "ctdaten", "shared": 1,
"content": "rootdir", "storage": "ceph-ct"},
"local": {"type": "dir", "shared": 0, "storage": "local",
"content": "iso,vztmpl,backup"},
}.get(parts[1], _MISS)
if parts == ["storage"]:
return [{"storage": "ceph-vm", "type": "rbd", "shared": 1},
{"storage": "ceph-ct", "type": "rbd", "shared": 1},
{"storage": "local", "type": "dir", "shared": 0}]
if parts == ["cluster", "nextid"]:
wanted = args.get("vmid")
if wanted:
taken = {str(r["vmid"]) for r in data["resources"]}
if str(wanted) in taken:
print("VM %s already exists" % wanted, file=sys.stderr)
sys.exit(2)
return int(wanted)
return data["nextid"]
# Muss vor dem allgemeinen /nodes-Zweig stehen: sonst wird "tasks" fuer
# eine Gastart gehalten und /status liefert einen leeren Datensatz - die
# Warteschleife auf den Task laeuft dann bis zum Zeitlimit.
if len(parts) >= 3 and parts[0] == "nodes" and parts[2] == "tasks":
if parts[-1] == "log":
return [{"n": 1, "t": "TASK OK"}]
return {"status": "stopped", "exitstatus": "OK", "type": "qmconfig",
"upid": parts[3] if len(parts) > 3 else "", "node": parts[1]}
if len(parts) >= 4 and parts[0] == "nodes":
node, kind, vmid = parts[1], parts[2], parts[3]
key = "%s/%s/%s" % (node, kind, vmid)
tail = parts[4:]
if tail == ["snapshot"]:
entries = list(data["snapshots"].get(key, []))
entries.append({"name": "current", "digest": "0" * 40,
"description": "You are here!",
"parent": entries[-1]["name"] if entries else ""})
return entries
if tail == ["config"]:
return _conf(data["configs"].get(key, ""))
if len(tail) == 3 and tail[0] == "snapshot" and tail[2] == "config":
base = _conf(data["configs"].get(key, ""))
base["parent"] = tail[1]
base["snaptime"] = next(
(s["snaptime"] for s in data["snapshots"].get(key, [])
if s["name"] == tail[1]), 0)
# Ein Snapshot "mit RAM" traegt den Arbeitsspeicher als eigenes
# Volume; genau daran erkennt pvesnap, dass es warm gehen kann.
if tail[1].startswith("auto-stuendlich"):
base["vmstate"] = "ceph-vm:vm-%s-state-%s" % (vmid, tail[1])
base["runningmachine"] = "pc-i440fx-9.0+pve0"
base["runningcpu"] = "x86-64-v2-AES,enforce"
return base
if tail == ["status", "current"]:
return data["status"].get(key, {})
if tail and tail[0] == "status":
return data["status"].get(key, {})
return _MISS
def _conf(text):
result = {}
for line in text.splitlines():
if ":" in line and not line.startswith("#"):
key, _, value = line.partition(":")
result[key.strip()] = value.strip()
for key in ("cores", "memory", "sockets", "numa", "agent"):
if key in result:
try:
result[key] = int(result[key])
except ValueError:
pass
return result
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Attrappe von `rbd` - nur lesende Auskuenfte fuer die Bildschirmfotos.
VM 9101 ist ein Linked Clone (haengt am Quell-Snapshot), VM 9102 ist bereits
geloest. Genau diese beiden Zustaende soll das Handbuch zeigen.
"""
import sys
VERBUNDEN = "vm-9101-disk-0" # haengt noch am Snapshot
QUELLE = "vmdaten/vm-101-disk-0@auto-stuendlich-20260809-100000"
# Optionen, hinter denen noch ein Wert steht - sonst haelt man "-m <monhost>"
# fuer den Befehl.
MIT_WERT = {"-m", "--id", "--keyring", "-c", "--conf", "-p", "--pool",
"--namespace", "-n", "--name"}
def zerlege(argv):
"""(Befehl, Abbild) aus der Kommandozeile."""
rest = []
index = 0
while index < len(argv):
wort = argv[index]
if wort in MIT_WERT:
index += 2
continue
if wort.startswith("-"):
index += 1
continue
rest.append(wort)
index += 1
return (rest[0] if rest else ""), (rest[1] if len(rest) > 1 else "")
def main(argv):
if not argv:
return 1
befehl, ziel = zerlege(argv)
name = ziel.split("/")[-1].split("@")[0]
if befehl == "info":
print("rbd image '%s':" % name)
print("\tsize 400 GiB in 102400 objects")
print("\torder 22 (4 MiB objects)")
print("\tsnapshot_count: 0")
print("\tid: 1f5c3d9a7b2e")
print("\tblock_name_prefix: rbd_data.1f5c3d9a7b2e")
print("\tformat: 2")
print("\tfeatures: layering, exclusive-lock")
print("\top_features:")
print("\tflags:")
if name == VERBUNDEN:
print("\tparent: %s" % QUELLE)
print("\toverlap: 400 GiB")
return 0
if befehl == "du":
print("NAME PROVISIONED USED")
print("%-19s 400 GiB 2.4 GiB" % name)
return 0
if befehl in ("flatten", "snap"):
return 0
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+478
View File
@@ -0,0 +1,478 @@
#!/usr/bin/env python3
"""Ein erfundenes Rechenzentrum fuer die Bildschirmfotos im Handbuch.
Baut unter DEMO/ eine vollstaendige Kulisse auf: Antworten fuer ein
Attrappen-pvesh, Gast-Konfigurationen, die Merkliste der Wiederherstellungen,
die Transfer-Laufwerke und einen Dateibaum fuer den Explorer.
Nichts davon fasst einen echten Proxmox-Host an.
"""
from __future__ import annotations
import json
import os
import shutil
from datetime import datetime, timedelta
HERE = os.path.dirname(os.path.abspath(__file__))
DEMO = os.path.join(HERE, "demo")
# Ein fester Zeitpunkt - sonst sehen zwei Bildschirmfotos derselben Liste
# unterschiedlich aus, und im Handbuch passt die Beschreibung nicht mehr.
NOW = datetime(2026, 8, 9, 11, 42, 0)
NODES = {"pve1": "10.20.0.11", "pve2": "10.20.0.12"}
GUESTS = [
# vmid, name, typ, node, zustand, tags, pool
(100, "web01", "qemu", "pve1", "running", "produktion;stuendlich", "Hausnetz"),
(101, "db01", "qemu", "pve1", "running", "produktion;datenbank;stuendlich", "Hausnetz"),
(102, "fileserver", "qemu", "pve2", "running", "produktion", "Hausnetz"),
(105, "warenwirtschaft", "qemu", "pve2", "running", "produktion;dongle", "Hausnetz"),
(110, "mailgw", "lxc", "pve1", "running", "produktion", "Hausnetz"),
(111, "gitlab", "lxc", "pve2", "running", "entwicklung", ""),
(120, "build-test", "qemu", "pve2", "stopped", "nosnap", ""),
(130, "dc01", "qemu", "pve1", "running", "produktion", "Hausnetz"),
(9101, "db01-live", "qemu", "pve1", "running", "pvesnap-recovery", ""),
(9102, "warenwirtschaft-w", "qemu", "pve2", "stopped", "pvesnap-recovery", ""),
]
# Wie viele Snapshots welcher Gruppe je Gast - so, wie es nach ein paar Wochen
# Betrieb tatsaechlich aussieht.
PLAN = {
100: [("stuendlich", 24, 3600), ("taeglich", 8, 86400)],
101: [("stuendlich", 24, 3600), ("taeglich", 14, 86400), ("monatlich", 3, 2592000)],
102: [("taeglich", 9, 86400)],
105: [("taeglich", 12, 86400), ("monatlich", 2, 2592000)],
110: [("taeglich", 7, 86400)],
111: [("taeglich", 4, 86400)],
130: [("taeglich", 11, 86400)],
}
# Von Hand angelegte Snapshots - die pvesnap niemals anfasst.
HANDMADE = {
101: [("vor-update-14.2", "Vor dem Update auf PostgreSQL 14.2", 6 * 86400)],
105: [("golden", "Frisch eingerichtet, Lizenz aktiviert", 40 * 86400)],
}
DESCRIPTION = ("pvesnap | Gruppe: %s | erstellt: %s | Vorhaltezeit: %s | max: %d")
KEEP = {"stuendlich": ("2 Tage", 24), "taeglich": ("21 Tage", 14),
"monatlich": ("400 Tage", 6)}
QEMU_CONF = """\
agent: 1
boot: order=scsi0
cores: %(cores)d
cpu: x86-64-v2-AES
memory: %(memory)d
meta: creation-qemu=9.0.2
name: %(name)s
net0: virtio=%(mac)s,bridge=vmbr0,firewall=1
numa: 0
ostype: %(ostype)s
scsi0: ceph-vm:vm-%(vmid)d-disk-0,discard=on,iothread=1,size=%(size)dG
scsihw: virtio-scsi-single
smbios1: uuid=%(uuid)s
sockets: 1
vmgenid: %(genid)s
"""
LXC_CONF = """\
arch: amd64
cores: 2
features: nesting=1
hostname: %(name)s
memory: 2048
net0: name=eth0,bridge=vmbr0,firewall=1,hwaddr=%(mac)s,ip=dhcp,type=veth
ostype: debian
rootfs: ceph-ct:subvol-%(vmid)d-disk-0,size=%(size)dG
swap: 512
unprivileged: 1
"""
HARDWARE = {
100: dict(cores=4, memory=8192, size=60, ostype="l26"),
101: dict(cores=8, memory=32768, size=400, ostype="l26"),
102: dict(cores=4, memory=8192, size=2048, ostype="l26"),
105: dict(cores=6, memory=16384, size=250, ostype="win11"),
120: dict(cores=8, memory=16384, size=120, ostype="l26"),
130: dict(cores=4, memory=8192, size=80, ostype="win11"),
110: dict(size=16),
111: dict(size=64),
9101: dict(cores=8, memory=32768, size=400, ostype="l26"),
9102: dict(cores=6, memory=16384, size=250, ostype="win11"),
}
def _mac(vmid, index=0):
return "BC:24:11:%02X:%02X:%02X" % (vmid // 256, vmid % 256, 0x40 + index)
def _uuid(vmid):
return "8f3c%04d-11ee-4a7b-9c2d-%012d" % (vmid, vmid * 7919)
def _genid(vmid):
return "b2e1%04d-7f45-4c18-a3d9-%012d" % (vmid, vmid * 104729)
# ---------------------------------------------------------------------------
# Snapshots
# ---------------------------------------------------------------------------
def _snapshots_for(vmid):
"""Alle Snapshots eines Gastes, aeltester zuerst, mit Elternkette."""
entries = []
for slug, count, step in PLAN.get(vmid, []):
keep_time, keep_count = KEEP[slug]
for index in range(count):
moment = NOW - timedelta(seconds=step * (index + 1))
if slug == "taeglich":
moment = moment.replace(hour=2, minute=30, second=0)
elif slug == "monatlich":
moment = moment.replace(day=1, hour=4, minute=0, second=0)
else:
moment = moment.replace(minute=0, second=0)
entries.append({
"name": "auto-%s-%s" % (slug, moment.strftime("%Y%m%d-%H%M%S")),
"snaptime": int(moment.timestamp()),
"description": DESCRIPTION % (slug, moment.strftime("%Y-%m-%d %H:%M:%S"),
keep_time, keep_count),
})
for name, note, age in HANDMADE.get(vmid, []):
moment = NOW - timedelta(seconds=age)
entries.append({"name": name, "snaptime": int(moment.timestamp()),
"description": note})
entries.sort(key=lambda e: e["snaptime"])
parent = ""
for entry in entries:
entry["parent"] = parent
parent = entry["name"]
return entries
# ---------------------------------------------------------------------------
# Kulisse aufbauen
# ---------------------------------------------------------------------------
def _write(path, text):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as handle:
handle.write(text)
def _guest_config(vmid, name, kind):
values = dict(HARDWARE.get(vmid, {}))
values.update(vmid=vmid, name=name, mac=_mac(vmid),
uuid=_uuid(vmid), genid=_genid(vmid))
values.setdefault("cores", 2)
values.setdefault("memory", 2048)
values.setdefault("size", 32)
values.setdefault("ostype", "l26")
return (LXC_CONF if kind == "lxc" else QEMU_CONF) % values
def build():
if os.path.isdir(DEMO):
shutil.rmtree(DEMO)
resources, snapshots, configs, status = [], {}, {}, {}
for vmid, name, kind, node, state, tags, pool in GUESTS:
resources.append({
"vmid": vmid, "name": name, "type": kind, "node": node,
"status": state, "tags": tags, "pool": pool,
"maxmem": HARDWARE.get(vmid, {}).get("memory", 2048) * 1024 * 1024,
"maxdisk": HARDWARE.get(vmid, {}).get("size", 32) * 1024 ** 3,
"uptime": 486231 if state == "running" else 0,
})
key = "%s/%s/%d" % (node, kind, vmid)
snapshots[key] = _snapshots_for(vmid)
configs[key] = _guest_config(vmid, name, kind)
status[key] = {
"status": state, "vmid": vmid, "name": name,
"qmpstatus": "running" if state == "running" else "stopped",
"uptime": 486231 if state == "running" else 0,
"maxmem": HARDWARE.get(vmid, {}).get("memory", 2048) * 1024 * 1024,
"cpus": HARDWARE.get(vmid, {}).get("cores", 2),
"spice": 1 if vmid == 9102 else 0,
}
# Die beiden Wiederherstellungen haben eine eigene Konfiguration: kein Netz
# bzw. Netz mit den MAC-Adressen des Originals, dazu das Transfer-Laufwerk.
live = _guest_config(9101, "db01-live", "qemu")
live = live.replace("net0: virtio=%s,bridge=vmbr0,firewall=1\n" % _mac(9101), "")
live = live.replace("scsi0: ceph-vm:vm-9101-disk-0",
"scsi0: ceph-vm:vm-9101-disk-0")
live += "scsi1: /dev/loop3,backup=0,replicate=0\n"
live += "tags: pvesnap-recovery\n"
configs["pve1/qemu/9101"] = live
warm = _guest_config(9102, "warenwirtschaft-w", "qemu")
warm = warm.replace("net0: virtio=%s" % _mac(9102), "net0: virtio=%s" % _mac(105))
warm = warm.replace("smbios1: uuid=%s" % _uuid(9102),
"smbios1: uuid=%s" % _uuid(105))
warm += "tags: pvesnap-recovery\nusb0: spice\nusb1: spice\nvga: qxl\n"
configs["pve2/qemu/9102"] = warm
_write(os.path.join(DEMO, "state.json"), json.dumps({
"resources": resources, "snapshots": snapshots,
"configs": configs, "status": status, "nextid": 9103,
"nodes": NODES,
}, indent=1))
# /etc/pve nachbilden - daraus liest pvesnap die Belegung der Laufwerke.
for vmid, name, kind, node, _s, _t, _p in GUESTS:
sub = "lxc" if kind == "lxc" else "qemu-server"
_write(os.path.join(DEMO, "pve", "nodes", node, sub, "%d.conf" % vmid),
configs["%s/%s/%d" % (node, kind, vmid)])
_write(os.path.join(DEMO, "pve", ".members"),
json.dumps({"nodename": "pve1", "version": 8,
"nodelist": {n: {"id": i + 1, "online": 1, "ip": ip}
for i, (n, ip) in enumerate(NODES.items())}},
indent=1))
_build_registry()
_build_transfers()
_build_config()
_build_tree()
return DEMO
def _build_registry():
created_live = int((NOW - timedelta(minutes=38)).timestamp())
created_warm = int((NOW - timedelta(hours=5, minutes=12)).timestamp())
entries = [
{"vmid": 9101, "type": "qemu", "node": "pve1", "name": "db01-live",
"source": 101, "source_node": "pve1",
"snapshot": "auto-stuendlich-20260809-100000", "mode": "live",
"created": created_live,
"volumes": ["ceph-vm:vm-9101-disk-0"],
"protected": [["ceph-vm:vm-101-disk-0", "auto-stuendlich-20260809-100000"]],
"transfers": [{"name": "dumps", "key": "scsi1",
"drive": "/dev/loop3,backup=0,replicate=0",
"kind": "disk", "pending": False}],
"resumed": True},
{"vmid": 9102, "type": "qemu", "node": "pve2", "name": "warenwirtschaft-w",
"source": 105, "source_node": "pve2",
"snapshot": "auto-taeglich-20260809-023000", "mode": "recover",
"created": created_warm,
"volumes": ["ceph-vm:vm-9102-disk-0"],
"protected": [],
"transfers": [], "resumed": False},
]
_write(os.path.join(DEMO, "lib", "recovery.json"), json.dumps(entries, indent=1))
def _build_transfers():
volumes = [
{"name": "dumps", "image": os.path.join(DEMO, "lib", "transfer", "dumps.img"),
"size": 20 * 1024 ** 3, "fs": "exfat", "label": "DUMPS",
"note": "Datenbank-Dumps und Exporte",
"created": int((NOW - timedelta(days=41)).timestamp()), "partitioned": True},
{"name": "werkzeuge",
"image": os.path.join(DEMO, "lib", "transfer", "werkzeuge.img"),
"size": 4 * 1024 ** 3, "fs": "exfat", "label": "WERKZEUGE",
"note": "Skripte, Treiber, Installer",
"created": int((NOW - timedelta(days=41)).timestamp()), "partitioned": True},
{"name": "austausch",
"image": os.path.join(DEMO, "lib", "transfer", "austausch.img"),
"size": 8 * 1024 ** 3, "fs": "exfat", "label": "AUSTAUSCH",
"note": "Alles andere",
"created": int((NOW - timedelta(days=9)).timestamp()), "partitioned": True},
]
_write(os.path.join(DEMO, "lib", "transfer", "index.json"),
json.dumps(volumes, indent=1))
# Duenn besetzte Abbilddateien: sie kosten keinen Plattenplatz, aber
# Volume.used_bytes findet echte Werte vor.
for volume, used in zip(volumes, (3_400_000_000, 780_000_000, 0)):
with open(volume["image"], "wb") as handle:
handle.truncate(volume["size"])
if used:
handle.seek(0)
handle.write(b"\0" * min(used, 4 * 1024 ** 2))
os.truncate(volume["image"], volume["size"])
def _build_config():
_write(os.path.join(DEMO, "pvesnap.conf"), """\
[global]
prefix = auto
check_interval = 60s
state_file = /var/lib/pvesnap/state.json
log_level = INFO
task_timeout = 15m
retries = 2
retry_delay = 60s
pause_between = 0s
run_on_start = no
dry_run = no
description = pvesnap | Gruppe: {group} | erstellt: {datetime} | Vorhaltezeit: {keep_time} | max: {keep_count}
[defaults]
enabled = yes
skip_stopped = no
vmstate = no
[group:stuendlich]
interval = 1h
align = yes
keep_count = 24
keep_time = 2d
tags = stuendlich
skip_stopped = yes
[group:taeglich]
schedule = daily
at = 02:30
keep_count = 14
keep_time = 21d
tags = produktion
exclude_tags = nosnap
[group:monatlich]
schedule = monthly
day_of_month = 1
at = 04:00
keep_count = 6
keep_time = 400d
all = yes
exclude_tags = nosnap, pvesnap-recovery
description = Monatssicherung {name} ({vmid}) vom {date}
""")
FSTAB = """\
# /etc/fstab: static file system information.
#
# Use 'blkid' to print the universally unique identifier for a device; this may
# be used with UUID= as a more robust way to name devices that works even if
# disks are added and removed. See fstab(5).
#
# <file system> <mount point> <type> <options> <dump> <pass>
UUID=4c1a9f3e-2b77-4d18-9a5c-7e3f0d1b8a26 / ext4 errors=remount-ro 0 1
UUID=9f2c-31AD /boot/efi vfat umask=0077 0 1
/dev/disk/by-id/scsi-0QEMU_QEMU_HARDDISK_drive-scsi1 /srv/dumps xfs defaults 0 2
/swap.img none swap sw 0 0
"""
SICHERUNG = """\
#!/bin/bash
# Naechtlicher Dump - laeuft aus der Crontab um 01:50, also vor dem Snapshot.
set -euo pipefail
ZIEL=/srv/dumps
STAMPE=$(date +%Y%m%d-%H%M)
for DB in kunden auftraege artikel; do
pg_dump -Fc "$DB" | gzip -1 > "$ZIEL/${DB}-${STAMPE}.sql.gz"
done
find "$ZIEL" -name '*.sql.gz' -mtime +14 -delete
"""
LIESMICH = """\
Transfer-Laufwerk dumps
=======================
Angelegt mit pvesnap-recovery (Taste v in der Uebersicht).
Dieses Laufwerk gehoert zu keiner Maschine. Es wird beim Start einer
Wiederherstellung angehaengt und danach wieder freigegeben - der Inhalt
bleibt erhalten.
Immer nur an einer Stelle benutzen: entweder am Host eingehaengt oder in
einem Gast. Beides gleichzeitig zerlegt das Dateisystem.
"""
TREE = {
"etc": {
"postgresql": {"14": {"main": {
"postgresql.conf": 28_412, "pg_hba.conf": 5_137, "pg_ident.conf": 1_636}}},
"nginx": {"nginx.conf": 1_482, "sites-enabled": {"default": 2_416}},
"fstab": FSTAB, "hostname": "db01\n", "hosts": 274, "passwd": 2_143,
"shadow": 1_309, "ssh": {"sshd_config": 3_290},
},
"home": {"stefan": {"notizen.txt": 1_204, ".bashrc": 3_771}},
"root": {".bash_history": 8_214, "sicherung.sh": SICHERUNG},
"srv": {"dumps": {
"db01-20260809-0200.sql.gz": 4_183_244_800,
"db01-20260808-0200.sql.gz": 4_106_112_512,
"kunden-export.csv": 88_412_160}},
"var": {"log": {"syslog": 12_884_901, "auth.log": 940_233,
"postgresql": {"postgresql-14-main.log": 3_402_118}},
"lib": {"postgresql": {"14": {"main": {"PG_VERSION": 3,
"postgresql.auto.conf": 88}}}}},
}
# Alles, was frisch angelegt wird, traegt sonst das Datum von heute - im
# Bildschirmfoto sieht ein Serverdateisystem dann aus wie eben ausgepackt.
MTIMES = {
"srv/dumps/db01-20260809-0200.sql.gz": NOW - timedelta(hours=9, minutes=12),
"srv/dumps/db01-20260808-0200.sql.gz": NOW - timedelta(days=1, hours=9),
"srv/dumps/kunden-export.csv": NOW - timedelta(days=2, hours=4),
"srv/dumps": NOW - timedelta(hours=9),
"var/log/syslog": NOW - timedelta(minutes=3),
"var/log/auth.log": NOW - timedelta(minutes=41),
"var/log": NOW - timedelta(minutes=3),
"root/.bash_history": NOW - timedelta(days=1, hours=2),
"root/sicherung.sh": NOW - timedelta(days=96),
"home/stefan/notizen.txt": NOW - timedelta(days=12),
"etc/postgresql/14/main/postgresql.conf": NOW - timedelta(days=214),
"etc/nginx/nginx.conf": NOW - timedelta(days=402),
"etc/fstab": NOW - timedelta(days=611),
"etc/hostname": NOW - timedelta(days=611),
}
GRUNDALTER = timedelta(days=611) # der Tag, an dem der Server aufgesetzt wurde
def _build_tree():
"""Ein glaubhaftes Linux-Wurzelverzeichnis fuer den Explorer."""
def make(base, spec, prefix=""):
for name, value in spec.items():
path = os.path.join(base, name)
relativ = "%s/%s" % (prefix, name) if prefix else name
if isinstance(value, dict):
os.makedirs(path, exist_ok=True)
make(path, value, relativ)
elif isinstance(value, str):
# Echter Inhalt - damit die Dateivorschau (F3) etwas zu zeigen hat.
with open(path, "w", encoding="utf-8") as handle:
handle.write(value)
else:
with open(path, "wb") as handle:
handle.truncate(value)
moment = MTIMES.get(relativ, NOW - GRUNDALTER)
stamp = moment.timestamp()
os.utime(path, (stamp, stamp))
root = os.path.join(DEMO, "snapshot")
os.makedirs(root, exist_ok=True)
make(root, TREE)
lokal = os.path.join(DEMO, "lokal")
os.makedirs(lokal, exist_ok=True)
make(lokal, {"werkzeuge": {"pg_repack.deb": 412_160, "pruefen.sh": 2_048},
"holen": {}, "notizen.md": 3_190})
# Was auf einem Transfer-Laufwerk liegt, wenn man es vorbereitet hat.
transfer = os.path.join(DEMO, "transfer-dumps")
os.makedirs(transfer, exist_ok=True)
make(transfer, {"LIESMICH.txt": LIESMICH,
"werkzeuge": {"pg_dump-14": 1_284_096, "7z.exe": 1_140_224},
"eingang": {}, "ausgang": {}})
for basis, alter in ((lokal, timedelta(days=3)), (transfer, timedelta(days=41))):
stamp = (NOW - alter).timestamp()
for wurzel, ordner, dateien in os.walk(basis):
for name in list(ordner) + list(dateien):
os.utime(os.path.join(wurzel, name), (stamp, stamp))
os.utime(wurzel, (stamp, stamp))
if __name__ == "__main__":
print(build())
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Eine Szene starten und den Bildschirm als Text zeigen - zum Nachsehen."""
import os
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__)) # handbuch/werkstatt
HANDBUCH = os.path.dirname(HERE) # handbuch
REPO = os.path.dirname(HANDBUCH) # Projektwurzel
sys.path.insert(0, HERE)
from shot import Terminal # noqa: E402
TASTEN = {"ab": "\x1bOB", "auf": "\x1bOA", "rechts": "\x1bOC", "links": "\x1bOD",
"enter": "\r", "tab": "\t", "esc": "\x1b", "f2": "\x1bOQ", "f3": "\x1bOR",
"f5": "\x1b[15~", "f7": "\x1b[18~", "f9": "\x1b[20~", "space": " "}
def umgebung():
env = dict(os.environ)
env["PATH"] = os.path.join(HERE, "bin") + ":" + env["PATH"]
env["PVESNAP_DEMO_STATE"] = os.path.join(HERE, "demo", "state.json")
# ncurses zeichnet Rahmen sonst ueber den Ersatzzeichensatz (ESC ( 0). Der
# Bildschirmnachbau ignoriert das im UTF-8-Betrieb, und aus den Linien
# wuerden Buchstaben. So kommen echte Unicode-Rahmenzeichen heraus.
env["NCURSES_NO_UTF8_ACS"] = "1"
env["PVESNAP_SRC"] = os.environ.get("PVESNAP_SRC", REPO)
env["PYTHONPATH"] = env["PVESNAP_SRC"]
return env
# Dieselbe Umgebung, die auch bauen.sh benutzt - dort wird pyte nachinstalliert.
VENV = os.path.join(HANDBUCH, ".venv", "bin", "python")
def befehl(szene):
"""Die Aufnahme laeuft in einem eigenen Mount-Namensraum - siehe stage.sh."""
return ["unshare", "-r", "-m", "--propagation", "private",
os.path.join(HERE, "stage.sh"), szene]
def starte(szene, cols=100, rows=30):
env = umgebung()
env["DOKU"] = HERE
env["PY"] = VENV
return Terminal(befehl(szene), cols=cols, rows=rows, env=env, cwd=HERE)
if __name__ == "__main__":
szene = sys.argv[1]
tasten = [TASTEN.get(t, t) for t in sys.argv[2:]]
term = starte(szene, cols=int(os.environ.get("COLS", 100)),
rows=int(os.environ.get("ROWS", 30)))
try:
time.sleep(1.2)
term.settle()
term.send(tasten, pause=0.3)
time.sleep(0.3)
term.settle()
for line in term.screen.display:
print("|" + line.rstrip() + "|")
finally:
term.close()
+216
View File
@@ -0,0 +1,216 @@
#!/usr/bin/env python3
"""Startet eine Oberflaeche von pvesnap gegen die Demo-Kulisse.
Aufruf: run.py <szene> [argumente]
Der eigentliche Trick steckt in _patch(): alle Pfade, die sonst auf einen
echten Proxmox-Host zeigen, werden auf demo/ umgebogen. Der Programmcode
selbst wird nicht angefasst - die Bildschirmfotos zeigen also wirklich das,
was auf dem Host auch zu sehen ist.
"""
from __future__ import annotations
import curses
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
DEMO = os.path.join(HERE, "demo")
sys.path.insert(0, os.environ.get("PVESNAP_SRC", ""))
# Wo die Kulisse haengt, wenn stage.sh sie eingehaengt hat. Ohne Namensraum
# (beim schnellen Nachsehen) wird auf die Verzeichnisse selbst zurueckgefallen.
SNAP_MNT = "/run/pvesnap/mnt/101-auto-taeglich-20260809-023000"
TRANSFER_MNT = "/run/pvesnap/transfer/dumps"
LOKAL = "/root"
def _oder(pfad, ersatz):
return pfad if os.path.isdir(pfad) and os.listdir(pfad) else ersatz
def _patch():
from pvesnap import preflight, recovery, transfer, tui
recovery.STATE_DIR = os.path.join(DEMO, "lib")
recovery.REGISTRY = os.path.join(DEMO, "lib", "recovery.json")
recovery.CONF_ROOT = os.path.join(DEMO, "pve", "nodes")
transfer.TRANSFER_DIR = os.path.join(DEMO, "lib", "transfer")
transfer.INDEX = os.path.join(DEMO, "lib", "transfer", "index.json")
transfer.MOUNT_ROOT = "/run/pvesnap/transfer"
transfer.CONF_ROOT = recovery.CONF_ROOT
# Loop-Geraete und Einhaengepunkte gibt es hier nicht - also erzaehlen wir
# sie. "dumps" haengt in der Maschine, "austausch" gerade am Host.
abbild = lambda name: os.path.join(transfer.TRANSFER_DIR, name + ".img")
transfer._loop_devices = lambda: {abbild("dumps"): "/dev/loop3",
abbild("austausch"): "/dev/loop5"}
transfer._mounts = lambda: {"/dev/loop5p1": os.path.join(transfer.MOUNT_ROOT,
"austausch")}
with open(os.path.join(DEMO, "pve", ".members"), encoding="utf-8") as handle:
mitglieder = json.load(handle)
recovery._ADDRESSES.update({n: e["ip"] for n, e
in mitglieder["nodelist"].items()})
preflight.check_environment = lambda require_pve=True: []
tui.service_state = lambda: (True, "active")
tui.service_text = lambda: "Dienst: laeuft"
def _explorer_panes(links_pfad, links_titel, untertitel, links_schreibbar=False):
from pvesnap.explorer import Explorer, Pane
links = Pane(path=links_pfad, root=links_pfad,
readonly=not links_schreibbar, title=links_titel)
rechts = Pane(path=_oder(LOKAL, os.path.join(DEMO, "lokal")), root="/",
readonly=False, title="Lokaler Rechner")
return Explorer(links, rechts, subtitle=untertitel)
# ---------------------------------------------------------------------------
# Szenen
# ---------------------------------------------------------------------------
def szene_config(stdscr):
from pvesnap.tui import Editor
pfad = "/etc/pvesnap.conf"
editor = Editor(pfad if os.path.exists(pfad)
else os.path.join(DEMO, "pvesnap.conf"))
editor.load()
editor.run(stdscr)
def szene_recovery(stdscr):
from pvesnap.proxmox import Proxmox
from pvesnap.recovery_ui import _overview
_overview(stdscr, Proxmox())
def szene_transfer(stdscr):
from pvesnap import transfer_ui
from pvesnap.recovery_ui import _browse_transfer
transfer_ui.screen(stdscr, browse=_browse_transfer)
def szene_explorer(stdscr):
_explorer_panes(_oder(SNAP_MNT, os.path.join(DEMO, "snapshot")),
"VM 101 (db01) @ auto-taeglich-20260809-023000",
"VM 101 (db01) - Snapshot auto-taeglich-20260809-023000"
).run(stdscr)
def szene_explorer_transfer(stdscr):
_explorer_panes(_oder(TRANSFER_MNT, os.path.join(DEMO, "transfer-dumps")),
"Transfer: dumps (DUMPS)",
"Transfer-Laufwerk dumps - beide Seiten beschreibbar",
links_schreibbar=True).run(stdscr)
def szene_gastauswahl(stdscr):
from pvesnap.explorer import _pick_guest
from pvesnap.proxmox import Proxmox
_pick_guest(stdscr, Proxmox().inventory())
def szene_snapshotauswahl(stdscr):
from pvesnap.explorer import _pick_snapshot
from pvesnap.proxmox import Proxmox
from pvesnap.snapfs import list_snapshots
proxmox = Proxmox()
gast = next(g for g in proxmox.inventory() if g.vmid == 101)
_pick_snapshot(stdscr, [s for s in list_snapshots(proxmox, gast) if s.complete])
def szene_neu(stdscr):
"""Die Optionsmaske des Assistenten - ohne etwas anzulegen."""
from pvesnap.proxmox import Proxmox
from pvesnap.recovery_ui import _options
from pvesnap.recovery import Spec
proxmox = Proxmox()
gast = next(g for g in proxmox.inventory() if g.vmid == 101)
spec = Spec(mode="live")
spec.snapshot = "auto-stuendlich-20260809-100000"
spec.newid = 9103
spec.node = "pve1"
spec.transfers = ["werkzeuge"]
_options(stdscr, gast, spec)
def _vorhaben(stdscr, vmid, snapshot, **werte):
from pvesnap.curses_util import init_colors
from pvesnap.proxmox import Proxmox
from pvesnap.recovery import Spec, plan
from pvesnap.recovery_ui import _pages, summary_lines
init_colors()
proxmox = Proxmox()
gast = next(g for g in proxmox.inventory() if g.vmid == vmid)
spec = Spec(mode=werte.pop("mode", "live"))
for schluessel, wert in werte.items():
setattr(spec, schluessel, wert)
_pages(stdscr, summary_lines(plan(proxmox, gast, snapshot, spec)),
"Vorhaben pruefen", "j = anlegen | n = abbrechen")
def szene_vorhaben(stdscr):
"""Der Normalfall: abgeschottet hineinschauen, mit Arbeitsspeicher."""
_vorhaben(stdscr, 101, "auto-stuendlich-20260809-100000",
newid=9103, transfers=["werkzeuge"])
def szene_vorhaben_dongle(stdscr):
"""Die Windows-Maschine mit SPICE und USB - fuer das Schutzmodul."""
_vorhaben(stdscr, 105, "auto-taeglich-20260809-023000",
mode="live", newid=9104, transfers=["werkzeuge"], spice=True, usb=2)
def szene_laufwerkswahl(stdscr):
from pvesnap import transfer_ui
transfer_ui.choose(stdscr, preselected=["werkzeuge"])
SZENEN = {
"config": szene_config,
"recovery": szene_recovery,
"transfer": szene_transfer,
"explorer": szene_explorer,
"explorer-transfer": szene_explorer_transfer,
"gastauswahl": szene_gastauswahl,
"snapshotauswahl": szene_snapshotauswahl,
"neu": szene_neu,
"vorhaben": szene_vorhaben,
"vorhaben-dongle": szene_vorhaben_dongle,
"laufwerkswahl": szene_laufwerkswahl,
}
def main():
if len(sys.argv) < 2 or sys.argv[1] not in SZENEN:
print("Szenen: %s" % ", ".join(sorted(SZENEN)), file=sys.stderr)
return 1
_patch()
from pvesnap.curses_util import init_colors
szene = SZENEN[sys.argv[1]]
def start(stdscr):
curses.curs_set(0)
if hasattr(curses, "set_escdelay"):
curses.set_escdelay(25)
init_colors()
stdscr.keypad(True)
return szene(stdscr)
try:
curses.wrapper(start)
except Exception: # beim Bauen will man den Grund sehen
import traceback
traceback.print_exc()
return 2
return 0
if __name__ == "__main__":
sys.exit(main())
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""Alle Bildschirmfotos fuers Handbuch aufnehmen.
./scenes.py alle
./scenes.py explorer nur die, deren Name das enthaelt
"""
from __future__ import annotations
import os
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from probe import TASTEN, VENV, befehl, umgebung # noqa: E402
from shot import Terminal # noqa: E402
ZIEL = os.path.join(os.path.dirname(HERE), "docs", "bilder")
BREIT, HOCH = 118, 32
# (Dateiname, Szene, Tasten, Fenstertitel)
AUFNAHMEN = [
# -- Sicherung einrichten: der Konfigurationseditor --------------------
("config-gruppen", "config", [],
"root@pve1 — pvesnap config"),
("config-gruppe", "config", ["ab", "enter"],
"root@pve1 — pvesnap config → Gruppe taeglich"),
("config-vms", "config", ["ab", "enter", "v"],
"root@pve1 — pvesnap config → VMs waehlen"),
("config-global", "config", ["g"],
"root@pve1 — pvesnap config → Globales"),
("config-uebersicht", "config", ["v"],
"root@pve1 — pvesnap config → Uebersicht"),
# -- Dateien holen: der Explorer ---------------------------------------
("explorer-gastauswahl", "gastauswahl", [],
"root@pve1 — pvesnap-explorer"),
("explorer-snapshotauswahl", "snapshotauswahl", [],
"root@pve1 — pvesnap-explorer"),
("explorer-fenster", "explorer", ["ab", "ab", "ab", "enter", "ab", "enter"],
"root@pve1 — pvesnap-explorer 101"),
("explorer-markiert", "explorer",
["ab", "ab", "ab", "enter", "ab", "enter", "ab", "space", "space"],
"root@pve1 — pvesnap-explorer 101"),
("explorer-kopieren", "explorer",
["ab", "ab", "ab", "enter", "ab", "enter", "ab", "space", "f5"],
"root@pve1 — pvesnap-explorer 101"),
("explorer-ansehen", "explorer",
["enter", "ab", "ab", "ab", "ab", "f3"],
"root@pve1 — pvesnap-explorer 101"),
("explorer-hilfe", "explorer", ["?"],
"root@pve1 — pvesnap-explorer 101"),
# -- Wiederherstellen ---------------------------------------------------
("recovery-uebersicht", "recovery", [],
"root@pve1 — pvesnap-recovery"),
("recovery-optionen", "neu", [],
"root@pve1 — pvesnap-recovery → einrichten"),
("recovery-betriebsart", "neu", ["enter"],
"root@pve1 — pvesnap-recovery → einrichten"),
("recovery-vorhaben", "vorhaben", [],
"root@pve1 — pvesnap-recovery → einrichten"),
("recovery-detail", "recovery", ["enter"],
"root@pve1 — pvesnap-recovery"),
("recovery-loesen", "recovery", ["enter", "f"],
"root@pve1 — pvesnap-recovery"),
("recovery-spice", "recovery", ["enter", "p"],
"root@pve1 — pvesnap-recovery"),
("recovery-usb", "recovery", ["enter", "p", "enter"],
"root@pve1 — pvesnap-recovery"),
("recovery-neustart", "recovery",
["enter", "p", "enter", "ab", "ab", "enter"],
"root@pve1 — pvesnap-recovery"),
("recovery-neustartwahl", "recovery",
["enter", "p", "enter", "ab", "ab", "enter", "j"],
"root@pve1 — pvesnap-recovery", 4.0),
("recovery-verwerfen", "recovery", ["x"],
"root@pve1 — pvesnap-recovery"),
# -- Austauschlaufwerke -------------------------------------------------
("transfer-uebersicht", "transfer", [],
"root@pve1 — pvesnap-recovery → Laufwerke"),
("transfer-neu", "transfer", ["n"],
"root@pve1 — pvesnap-recovery → Laufwerke"),
("transfer-commander", "explorer-transfer", [],
"root@pve1 — pvesnap-recovery → dumps"),
("transfer-auswahl", "laufwerkswahl", [],
"root@pve1 — pvesnap-recovery → einrichten"),
# -- Schutzmodul am SPICE-Anschluss -------------------------------------
("dongle-vorhaben", "vorhaben-dongle", [],
"root@pve2 — pvesnap-recovery → einrichten"),
]
def aufnehmen(name, szene, tasten, titel, nachlauf=0.3):
"""`nachlauf` fuer Bildschirme, hinter denen noch etwas arbeitet: der
Nachbau haelt sonst den letzten Stand fest, waehrend im Hintergrund noch
ein Proxmox-Task laeuft und danach der naechste Dialog aufgeht."""
env = umgebung()
env["DOKU"] = HERE
env["PY"] = VENV
term = Terminal(befehl(szene), cols=BREIT, rows=HOCH, env=env, cwd=HERE)
try:
time.sleep(1.3)
term.settle()
term.send([TASTEN.get(t, t) for t in tasten], pause=0.3)
time.sleep(nachlauf)
term.settle()
svg = term.to_svg(titel)
text = "\n".join(l.rstrip() for l in term.screen.display)
finally:
term.close()
os.makedirs(ZIEL, exist_ok=True)
with open(os.path.join(ZIEL, name + ".svg"), "w", encoding="utf-8") as handle:
handle.write(svg)
return text
def main():
filter_ = sys.argv[1] if len(sys.argv) > 1 else ""
fehler = []
for eintrag in AUFNAHMEN:
name = eintrag[0]
if filter_ and filter_ not in name:
continue
text = aufnehmen(*eintrag)
leer = not text.strip()
panne = "Traceback" in text
kennzeichen = "LEER" if leer else ("ABSTURZ" if panne else "ok")
print("%-28s %s" % (name, kennzeichen))
if leer or panne:
fehler.append(name)
print("\n".join(" " + l for l in text.splitlines() if l.strip())[:1500])
if fehler:
print("\nNachsehen: %s" % ", ".join(fehler))
return 1 if fehler else 0
if __name__ == "__main__":
sys.exit(main())
+236
View File
@@ -0,0 +1,236 @@
#!/usr/bin/env python3
"""Bildschirmfotos aus ncurses-Oberflaechen.
Startet ein Programm in einem echten Pseudo-Terminal fester Groesse, spielt
Tastendruecke ein, laesst pyte den Bildschirm nachbilden und schreibt das
Ergebnis als SVG. Das SVG ist reiner Text - keine Schriftart eingebettet,
dafuer jede Zeichenkette mit `textLength` fest vermessen. Damit sitzt das
Raster in jedem Browser, egal welche Monospace-Schrift er waehlt.
"""
from __future__ import annotations
import fcntl
import os
import pty
import select
import signal
import struct
import subprocess
import sys
import termios
import time
from xml.sax.saxutils import escape
import pyte
CW = 8.6 # Zeichenbreite in px
LH = 18.0 # Zeilenhoehe in px
FS = 14.5 # Schriftgroesse
PAD = 14.0 # Rand um den Textbereich
CHROME = 30.0 # Hoehe der Titelleiste
# Tango - die Palette, die auch Debian im Terminal benutzt. Fett macht aus der
# Grundfarbe die helle Variante; genau das tut ein Terminal mit A_BOLD, und
# genau darauf beruht der Kommentar in curses_util.init_colors().
PALETTE = {
"black": "#2e3436", "red": "#cc0000", "green": "#4e9a06",
"brown": "#c4a000", "yellow": "#c4a000", "blue": "#3465a4",
"magenta": "#75507b", "cyan": "#06989a", "white": "#d3d7cf",
"brightblack": "#555753", "brightred": "#ef2929", "brightgreen": "#8ae234",
"brightbrown": "#fce94f", "brightyellow": "#fce94f", "brightblue": "#729fcf",
"brightmagenta": "#ad7fa8", "brightcyan": "#34e2e2", "brightwhite": "#eeeeec",
}
BG_DEFAULT = "#1b1e24"
FG_DEFAULT = "#d3d7cf"
BRIGHT = {"black": "brightblack", "red": "brightred", "green": "brightgreen",
"brown": "brightbrown", "yellow": "brightyellow", "blue": "brightblue",
"magenta": "brightmagenta", "cyan": "brightcyan", "white": "brightwhite"}
def _color(name, bold, default):
if name == "default":
# Fett faerbt den Vordergrund nicht um, wenn er gar keine Farbe hat.
return default
if bold:
name = BRIGHT.get(name, name)
if name in PALETTE:
return PALETTE[name]
if len(name) == 6:
try:
int(name, 16)
return "#" + name
except ValueError:
pass
return default
class Terminal:
"""Ein Programm in einem Pseudo-Terminal, dessen Bildschirm mitgelesen wird."""
def __init__(self, command, cols=100, rows=30, env=None, cwd=None):
self.cols, self.rows = cols, rows
self.screen = pyte.Screen(cols, rows)
self.stream = pyte.ByteStream(self.screen)
self.master, slave = pty.openpty()
fcntl.ioctl(slave, termios.TIOCSWINSZ,
struct.pack("HHHH", rows, cols, cols * 8, rows * 16))
environment = dict(os.environ)
environment.update({"TERM": "xterm-256color", "LINES": str(rows),
"COLUMNS": str(cols), "LANG": "de_DE.UTF-8",
"LC_ALL": "de_DE.UTF-8"})
environment.update(env or {})
self.process = subprocess.Popen(
command, stdin=slave, stdout=slave, stderr=slave, cwd=cwd,
env=environment, close_fds=True, start_new_session=True)
os.close(slave)
# -- lesen -------------------------------------------------------------
def _drain(self, timeout):
"""Alles lesen, was bis `timeout` kommt. True, wenn etwas kam."""
got = False
deadline = time.monotonic() + timeout
while True:
rest = deadline - time.monotonic()
if rest <= 0:
return got
ready, _, _ = select.select([self.master], [], [], rest)
if not ready:
return got
try:
data = os.read(self.master, 65536)
except OSError:
return got
if not data:
return got
self.stream.feed(data)
got = True
def settle(self, quiet=0.35, limit=8.0):
"""Warten, bis eine Weile nichts mehr nachkommt."""
deadline = time.monotonic() + limit
while time.monotonic() < deadline:
if not self._drain(quiet):
return
self._drain(0.1)
# -- schreiben ---------------------------------------------------------
def send(self, keys, pause=0.25):
for key in keys if isinstance(keys, (list, tuple)) else [keys]:
os.write(self.master, key.encode("utf-8"))
time.sleep(pause)
self.settle()
def close(self):
try:
self.process.send_signal(signal.SIGKILL)
self.process.wait(timeout=5)
except (ProcessLookupError, subprocess.TimeoutExpired, OSError):
pass
try:
os.close(self.master)
except OSError:
pass
# -- ausgeben ----------------------------------------------------------
def to_svg(self, title=""):
return render(self.screen, self.cols, self.rows, title)
def _runs(screen, y, cols):
"""Eine Zeile in Abschnitte gleicher Darstellung zerlegen."""
line = screen.buffer[y]
out, current = [], None
for x in range(cols):
char = line[x]
style = (char.fg, char.bg, char.bold, char.reverse)
if char.reverse:
style = (char.bg, char.fg, char.bold, False)
if current and current[0] == style:
current[1].append(char.data or " ")
else:
current = (style, [char.data or " "], x)
out.append(current)
return out
def render(screen, cols, rows, title=""):
width = cols * CW + 2 * PAD
height = rows * LH + 2 * PAD + (CHROME if title else 0)
top = PAD + (CHROME if title else 0)
parts = [
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %.1f %.1f" '
'width="%.0f" height="%.0f" font-family="ui-monospace, SFMono-Regular, '
'Menlo, Consolas, \'DejaVu Sans Mono\', monospace" font-size="%.1f">'
% (width, height, width, height, FS),
'<rect width="%.1f" height="%.1f" rx="7" fill="%s"/>' % (width, height, BG_DEFAULT),
]
if title:
parts.append('<path d="M0 7a7 7 0 0 1 7-7h%.1fa7 7 0 0 1 7 7v%.1fH0z" '
'fill="#2b2f38"/>' % (width - 14, CHROME - 7))
for index, colour in enumerate(("#ed6a5e", "#f4bf4f", "#61c554")):
parts.append('<circle cx="%.1f" cy="15" r="5" fill="%s"/>'
% (16 + index * 17, colour))
parts.append('<text x="%.1f" y="20" fill="#9aa3b2" font-size="12" '
'text-anchor="middle">%s</text>'
% (width / 2, escape(title)))
for y in range(rows):
for style, chars, x in _runs(screen, y, cols):
fg_name, bg_name, bold, _ = style
text = "".join(chars)
run_width = len(chars) * CW
bg = _color(bg_name, False, None)
if bg and bg != BG_DEFAULT:
parts.append('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" '
'fill="%s"/>' % (PAD + x * CW, top + y * LH,
run_width + 0.4, LH + 0.4, bg))
if not text.strip():
continue
fg = _color(fg_name, bold, FG_DEFAULT)
weight = ' font-weight="bold"' if bold else ""
parts.append('<text x="%.1f" y="%.1f" fill="%s"%s textLength="%.1f" '
'lengthAdjust="spacing" xml:space="preserve">%s</text>'
% (PAD + x * CW, top + y * LH + FS * 0.92, fg, weight,
run_width, escape(text)))
parts.append("</svg>")
return "\n".join(parts)
def capture(target, command, keys=(), cols=100, rows=30, title="",
env=None, cwd=None, pause=0.3, warmup=1.2):
"""Ein Programm starten, Tasten schicken, Bildschirm als SVG ablegen."""
term = Terminal(command, cols=cols, rows=rows, env=env, cwd=cwd)
try:
time.sleep(warmup)
term.settle()
term.send(list(keys), pause=pause)
time.sleep(0.2)
term.settle()
svg = term.to_svg(title)
finally:
term.close()
os.makedirs(os.path.dirname(target), exist_ok=True)
with open(target, "w", encoding="utf-8") as handle:
handle.write(svg)
return svg
def text_of(svg_screen):
"""Nur zum Nachsehen beim Bauen: der Bildschirm als reiner Text."""
return "\n".join(svg_screen.display)
if __name__ == "__main__":
print("Wird von scenes.py benutzt.", file=sys.stderr)
+27
View File
@@ -0,0 +1,27 @@
#!/bin/sh
# Die Kulisse an die richtigen Stellen haengen.
#
# Der Explorer zeigt Pfade an. Im Handbuch sollen das die Pfade sein, die auf
# einem echten Host stehen - /run/pvesnap/mnt/... und /root -, nicht die
# Arbeitsverzeichnisse dieser Werkstatt. Also laeuft die Aufnahme in einem
# eigenen Mount-Namensraum (unshare -r -m), in dem wir uns die Pfade
# hinbiegen duerfen, ohne irgendetwas am System zu veraendern.
set -e
D="$DOKU/demo"
mount -t tmpfs none /run
mkdir -p /run/pvesnap/mnt/101-auto-taeglich-20260809-023000
mkdir -p /run/pvesnap/transfer/dumps
mount --bind "$D/snapshot" /run/pvesnap/mnt/101-auto-taeglich-20260809-023000
mount --bind "$D/transfer-dumps" /run/pvesnap/transfer/dumps
mount --bind "$D/lokal" /root
# /etc bleibt vollstaendig, bekommt aber eine Schreibschicht obendrauf - damit
# die Konfiguration dort liegt, wo sie auf dem Host liegt.
mkdir -p /run/overlay/oben /run/overlay/arbeit
mount -t overlay overlay -o lowerdir=/etc,upperdir=/run/overlay/oben,workdir=/run/overlay/arbeit /etc
cp "$D/pvesnap.conf" /etc/pvesnap.conf
cd /root
exec "$PY" "$DOKU/run.py" "$@"