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:
co-authored by
Claude Opus 5
parent
c484596702
commit
59e7224297
@@ -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 |
|
||||
Executable
+45
@@ -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/"
|
||||
Executable
+63
@@ -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:]))
|
||||
Executable
+157
@@ -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:]))
|
||||
Executable
+71
@@ -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:]))
|
||||
Executable
+478
@@ -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())
|
||||
Executable
+62
@@ -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()
|
||||
Executable
+216
@@ -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())
|
||||
Executable
+145
@@ -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())
|
||||
Executable
+236
@@ -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)
|
||||
Executable
+27
@@ -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" "$@"
|
||||
Reference in New Issue
Block a user