Files
homelabstack/docs/superpowers/plans/2026-06-26-r730xd-post-migration-tasks.md
T

381 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# R730XD Post-Migration Host Tasks — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Close out the five remaining post-migration items on the Proxmox host `valhalla-dev`: a permanent fix for virtiofsd FD exhaustion, the hookscript bug, GPU thermal safety, container GPU access, and retiring the last failing pool disk.
**Architecture:** These are five **independent** host-ops tasks against a live single-host Proxmox box (no application code, no test suite). Each is self-contained and can be executed in any order, with two dependencies noted below. "Tests" here are verification commands with expected output; "rollback" replaces "revert commit." Most changes are on the Proxmox host (`.68`) or the `valhalla` VM (`.69`), neither of which is tracked in this git repo — persistence is via on-host files + the vault migration note `[[Server Migration (Proxmox R730XD)]]`.
**Tech Stack:** Proxmox VE (Debian) host, `virtiofsd` 1.13.2, ZFS (`storage1` stripe), EndeavourOS guest VM (id 100), NVIDIA Tesla P100 + driver 580.159.04 + nvidia-ctk 1.19.1, systemd, ipmitool fan control.
## Access & conventions (read first)
- **Host (`valhalla-dev`, Proxmox):** `wsl.exe -- bash -lc 'ssh -o BatchMode=yes -i ~/.ssh/id_ed25519 root@192.168.1.68 "<cmd>"'` — the **WSL** `id_ed25519` (`ginnoir@TELLUS`) key is authorized for root.
- **Guest (`valhalla` VM):** `wsl.exe -- bash -lc 'ssh -o BatchMode=yes -i ~/.ssh/id_ed25519 ginnoir@192.168.1.69 "<cmd>"'` (sudo is NOPASSWD).
- For multi-line remote scripts use the heredoc form: `wsl.exe -- bash -lc 'ssh ... root@192.168.1.68 bash -s' <<'EOF' … EOF` (avoids shell-variable mangling through the WSL→ssh layers).
- **GateGuard:** the first `Bash` and every `Write`/`Edit` requires stating the user request + what the op does/affects before it runs.
## Recommended sequence & dependencies
1. **Task 1** (virtiofsd `--inode-file-handles`) and **Task 2** (hookscript fix) are both the FD-exhaustion fix — do them together. Task 1 is the real fix; Task 2 repairs the fallback. Both require a VM restart, so batch them into one VM bounce.
2. **Task 3** (GPU fan safety) is a **hard prerequisite** for **Task 4** (container GPU) being *used* under load — never run a sustained CUDA job until Task 3 is live (passive P100 + Dell-auto fans that can't see GPU temp = overheat risk).
3. **Task 5** (replace `sdb`) is fully independent and starts a ~11.5 day resilver — run it when power is stable.
---
## Task 1: Permanent virtiofsd FD fix — `--inode-file-handles=prefer`
**Problem:** virtiofsd holds one `O_PATH` file descriptor per inode the guest touches on `/storage1`. With ~78 TB of media scanned by *arr/Plex it climbed to ~1,000,000 open FDs and the guest got `Too many open files` on new `/storage1` access. `--inode-file-handles=prefer` makes virtiofsd use `name_to_handle_at`/`open_by_handle_at` instead, keeping near-zero FDs. Proxmox 9 has **no config option** to pass this flag, so we inject it via a `dpkg-divert` wrapper around the virtiofsd binary (survives package updates).
**Targets (host `.68`):**
- Divert: `/usr/libexec/virtiofsd``/usr/libexec/virtiofsd.real`
- Create: `/usr/libexec/virtiofsd` (wrapper script)
- Restart: VM 100 (cold)
- [ ] **Step 1: Baseline — capture the current launch args and FD count**
```bash
wsl.exe -- bash -lc 'ssh -o BatchMode=yes -i ~/.ssh/id_ed25519 root@192.168.1.68 bash -s' <<'EOF'
echo "=== current virtiofsd cmdline ==="
for p in $(pgrep virtiofsd); do tr '\0' ' ' </proc/$p/cmdline; echo; done
echo "=== worker FD count (the one that climbs) ==="
for p in $(pgrep virtiofsd); do echo "pid $p fds=$(ls /proc/$p/fd 2>/dev/null | wc -l)"; done
EOF
```
Expected: two virtiofsd processes, args include `--shared-dir=/storage1 --xattr`; worker FD count is whatever it has grown to (could be tens of thousands+). Record it.
- [ ] **Step 2: Confirm the binary supports the flag**
```bash
wsl.exe -- bash -lc 'ssh -o BatchMode=yes -i ~/.ssh/id_ed25519 root@192.168.1.68 "/usr/libexec/virtiofsd --help 2>&1 | grep -- --inode-file-handles"'
```
Expected: a line containing `--inode-file-handles=<INODE_FILE_HANDLES>`. (Confirmed present on 1.13.2.)
- [ ] **Step 3: Create the diversion and the wrapper**
```bash
wsl.exe -- bash -lc 'ssh -o BatchMode=yes -i ~/.ssh/id_ed25519 root@192.168.1.68 bash -s' <<'EOF'
set -e
# Move the real binary aside, persistently across apt upgrades
dpkg-divert --add --rename --divert /usr/libexec/virtiofsd.real /usr/libexec/virtiofsd
# Write a wrapper that injects the flag, then execs the real binary with all original args
cat > /usr/libexec/virtiofsd <<'WRAP'
#!/bin/bash
# Proxmox-launched virtiofsd wrapper: force inode-file-handles to stop FD growth.
# See docs/superpowers/plans/2026-06-26-r730xd-post-migration-tasks.md Task 1.
exec /usr/libexec/virtiofsd.real --inode-file-handles=prefer "$@"
WRAP
chmod 0755 /usr/libexec/virtiofsd
echo "=== verify ==="
dpkg-divert --list /usr/libexec/virtiofsd
ls -l /usr/libexec/virtiofsd /usr/libexec/virtiofsd.real
EOF
```
Expected: diversion listed (`diversion of /usr/libexec/virtiofsd to /usr/libexec/virtiofsd.real`), wrapper is a 0755 regular file, `.real` is the original ELF binary.
- [ ] **Step 4: Cold-restart VM 100 so a new virtiofsd launches via the wrapper**
```bash
wsl.exe -- bash -lc 'ssh -o BatchMode=yes -i ~/.ssh/id_ed25519 root@192.168.1.68 bash -s' <<'EOF'
qm shutdown 100 --timeout 180 && qm status 100
qm start 100 && qm status 100
EOF
```
Expected: `status: stopped` then `status: running`.
- [ ] **Step 5: VERIFY — new virtiofsd carries the flag and FDs stay low**
```bash
wsl.exe -- bash -lc 'ssh -o BatchMode=yes -i ~/.ssh/id_ed25519 root@192.168.1.68 bash -s' <<'EOF'
echo "=== flag present in launch args? ==="
for p in $(pgrep virtiofsd); do tr '\0' ' ' </proc/$p/cmdline | grep -o -- '--inode-file-handles=prefer' && echo " (pid $p OK)"; done
echo "=== /storage1 still works in guest ==="
ssh -o BatchMode=yes -o ConnectTimeout=8 -i /root/.ssh/id_ed25519 ginnoir@192.168.1.69 'ls /storage1 >/dev/null 2>&1 && echo storage1-OK || echo storage1-FAIL' 2>/dev/null || echo "(set up host->guest key per Task 3, or check from the admin box)"
EOF
```
Expected: `--inode-file-handles=prefer (pid … OK)` for the worker. If the host→guest key isn't set yet, verify `/storage1` from the admin box instead: `wsl.exe -- bash -lc 'ssh ... ginnoir@192.168.1.69 "ls /storage1 >/dev/null && echo OK"'`.
- [ ] **Step 6: Soak check — FD count after the library gets re-scanned**
Re-run Step 1's FD-count command after a Plex/*arr scan cycle (or ~an hour of normal use). Expected: worker FD count stays in the **hundreds/low-thousands**, not climbing toward 1M. This confirms the fix.
**Rollback:**
```bash
wsl.exe -- bash -lc 'ssh -o BatchMode=yes -i ~/.ssh/id_ed25519 root@192.168.1.68 bash -s' <<'EOF'
rm -f /usr/libexec/virtiofsd
dpkg-divert --remove --rename /usr/libexec/virtiofsd
qm shutdown 100 --timeout 180 && qm start 100
EOF
```
**Persist:** copy the wrapper to this repo under `docs/host/usr-libexec-virtiofsd.wrapper` (reference only) and note the diversion in the vault migration page.
---
## Task 2: Fix the `virtiofsd-limits.sh` hookscript bug
**Problem:** `qm start 100` logs `hookscript error for 100 on post-start: /var/lib/vz/snippets/virtiofsd-limits.sh: line 19: exit: 0: numeric argument required`. Line 19 is `exit 0` — the "0 not numeric" error means a stray carriage return (`\r`) is attached (`exit $'0\r'`). The script is the FD-limit fallback (raises virtiofsd nofile to 10M); after Task 1 it's a belt-and-suspenders safety net, but it should run cleanly.
**Targets (host `.68`):**
- Rewrite: `/var/lib/vz/snippets/virtiofsd-limits.sh` (clean LF, correct content)
- [ ] **Step 1: Confirm the CRLF/stray-char hypothesis**
```bash
wsl.exe -- bash -lc 'ssh -o BatchMode=yes -i ~/.ssh/id_ed25519 root@192.168.1.68 "file /var/lib/vz/snippets/virtiofsd-limits.sh; grep -c $'"'"'\r'"'"' /var/lib/vz/snippets/virtiofsd-limits.sh"'
```
Expected: `file` reports `... with CRLF line terminators` and/or a non-zero `\r` count. (If zero, the bug is a different stray char — proceed to Step 2 anyway; the rewrite fixes it regardless.)
- [ ] **Step 2: Rewrite the script cleanly (LF only)**
```bash
wsl.exe -- bash -lc 'ssh -o BatchMode=yes -i ~/.ssh/id_ed25519 root@192.168.1.68 bash -s' <<'EOF'
cp /var/lib/vz/snippets/virtiofsd-limits.sh /var/lib/vz/snippets/virtiofsd-limits.sh.bak
cat > /var/lib/vz/snippets/virtiofsd-limits.sh <<'SCRIPT'
#!/bin/bash
# Fallback: raise virtiofsd open-file limit after VM 100 starts.
# Primary fix is the --inode-file-handles=prefer wrapper (Task 1); this is a safety net.
VMID="$1"
PHASE="$2"
[ "$VMID" = "100" ] && [ "$PHASE" = "post-start" ] || exit 0
sleep 3
pgrep virtiofsd | while read -r pid; do
prlimit --pid "$pid" --nofile=10000000:10000000 \
&& logger -t virtiofsd-limits "raised nofile for PID $pid to 10M"
done
exit 0
SCRIPT
chmod 0755 /var/lib/vz/snippets/virtiofsd-limits.sh
# guarantee no CRLF crept back in
sed -i 's/\r$//' /var/lib/vz/snippets/virtiofsd-limits.sh
echo "=== syntax check ==="
bash -n /var/lib/vz/snippets/virtiofsd-limits.sh && echo "syntax OK"
file /var/lib/vz/snippets/virtiofsd-limits.sh
EOF
```
Expected: `syntax OK` and `file` reports a plain `Bourne-Again shell script, ASCII text executable` (no CRLF).
- [ ] **Step 3: VERIFY — dry-run the hook and confirm no error**
```bash
wsl.exe -- bash -lc 'ssh -o BatchMode=yes -i ~/.ssh/id_ed25519 root@192.168.1.68 "/var/lib/vz/snippets/virtiofsd-limits.sh 100 post-start; echo exit=$?"'
```
Expected: `exit=0`, no `numeric argument required` error. (It will also bump the live virtiofsd nofile to 10M — harmless.)
- [ ] **Step 4: VERIFY at next real start (do this opportunistically with Task 1's restart)**
After any `qm start 100`, check the journal:
```bash
wsl.exe -- bash -lc 'ssh -o BatchMode=yes -i ~/.ssh/id_ed25519 root@192.168.1.68 "journalctl -u qemu-server@100 -n 30 --no-pager | grep -iE \"hookscript|virtiofsd-limits\" | tail"'
```
Expected: no `hookscript error`; a `virtiofsd-limits: raised nofile …` logger line.
**Rollback:** `cp /var/lib/vz/snippets/virtiofsd-limits.sh.bak /var/lib/vz/snippets/virtiofsd-limits.sh`
---
## Task 3: GPU thermal safety — wire P100 temp into the fan daemon
**Problem:** The P100 is passively cooled. Fans are on **Dell-auto, which cannot read the GPU's temperature** (non-Dell card), and the custom `valhalla-thermal-monitor.service` (the CPU/GPU-reactive controller with a watchdog→Dell-auto failsafe) is currently **disabled** (shut off during the June panics). The daemon already supports GPU temp via a `VM_SSH` env var (`gpu_max()` runs `$VM_SSH 'nvidia-smi --query-gpu=temperature.gpu …'`), but `VM_SSH` is unset → `gpu=off`. We set up host→guest SSH, point `VM_SSH` at it, and re-enable the daemon so GPU temp drives the fans. **This must be live before any sustained GPU workload.**
**Targets:**
- Create (host): `/root/.ssh/id_ed25519` keypair (if absent) for host→guest SSH
- Modify (guest `.69`): `~ginnoir/.ssh/authorized_keys` (append host pubkey)
- Modify (host): `valhalla-thermal-monitor.service` drop-in with `Environment=VM_SSH=…`
- Enable + start: `valhalla-thermal-monitor.service`
- [ ] **Step 1: Generate a host→guest SSH key (host side)**
```bash
wsl.exe -- bash -lc 'ssh -o BatchMode=yes -i ~/.ssh/id_ed25519 root@192.168.1.68 bash -s' <<'EOF'
[ -f /root/.ssh/id_ed25519 ] || ssh-keygen -t ed25519 -N "" -C "valhalla-dev-host->vm" -f /root/.ssh/id_ed25519
echo "=== host pubkey ==="
cat /root/.ssh/id_ed25519.pub
EOF
```
Expected: prints an `ssh-ed25519 … valhalla-dev-host->vm` pubkey. Copy this line.
- [ ] **Step 2: Authorize that key on the guest (bridge via the admin/WSL key)**
Substitute `<HOST_PUBKEY>` with the line from Step 1:
```bash
wsl.exe -- bash -lc 'ssh -o BatchMode=yes -i ~/.ssh/id_ed25519 ginnoir@192.168.1.69 "mkdir -p ~/.ssh && chmod 700 ~/.ssh && grep -qxF \"<HOST_PUBKEY>\" ~/.ssh/authorized_keys 2>/dev/null || echo \"<HOST_PUBKEY>\" >> ~/.ssh/authorized_keys; chmod 600 ~/.ssh/authorized_keys; echo done"'
```
Expected: `done`.
- [ ] **Step 3: VERIFY host→guest SSH + nvidia-smi works non-interactively**
```bash
wsl.exe -- bash -lc 'ssh -o BatchMode=yes -i ~/.ssh/id_ed25519 root@192.168.1.68 "ssh -o BatchMode=yes -o ConnectTimeout=5 -i /root/.ssh/id_ed25519 ginnoir@192.168.1.69 \"nvidia-smi --query-gpu=temperature.gpu --format=csv,noheader,nounits\""'
```
Expected: a bare integer like `56`. If it errors, fix the key before continuing — do not enable the daemon without a working GPU temp source.
- [ ] **Step 4: Set `VM_SSH` on the service via a drop-in**
```bash
wsl.exe -- bash -lc 'ssh -o BatchMode=yes -i ~/.ssh/id_ed25519 root@192.168.1.68 bash -s' <<'EOF'
mkdir -p /etc/systemd/system/valhalla-thermal-monitor.service.d
cat > /etc/systemd/system/valhalla-thermal-monitor.service.d/gpu.conf <<'DROP'
[Service]
Environment=VM_SSH=ssh -o BatchMode=yes -o ConnectTimeout=5 -i /root/.ssh/id_ed25519 ginnoir@192.168.1.69
DROP
systemctl daemon-reload
echo "=== drop-in ==="; systemctl cat valhalla-thermal-monitor.service | grep -i VM_SSH
EOF
```
Expected: the `Environment=VM_SSH=…` line echoed back.
- [ ] **Step 5: Enable + start the daemon**
```bash
wsl.exe -- bash -lc 'ssh -o BatchMode=yes -i ~/.ssh/id_ed25519 root@192.168.1.68 "systemctl enable --now valhalla-thermal-monitor.service; systemctl is-active valhalla-thermal-monitor.service"'
```
Expected: `active`.
- [ ] **Step 6: VERIFY — GPU temp is in the curve, and the failsafe is intact**
```bash
wsl.exe -- bash -lc 'ssh -o BatchMode=yes -i ~/.ssh/id_ed25519 root@192.168.1.68 "journalctl -t thermal-monitor -n 8 --no-pager"'
```
Expected: lines like `cpu=NNC gpu=NNC -> fan NN%` with a **real number** for `gpu=` (not `na`), and the startup line shows `gpu=on`.
- [ ] **Step 7: VERIFY the stop/crash failsafe returns fans to Dell-auto**
```bash
wsl.exe -- bash -lc 'ssh -o BatchMode=yes -i ~/.ssh/id_ed25519 root@192.168.1.68 "systemctl cat valhalla-thermal-monitor.service | grep -iE \"ExecStopPost|trap\"; echo --- ; grep -nE \"0x30 0x30 0x01 0x01|trap\" /usr/local/sbin/valhalla-thermal-monitor.sh"'
```
Expected: confirms an `ExecStopPost`/`trap` that issues `ipmitool raw 0x30 0x30 0x01 0x01` (return to Dell auto). This was previously proven; just re-confirm it's present.
**Rollback:**
```bash
wsl.exe -- bash -lc 'ssh -o BatchMode=yes -i ~/.ssh/id_ed25519 root@192.168.1.68 "systemctl disable --now valhalla-thermal-monitor.service; rm -f /etc/systemd/system/valhalla-thermal-monitor.service.d/gpu.conf; systemctl daemon-reload; ipmitool raw 0x30 0x30 0x01 0x01"'
```
(The final `ipmitool` call forces fans back to Dell auto.)
---
## Task 4: Container GPU access (nvidia-ctk + CDI) in the guest
**Problem:** The guest has `nvidia-ctk` 1.19.1 and a working driver, but Docker has only the `runc` runtime and no CDI spec, so containers can't use the P100. Generate a CDI spec and enable CDI in Docker so workloads request the GPU with `--device nvidia.com/gpu=all`. **Prerequisite: Task 3 must be live before running any non-trivial GPU container.** Restarting Docker bounces all ~73 containers — do it in a maintenance window.
**Targets (guest `.69`):**
- Create: `/etc/cdi/nvidia.yaml` (CDI spec)
- Modify: `/etc/docker/daemon.json` (enable CDI feature)
- Restart: `docker` service
- [ ] **Step 1: Baseline — Docker version (CDI needs Engine ≥ 25) and current runtimes**
```bash
wsl.exe -- bash -lc 'ssh -o BatchMode=yes -i ~/.ssh/id_ed25519 ginnoir@192.168.1.69 "docker version --format \"{{.Server.Version}}\"; docker info 2>/dev/null | grep -iE \"Runtimes|Default Runtime\""'
```
Expected: a server version. If **≥ 25.0**, use CDI (Steps 25). If older, use the classic nvidia runtime instead (see Alternative below).
- [ ] **Step 2: Generate the CDI spec**
```bash
wsl.exe -- bash -lc 'ssh -o BatchMode=yes -i ~/.ssh/id_ed25519 ginnoir@192.168.1.69 "sudo mkdir -p /etc/cdi && sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml && sudo nvidia-ctk cdi list"'
```
Expected: `nvidia-ctk cdi list` shows `nvidia.com/gpu=all` (and `nvidia.com/gpu=0`).
- [ ] **Step 3: Enable the CDI feature in Docker**
```bash
wsl.exe -- bash -lc 'ssh -o BatchMode=yes -i ~/.ssh/id_ed25519 ginnoir@192.168.1.69 bash -s' <<'EOF'
sudo install -d /etc/docker
# merge "features.cdi=true" into daemon.json (create if absent)
sudo python3 - <<'PY'
import json,os
p="/etc/docker/daemon.json"
d=json.load(open(p)) if os.path.exists(p) and os.path.getsize(p) else {}
d.setdefault("features",{})["cdi"]=True
json.dump(d,open(p,"w"),indent=2)
print(open(p).read())
PY
EOF
```
Expected: prints a `daemon.json` containing `"features": { "cdi": true }`.
- [ ] **Step 4: Restart Docker (MAINTENANCE WINDOW — bounces all containers)**
```bash
wsl.exe -- bash -lc 'ssh -o BatchMode=yes -i ~/.ssh/id_ed25519 ginnoir@192.168.1.69 "sudo systemctl restart docker && sleep 20 && echo running:$(docker ps -q|wc -l)"'
```
Expected: Docker restarts; running container count climbs back toward ~72 over the next minute.
- [ ] **Step 5: VERIFY — a CUDA container sees the P100**
```bash
wsl.exe -- bash -lc 'ssh -o BatchMode=yes -i ~/.ssh/id_ed25519 ginnoir@192.168.1.69 "docker run --rm --device nvidia.com/gpu=all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi --query-gpu=name --format=csv,noheader"'
```
Expected: `Tesla P100-PCIE-16GB`. Watch the host thermal log (Task 3) during any longer test to confirm fans respond to GPU temp.
**Alternative (Docker < 25, classic runtime):**
```bash
wsl.exe -- bash -lc 'ssh -o BatchMode=yes -i ~/.ssh/id_ed25519 ginnoir@192.168.1.69 "sudo nvidia-ctk runtime configure --runtime=docker && sudo systemctl restart docker && docker run --rm --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi -L"'
```
Expected: `GPU 0: Tesla P100-PCIE-16GB (UUID: …)`.
**Rollback:** remove `/etc/cdi/nvidia.yaml`, revert `daemon.json` (drop `features.cdi`), `sudo systemctl restart docker`.
---
## Task 5: Replace the last failing disk `sdb` with the staged 4 TB
**Problem:** `storage1` is still `DEGRADED` because `sdb` (4 TB WD Red, serial `WD-WCC4E4TSK1S2`, "too many errors") is failing. A new 4 TB disk (serial `V6HXW23W`) is already installed in the old Slot 2 (currently `sdc`, not in the pool). `zpool replace` swaps it in. This starts a **~11.5 day resilver** over a no-redundancy stripe — run only when power is stable, and keep `sdb` seated during the resilver (pulling it first loses its data).
**Targets (host `.68`):** `storage1` pool — no files.
- [ ] **Step 1: Resolve the new disk's stable by-id (do NOT use bare `sdc` — letters shuffle on reboot)**
```bash
wsl.exe -- bash -lc 'ssh -o BatchMode=yes -i ~/.ssh/id_ed25519 root@192.168.1.68 "ls -l /dev/disk/by-id/ | grep -iE \"V6HXW23W\" | grep -vE \"part\""'
```
Expected: one or more by-id symlinks for serial `V6HXW23W` (e.g. `wwn-0x…` / `ata-…V6HXW23W`). Pick the `wwn-…` or `ata-…` whole-disk link → call it `$NEWDISK` (full path `/dev/disk/by-id/<that>`).
- [ ] **Step 2: Pre-flight — confirm target is `sdb` and new disk is unused**
```bash
wsl.exe -- bash -lc 'ssh -o BatchMode=yes -i ~/.ssh/id_ed25519 root@192.168.1.68 bash -s' <<'EOF'
echo "=== sdb serial (must be WD-WCC4E4TSK1S2) ==="; lsblk -dno NAME,SERIAL | awk '$1=="sdb"'
echo "=== new disk NOT in pool ==="; zpool status -P storage1 | grep -q "V6HXW23W" && echo "ALREADY IN POOL?!" || echo "new disk not in pool (good)"
echo "=== pool baseline ==="; zpool status storage1 | grep -E "state:|scan:"
EOF
```
Expected: `sdb` serial is `WD-WCC4E4TSK1S2`; new disk not in pool; no active scan.
- [ ] **Step 3: Kick off the replace** (substitute `$NEWDISK` from Step 1)
```bash
wsl.exe -- bash -lc 'ssh -o BatchMode=yes -i ~/.ssh/id_ed25519 root@192.168.1.68 "zpool replace storage1 sdb /dev/disk/by-id/<NEWDISK>; echo exit=$?; zpool status storage1 | grep -A4 replacing"'
```
Expected: `exit=0`; status now shows a `replacing-N` vdev with `sdb` (old) and the new disk `(resilvering)`.
- [ ] **Step 4: VERIFY at completion** (poll periodically — reuse the existing resilver-watch cadence)
```bash
wsl.exe -- bash -lc 'ssh -o BatchMode=yes -i ~/.ssh/id_ed25519 root@192.168.1.68 "zpool status storage1"'
```
Expected at completion: `scan: resilvered … with N errors`, the `replacing` vdev is gone, `sdb` is **evicted**, and the new disk is a normal member. Pool state should move toward `ONLINE` (it may still report the 7 pre-existing data errors — see Step 5).
- [ ] **Step 5: After resilver — clear stale errors if no live counters remain**
```bash
wsl.exe -- bash -lc 'ssh -o BatchMode=yes -i ~/.ssh/id_ed25519 root@192.168.1.68 "zpool status -v storage1"'
```
If only the 3 known media files remain and all device READ/WRITE/CKSUM are 0, optionally delete + re-grab those files via the *arr stack, then `zpool clear storage1` to reset the error state. Expected after clear + a scrub: `state: ONLINE`, `errors: No known data errors`.
- [ ] **Step 6: Physically remove the evicted `sdb`** (optional, later)
Use the SES locate-LED method to confirm the bay before pulling (host must be up): match `sdb`'s SAS address to its `sg_ses --dev-slot-num` and `sg_ses --dev-slot-num=<N> --set=ident /dev/sg12`. Verify the drive label reads `WD-WCC4E4TSK1S2` before full removal.
**Rollback (only before/early in resilver):** `zpool detach storage1 <NEWDISK>` cancels the replace and keeps the original `sdb` in service.
---
## Self-review notes
- **Coverage:** all five requested items have a task (fan safety = Task 3, container GPU = Task 4, hookscript = Task 2, `sdb` = Task 5, virtiofs permanent fix = Task 1). ✅
- **Dependencies flagged:** Task 3 before Task 4 under load; Tasks 1+2 share a VM restart.
- **Stable identifiers:** disk ops use serial/by-id, never bare `sdX` (letters shuffled across the week's reboots).
- **Each task has explicit verification + rollback.** No `zpool`/Docker/systemd change lacks a confirmation command.