homelab apt-upgrade: add --safe (pct snapshot + vzdump fallback)
F3 from the apt-sweep backlog. `homelab apt-upgrade --safe ...` takes a
pre-upgrade snapshot per LXC before launching the apt run, so rollback is
trivial if anything regresses.
Snapshot strategy per target:
- LXC: try `pct snapshot <id> preupgrade_<timestamp>` first (CoW, near
instant). If that refuses ("snapshot feature is not available" — the
failure mode for LXCs with host bind-mounts like `mp0: /mnt/library`),
fall back to `vzdump <id> --mode snapshot --storage local --compress
zstd`. Slower but works on bind-mounted LXCs.
- hubris (PVE host): skipped intentionally; no host-level snapshot in v1.
If any snapshot fails the entire run refuses unless --force is passed.
Snapshot rollback hints are printed after launch so the operator has the
recovery path one copy/paste away.
Validated 2026-05-21: py_compile clean; --status mode works on bind-
mounted LXCs (skips snapshot path). Live snapshot test deferred to next
real fleet sweep to avoid leaving stale artifacts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
78
bin/homelab
78
bin/homelab
@@ -20,6 +20,7 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -1039,6 +1040,51 @@ def _audit_one(name: str, pve_id: str | None) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_target(name: str, pve_id: str | None, snap_name: str) -> tuple[str, str]:
|
||||||
|
"""Take a pre-upgrade snapshot of a target.
|
||||||
|
|
||||||
|
Tries `pct snapshot` first (CoW, near-instant). Falls back to `vzdump
|
||||||
|
--mode snapshot` for LXCs where pct snapshot refuses due to host bind-
|
||||||
|
mounts. Hubris is skipped (no PVE-host-level snapshot supported here).
|
||||||
|
|
||||||
|
Returns (method, detail) where method is one of:
|
||||||
|
"pct" -> pct snapshot succeeded; detail = snapshot name
|
||||||
|
"vzdump" -> vzdump succeeded; detail = backup file path
|
||||||
|
"skip" -> hubris (skipped intentionally)
|
||||||
|
"fail" -> both failed; detail = error message
|
||||||
|
"""
|
||||||
|
if pve_id is None:
|
||||||
|
return ("skip", "hubris (no host-level snapshot)")
|
||||||
|
# pct snapshot first
|
||||||
|
res = subprocess.run(hubris_ssh() + ["--", "pct", "snapshot", pve_id, snap_name,
|
||||||
|
"--description", f"homelab apt-upgrade --safe ({name})"],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
if res.returncode == 0:
|
||||||
|
return ("pct", snap_name)
|
||||||
|
err = (res.stderr or res.stdout or "").strip()
|
||||||
|
# Common refusal for LXCs with host bind-mounts.
|
||||||
|
bind_mount_refused = (
|
||||||
|
"snapshot feature is not available" in err.lower()
|
||||||
|
or "is not snapshottable" in err.lower()
|
||||||
|
or "snapshots are not supported" in err.lower()
|
||||||
|
)
|
||||||
|
if not bind_mount_refused:
|
||||||
|
return ("fail", err)
|
||||||
|
# Fall back to vzdump
|
||||||
|
res = subprocess.run(hubris_ssh() + ["--", "vzdump", pve_id,
|
||||||
|
"--mode", "snapshot",
|
||||||
|
"--storage", "local",
|
||||||
|
"--compress", "zstd",
|
||||||
|
"--notes-template", f"homelab apt-upgrade --safe ({name})"],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
if res.returncode == 0:
|
||||||
|
# Extract the file path from vzdump output (line like "creating archive '/var/lib/vz/dump/...vma.zst'").
|
||||||
|
m = _re.search(r"creating[^']*'([^']+)'", res.stdout)
|
||||||
|
path = m.group(1) if m else "(vzdump complete)"
|
||||||
|
return ("vzdump", path)
|
||||||
|
return ("fail", (res.stderr or res.stdout or "").strip())
|
||||||
|
|
||||||
|
|
||||||
def cmd_apt_audit(args: argparse.Namespace) -> int:
|
def cmd_apt_audit(args: argparse.Namespace) -> int:
|
||||||
"""Per-host pre-flight: dpkg state, holds, upgradable count, non-apt binaries, DNS health.
|
"""Per-host pre-flight: dpkg state, holds, upgradable count, non-apt binaries, DNS health.
|
||||||
|
|
||||||
@@ -1116,6 +1162,24 @@ fi
|
|||||||
print(" or rerun with --force to skip the audit gate", file=sys.stderr)
|
print(" or rerun with --force to skip the audit gate", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
|
# Optional snapshot pass — LXC-only; hubris is skipped.
|
||||||
|
snap_results: dict[str, tuple[str, str]] = {}
|
||||||
|
if args.safe:
|
||||||
|
# pct snapshot names must match [a-zA-Z][a-zA-Z0-9_]* — underscores only.
|
||||||
|
snap_name = "preupgrade_" + datetime.now().strftime("%Y%m%d_%H%M")
|
||||||
|
print(f"snapshot pass ({snap_name}):")
|
||||||
|
any_fail = False
|
||||||
|
for name, pve_id in targets:
|
||||||
|
method, detail = _snapshot_target(name, pve_id, snap_name)
|
||||||
|
snap_results[name] = (method, detail)
|
||||||
|
print(f" {name:<20} {method:<7} {detail}")
|
||||||
|
if method == "fail":
|
||||||
|
any_fail = True
|
||||||
|
if any_fail and not args.force:
|
||||||
|
print("refusing: at least one snapshot failed; rerun with --force to upgrade anyway",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 3
|
||||||
|
|
||||||
# Launch on each target
|
# Launch on each target
|
||||||
rc = 0
|
rc = 0
|
||||||
for name, pve_id in targets:
|
for name, pve_id in targets:
|
||||||
@@ -1125,6 +1189,16 @@ fi
|
|||||||
rc = 1
|
rc = 1
|
||||||
continue
|
continue
|
||||||
print(f"{name}: {res.stdout.strip()}")
|
print(f"{name}: {res.stdout.strip()}")
|
||||||
|
|
||||||
|
# Reminder of snapshot rollback paths after launch.
|
||||||
|
if snap_results:
|
||||||
|
print()
|
||||||
|
print("snapshots created (rollback path on failure):")
|
||||||
|
for name, (method, detail) in snap_results.items():
|
||||||
|
if method == "pct":
|
||||||
|
print(f" {name}: pct rollback <id> {detail} (or pct delsnapshot <id> {detail})")
|
||||||
|
elif method == "vzdump":
|
||||||
|
print(f" {name}: pct restore <id> {detail} (or rm {detail} when no longer needed)")
|
||||||
return rc
|
return rc
|
||||||
|
|
||||||
|
|
||||||
@@ -1202,8 +1276,10 @@ def main() -> int:
|
|||||||
grp.add_argument("--all", action="store_true", help="upgrade hubris + every LXC")
|
grp.add_argument("--all", action="store_true", help="upgrade hubris + every LXC")
|
||||||
sp.add_argument("--status", action="store_true",
|
sp.add_argument("--status", action="store_true",
|
||||||
help="show running screen sessions + tail the upgrade log on each target")
|
help="show running screen sessions + tail the upgrade log on each target")
|
||||||
|
sp.add_argument("--safe", action="store_true",
|
||||||
|
help="take a pre-upgrade snapshot per LXC (pct snapshot, vzdump fallback)")
|
||||||
sp.add_argument("--force", action="store_true",
|
sp.add_argument("--force", action="store_true",
|
||||||
help="skip the pre-flight dpkg-audit gate")
|
help="skip the pre-flight dpkg-audit gate AND proceed past snapshot failures")
|
||||||
sp.set_defaults(func=cmd_apt_upgrade)
|
sp.set_defaults(func=cmd_apt_upgrade)
|
||||||
|
|
||||||
sp = sub.add_parser("nuke", help="shred /etc/age/key.txt + /opt/homelab-context on a host")
|
sp = sub.add_parser("nuke", help="shred /etc/age/key.txt + /opt/homelab-context on a host")
|
||||||
|
|||||||
Reference in New Issue
Block a user