hub v0.129.0: operator raises ONE clean-up window's cap (R-833); restore-beside script + runbooks (R-834)
gates / gates (push) Successful in 30s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-04 08:56:56 +02:00
parent 3885640f66
commit 55f7621c90
20 changed files with 647 additions and 28 deletions
+60
View File
@@ -0,0 +1,60 @@
#!/bin/bash
# felhom-restore-beside.sh — restore a whole-guest archive BESIDE a live original, safely (R-834).
#
# Run as root on a Proxmox host. The restored guest is for READING (pct mount, a file copy, a check):
# - it is created with onboot 0, so a host reboot never starts it;
# - every mpN that binds a HOST path (mp8 = the household's real drives, mp9 = the original guest's
# bootstrap) is removed before anything can start it;
# - every NIC is set link_down=1 (the archive keeps the original's MAC and island address);
# - it is NEVER started by this script.
# The script then reads the config back and fails loudly if any of that is not true.
#
# NOT for a replaced host where the original is gone — there the binds are right; use the agent's DR
# bring-up or RUNBOOK-manual-guest-restore.md §3.
#
# Usage: felhom-restore-beside.sh <scratch-vmid> <volid> <storage>
# e.g. felhom-restore-beside.sh 9299 tmp-dooplex-copy:backup/ct/9201/2026-10-03T19:00:00Z nvme-scratch
#
# Test: scripts/test_felhom_restore_beside.py (a fake pct on PATH).
set -euo pipefail
die() { echo "restore-beside: REFUSED: $*" >&2; exit 1; }
log() { echo "restore-beside: $*"; }
[ $# -eq 3 ] || die "usage: $0 <scratch-vmid> <volid> <storage>"
vmid=$1 volid=$2 storage=$3
[[ "$vmid" =~ ^[0-9]+$ ]] || die "vmid '$vmid' is not a number"
# The restore-test band and the standing scratch are the agent's; a customer guest is never a target.
if [ "$vmid" -ge 990000 ] && [ "$vmid" -le 990009 ]; then die "vmid $vmid is the agent's restore-test band"; fi
[ "$vmid" != 9999 ] || die "vmid 9999 is the agent's standing scratch"
if pct status "$vmid" >/dev/null 2>&1; then die "vmid $vmid already exists — this script never restores over a guest"; fi
log "restoring $volid -> $vmid on $storage with onboot 0 (never started)"
pct restore "$vmid" "$volid" --storage "$storage" --unprivileged 1 --onboot 0
# Strip host-path binds and take the NICs down, BEFORE anything else can touch the guest.
conf=$(pct config "$vmid" --current)
binds=$(printf '%s\n' "$conf" | sed -n -E 's/^(mp[0-9]+): \/.*/\1/p' | paste -sd, -)
if [ -n "$binds" ]; then
log "removing host-path binds: $binds"
pct set "$vmid" --delete "$binds"
fi
printf '%s\n' "$conf" | sed -n -E 's/^(net[0-9]+): (.*)$/\1 \2/p' | while read -r key val; do
case ",$val," in *",link_down=1,"*) continue ;; esac
log "link down: $key"
pct set "$vmid" "--$key" "$val,link_down=1"
done
pct set "$vmid" --onboot 0
# Read back: the CONSEQUENCE, not the commands.
conf=$(pct config "$vmid" --current)
bad=""
printf '%s\n' "$conf" | grep -qx 'onboot: 0' || bad="$bad onboot-not-0"
printf '%s\n' "$conf" | grep -qE '^mp[0-9]+: /' && bad="$bad host-bind-left"
printf '%s\n' "$conf" | grep -E '^net[0-9]+: ' | grep -qv 'link_down=1' && bad="$bad nic-up"
if [ -n "$bad" ]; then
echo "restore-beside: FAILED read-back on $vmid:$bad — do NOT start it; destroy it with: pct destroy $vmid" >&2
exit 2
fi
log "OK: $vmid has onboot 0, no host-path binds, every NIC link_down; it was not started"
log "read it with: pct mount $vmid (then pct unmount $vmid; pct destroy $vmid --purge when done)"
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Tests for felhom-restore-beside.sh (R-834) — a fake `pct` on PATH holds the guest config in a file.
The restored config is the PRODUCTION one (onboot 1, mp8 on the household's drives, mp9 on the
original's bootstrap, NICs up). The script must end with onboot 0, no host-path bind, every NIC
link_down, and never start the guest. Asserted on the consequence: the fake's final config file and
its call log. Red-proof: drop the `pct set --delete` line from the script and test_strips fails.
Run: python3 scripts/test_felhom_restore_beside.py
"""
import os
import pathlib
import stat
import subprocess
import sys
import tempfile
import unittest
SCRIPT = pathlib.Path(__file__).with_name("felhom-restore-beside.sh")
PROD_CONF = """arch: amd64
hostname: demo-hp
onboot: 1
rootfs: nvme-scratch:9299/vm-9299-disk-0.raw,size=16G
mp0: nvme-scratch:9299/vm-9299-disk-1.raw,mp=/var/lib/felhom,backup=1,size=70G
mp8: /mnt/felhom-drives,mp=/mnt/felhom-drives
mp9: /var/lib/felhom-agent/guests/9201/bootstrap,mp=/etc/felhom-bootstrap,ro=1
net0: name=eth0,bridge=vmbr0,hwaddr=BC:24:11:0F:7E:C5,ip=dhcp,type=veth
net1: name=eth1,bridge=vmbr9,hwaddr=BC:24:11:28:B4:F5,ip=169.254.253.2/30,type=veth
"""
# A minimal pct: status (exists iff the conf file exists), restore (writes PROD_CONF, records whether
# --onboot 0 was passed), config, set (--delete / --netN / --onboot), start (recorded — must never run).
FAKE_PCT = r'''#!/usr/bin/env python3
import os, sys
d = os.environ["FAKE_PCT_DIR"]
conf = os.path.join(d, "conf")
with open(os.path.join(d, "calls"), "a") as f:
f.write(" ".join(sys.argv[1:]) + "\n")
cmd = sys.argv[1]
def read():
return [l for l in open(conf).read().splitlines() if l]
def write(lines):
open(conf, "w").write("\n".join(lines) + "\n")
if cmd == "status":
sys.exit(0 if os.path.exists(conf) else 2)
if cmd == "restore":
lines = open(os.path.join(d, "prod")).read().splitlines()
args = sys.argv[3:]
if "--onboot" in args:
v = args[args.index("--onboot") + 1]
lines = [("onboot: " + v) if l.startswith("onboot:") else l for l in lines]
write([l for l in lines if l])
sys.exit(0)
if cmd == "config":
print("\n".join(read()))
sys.exit(0)
if cmd == "set":
lines = read()
a = sys.argv[3:]
while a:
k, v = a[0], a[1]
a = a[2:]
if k == "--delete":
drop = set(v.split(","))
lines = [l for l in lines if l.split(":")[0] not in drop]
else:
key = k.lstrip("-")
lines = [l for l in lines if l.split(":")[0] != key] + [key + ": " + v]
write(lines)
sys.exit(0)
if cmd == "start":
sys.exit(0)
sys.exit(9)
'''
def run(tmp, *args, script=SCRIPT, exists=False):
bindir = pathlib.Path(tmp, "bin")
bindir.mkdir(exist_ok=True)
pct = bindir / "pct"
pct.write_text(FAKE_PCT)
pct.chmod(pct.stat().st_mode | stat.S_IEXEC)
pathlib.Path(tmp, "prod").write_text(PROD_CONF)
if exists:
pathlib.Path(tmp, "conf").write_text(PROD_CONF)
env = dict(os.environ, PATH=f"{bindir}:{os.environ['PATH']}", FAKE_PCT_DIR=tmp)
return subprocess.run(["bash", str(script), *args], env=env, capture_output=True, text=True)
class RestoreBeside(unittest.TestCase):
def test_strips(self):
with tempfile.TemporaryDirectory() as tmp:
r = run(tmp, "9299", "tmp:backup/ct/9201/x", "nvme-scratch")
self.assertEqual(r.returncode, 0, r.stderr + r.stdout)
conf = pathlib.Path(tmp, "conf").read_text()
calls = pathlib.Path(tmp, "calls").read_text()
self.assertIn("onboot: 0", conf)
self.assertNotIn("onboot: 1", conf)
for line in conf.splitlines():
self.assertFalse(line.startswith("mp") and ": /" in line, f"host bind left: {line}")
if line.startswith("net"):
self.assertIn("link_down=1", line)
self.assertIn("mp0:", conf, "a storage volume must stay")
self.assertNotIn("\nstart", "\n" + calls, "the script started the guest")
self.assertIn("--onboot 0", calls.splitlines()[1], "onboot 0 must be set AT restore time")
def test_refuses_an_existing_vmid(self):
with tempfile.TemporaryDirectory() as tmp:
r = run(tmp, "9201", "tmp:backup/ct/9201/x", "nvme-scratch", exists=True)
self.assertNotEqual(r.returncode, 0)
self.assertIn("already exists", r.stderr)
self.assertNotIn("restore", pathlib.Path(tmp, "calls").read_text())
def test_refuses_the_agent_bands(self):
for v in ("990003", "9999", "abc"):
with tempfile.TemporaryDirectory() as tmp:
r = run(tmp, v, "tmp:backup/ct/9201/x", "s")
self.assertNotEqual(r.returncode, 0, v)
def test_readback_catches_a_bind_left_behind(self):
# Red-proof built in: the same script with the --delete line removed must FAIL its read-back.
with tempfile.TemporaryDirectory() as tmp:
broken = pathlib.Path(tmp, "broken.sh")
broken.write_text(SCRIPT.read_text().replace('\tpct set "$vmid" --delete "$binds"\n', "\t:\n"))
self.assertNotEqual(broken.read_text(), SCRIPT.read_text(), "mutation did not apply")
r = run(tmp, "9299", "tmp:backup/ct/9201/x", "s", script=broken)
self.assertEqual(r.returncode, 2, r.stdout + r.stderr)
self.assertIn("host-bind-left", r.stderr)
if __name__ == "__main__":
sys.exit(unittest.main())