#!/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). # # 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())