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
+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())