first commit

Claude-Session: https://claude.ai/code/session_014PM3SXLZ3PUivkMQ8QXTxT
This commit is contained in:
James Coleman 2026-08-21 09:34:09 -05:00
commit b155475c8c
3 changed files with 488 additions and 0 deletions

169
kvm-zvol-restore/SKILL.md Normal file
View file

@ -0,0 +1,169 @@
---
name: kvm-zvol-restore
description: Restore KVM/libvirt VM disks from borg backups onto ZFS zvols exported over iSCSI. Use when asked to restore, roll back, or recover a VM's drive to a previous date, or to inspect what VM disk backups are available. Covers the tama/kiki zvol+iSCSI setup backed up by kvm-backup-zvol-iscsi.sh.
---
# Restoring KVM VM disks from borg to ZFS zvols
## Topology
Two hosts, and it matters which one you run each command on:
| Host | Name | Role |
|---|---|---|
| `10.0.0.5` | `kiki` | libvirt host (runs the VMs) **and** holds the borg repo at `/media/Storage/Backup/kvm` |
| `10.0.0.6` | `tama` | ZFS server: owns the zvols (`tank/kvm/*`), exports them via iSCSI (LIO/targetcli), runs the backup script |
The backup script is `/usr/local/bin/kvm-backup-zvol-iscsi.sh` **on tama (.6)**, not on kiki.
It discovers domains via `virsh` locally and on `REMOTE_HOSTS` (`kiki`, `gaming-pc`), maps each
iSCSI-backed disk to a local zvol, snapshots it, and `dd`s the snapshot device into borg.
VMs reach their disks over the **storage network** `10.0.100.6:3260`, not `10.0.0.6`.
## What the backups actually are
Raw whole-device images (`dd` of a ZFS snapshot block device) piped into borg as **stdin**, so each
archive holds exactly one item named `stdin`. There is no filesystem structure inside — restoring
means writing the stream back over the whole block device.
Archive naming:
```
<domain>-<target-dev>-<YYYY-MM-DDTHH:MM:SS> disk image, e.g. centos7-vda-2026-08-14T07:38:41
<domain>-xml-<YYYY-MM-DDTHH:MM:SS> virsh dumpxml output
```
Retention is `--keep-daily 7 --keep-weekly 4 --keep-monthly 6`, so anything older than a week
only exists at weekly/monthly granularity. Check before promising a specific date.
## Gotchas that have actually bitten
1. **Archive names contain colons.** `centos7-vda-2026-08-14T07:38:41`. Never use `:` as a field
separator when building job lists in a shell script — it silently truncates the name to
`centos7-vda-2026-08-14T07` and borg reports "Archive does not exist". Use `|`.
2. **`borg list --format "{archive}"` fails on an archive.** `{archive}` is a repo-level key; using
it while listing archive *contents* errors out and looks exactly like a missing archive.
To test existence use `borg info "::$ARCHIVE"`.
3. **The device name is not always `vda`.** `centos6` is `sdb`; `centos7`/`rocky9` are `vda`.
Always read the device from the archive name or `virsh domblklist`.
4. **`virsh domblklist` shows CDROMs with source `-`.** Skip those; only `-iscsi-` paths are backed up.
5. **Domain name vs zvol name is case-insensitive.** IQN target names are lowercased
(`iqn...tama:centos7-2`) while the zvol may be `tank/kvm/CentOS7-2`. There are also stale archives
from an older capitalized `Centos7` domain — don't confuse them with current `centos7`.
6. **CentOS 6 ignores `virsh shutdown`** (no/old acpid). It may need several minutes or a
`virsh destroy`. Never start writing until `virsh domstate` says `shut off`.
7. **Don't run during the backup window** (~07:2407:55 daily). The backup holds
`/tmp/backup-zvol-iscsi.pid` on tama and would be snapshotting the same zvols.
## Procedure
### 1. Shut down the VMs (on kiki, .5)
```bash
ssh root@10.0.0.5 'for d in vm1 vm2; do virsh shutdown "$d"; done'
# then poll until every one reports "shut off" — do not proceed otherwise
ssh root@10.0.0.5 'for i in $(seq 1 30); do s=$(virsh domstate vm1); [ "$s" = "shut off" ] && break; sleep 5; done; virsh domstate vm1'
```
If a guest won't go down gracefully, confirm with the user before `virsh destroy`.
### 2. Find the archives (from tama, .6)
```bash
ssh root@10.0.0.6 'export BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK=yes
borg list --format "{archive}{TAB}{time}{NL}" root@10.0.0.5:/media/Storage/Backup/kvm' \
| grep -Ei "^(vm1|vm2)-"
```
Then record each archive's exact byte size — it must equal the zvol's size:
```bash
borg info "::$ARCHIVE" # "Original size" column, or borg list shows the stdin item size
blockdev --getsize64 /dev/zvol/tank/kvm/<zvol>
```
### 3. Map disks to zvols
```bash
ssh root@10.0.0.5 'virsh domblklist <domain>' # dev -> /dev/disk/by-path/...-iscsi-iqn...:<target>-lun-0
ssh root@10.0.0.6 'zfs list -o name,volsize,volmode -r tank/kvm -d 1'
```
The zvol is the IQN target name after the last colon, matched case-insensitively under `tank/kvm/`.
### 4. Optional rollback point
A pre-restore `zfs snapshot tank/kvm/<z>@pre-restore-<date>` is cheap insurance, **but ask first**
it is not always wanted, and the daily backup snapshots (`SNAPSHOTS_KEEP=2`) may already cover it.
### 5. Restore (on tama, .6)
Run detached — 500 GiB takes ~20 min at ~475 MB/s, and an SSH drop must not kill it.
Guard every device write behind a size check and an existence check.
```bash
#!/bin/bash
export BORG_REPO="root@10.0.0.5:/media/Storage/Backup/kvm"
export BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK=yes
export BORG_RELOCATED_REPO_ACCESS_IS_OK=yes
set -o pipefail
# zvol|archive|expected_bytes (pipe-delimited: archive names contain colons)
JOBS=(
"centos7|centos7-vda-2026-08-14T07:38:41|536870912000"
)
for J in "${JOBS[@]}"; do
IFS="|" read -r ZVOL ARCHIVE EXPECT <<<"$J"
DEV="/dev/zvol/tank/kvm/$ZVOL"
echo "=== $(date "+%F %T") restoring $ARCHIVE -> $DEV ($EXPECT bytes)"
[[ -b "$DEV" ]] || { echo "FAIL: $DEV not a block device"; exit 1; }
ACTUAL=$(blockdev --getsize64 "$DEV")
[[ "$ACTUAL" == "$EXPECT" ]] || { echo "FAIL: $DEV is $ACTUAL, archive is $EXPECT"; exit 1; }
borg info "::$ARCHIVE" >/dev/null 2>&1 || { echo "FAIL: archive $ARCHIVE not found"; exit 1; }
borg extract --stdout "::$ARCHIVE" \
| dd of="$DEV" bs=4M iflag=fullblock conv=fsync status=progress \
|| { echo "FAIL: restore of $ARCHIVE failed"; exit 1; }
echo "=== $(date "+%F %T") completed $ZVOL"
done
echo "=== $(date "+%F %T") ALL RESTORES COMPLETE"
```
Launch and watch:
```bash
ssh root@10.0.0.6 'nohup setsid /root/restore.sh > /root/restore.log 2>&1 </dev/null & echo $!'
ssh root@10.0.0.6 'grep -aE "^===|FAIL:" /root/restore.log; tail -c 120 /root/restore.log'
```
`iflag=fullblock` matters: without it `dd` can do short reads from the pipe and write a short image.
Writing to `/dev/zvol/...` while LIO exports it is safe **only** because the guest is powered off —
LIO takes no exclusive lock, so a running VM would race and corrupt.
### 6. Flush stale initiator cache (on kiki, .5)
The zvol changed underneath the iSCSI initiator, so kiki's block-layer cache for that LUN is stale.
Before booting, flush it:
```bash
ssh root@10.0.0.5 'blockdev --flushbufs /dev/disk/by-path/ip-10.0.100.6:3260-iscsi-iqn.2026-03.im.gec.tama:<target>-lun-0'
```
A `iscsiadm -m node -T <iqn> --rescan`, or logout/login of the session, is the heavier alternative.
### 7. Boot and verify
```bash
ssh root@10.0.0.5 'virsh start <domain>; virsh domstate <domain>'
```
Restoring the domain XML is a **separate** decision — the `-xml-` archives exist, but rolling back a
disk rarely needs the definition rolled back too, and doing so can undo unrelated config. Ask first:
```bash
borg extract --stdout "::<domain>-xml-<ts>" > /tmp/<domain>.xml # then virsh define
```

165
unit-testing/SKILL.md Normal file
View file

@ -0,0 +1,165 @@
---
name: unit-testing
description: My unit-testing style. Use whenever writing, reviewing, or pruning unit tests in any project, before writing the first test function. Covers what deserves a unit test, what must not be tested, and the required mechanics (testify, table-driven, real dependencies first, mocks only as a last resort).
---
# Unit testing style
I want few, high-value tests, in two tiers.
1. **Unit tests.** Small, fast, and mainly covering serialization boundaries:
marshal/unmarshal, parse/encode, round-trips, and on-disk or wire-format
invariants.
2. **Integration tests.** The primary safety net. They exercise the project as
a whole against real systems (real backends, real servers, real databases)
and usually live in a separate suite behind build tags, run with
`make test-integration`.
More tests are not better. I have had redundant tests removed by the dozens
("Slim down the tests", "remove simple logic tests"). Write fewer tests than
feels natural, and never pad coverage.
## What deserves a unit test
- **Serialization and parsing.** JSON/YAML marshal and unmarshal round-trips,
config parsing, enum-to-string stability (a backup must stay readable even
if an iota constant gets reordered). For codecs, encode, decode, and
compare, with the invalid-input case paired in the same test.
- **External-format invariants.** Scan/rewrite behavior against fixtures
shaped exactly like what the external tool or system really emits,
especially the shapes an integration run can't easily seed.
- **Contract tests against an external system's declared interface.** Assert
the encoder's output against what the external system's spec or
introspection declares; the declared signature or schema string can itself
be the assertion. Include tolerance tests: truncated or old-version input
must not panic or misalign.
- **Facts about the outside world** the code depends on: a real tool's exact
output spelling, a protocol quirk, a version-capability boundary pinned to
the real deployment targets. These stay even when the test is four lines.
- **Real algorithmic logic** with a non-obvious result: coverage and overlap
relations, canonicalization that guards against churn, index math, scoring.
One representative test per axis, never an exhaustive fan-out.
- **API-boundary behavior** of a service. Drive the real handler with
`httptest` and assert observable effects (status codes, files written or
removed), not internals.
## What must NOT be unit tested
- **Simple logic.** Trivial predicates, single-field identity assertions
("Equal ignores field X"), tests that restate a small helper's own body.
These are useless. They don't test anything worthwhile.
- **Anything the integration suite already proves live.** Lifecycle
operations (add, remove, sync, backup, restore) that a named integration
subtest pushes through a real backend don't get duplicated in miniature
with fakes.
- **Pure reject-only guards**, meaning a lone `require.Error` on one
validation. The real system fails loudly anyway. A rejection test earns its
place only when it also pins a positive boundary case next to the
rejection.
- **Mock-based tests of internal collaborators.** Mock-manager style tests
get deleted. Mocks aren't banned outright, see "Real dependencies" below,
but mocking your own internals never qualifies.
For borderline cases, one question decides: does the test encode an external
fact? If it pins a real spelling, format, or quirk of a system outside this
codebase, keep it. If it only restates this codebase's own code, delete it. A
bug there fails loudly downstream.
These rules govern what gets committed, not how to work. A throwaway test to
confirm behavior while developing or debugging is fine, even for simple
logic. Write it, run it, learn from it, then delete it before the work is
done. It must not land in the suite.
## Mechanics
- Go tests use `testing` plus `github.com/stretchr/testify`, never
hand-rolled `if got != want`. Some older projects predate this, but every
migration has been toward testify. Split the roles: `require` for setup and
anything that invalidates the rest of the test, `assert` for the actual
claims, so one run reports every wrong field.
- Assertion messages are sentences that carry the reason, not labels:
`assert.Equal(t, 1, *calls, "a ready service must not be polled twice")`.
In tables, the message identifies the case: `"signature mismatch for %s"`.
- Go table-driven wherever more than one input shape exists: anonymous struct
slice with `name`, inputs, and `want` fields. Rows may carry a `why` column
so the table holds the rationale ("leading zeros do not change the value").
`t.Run(tc.name, ...)` is optional when the assert message already names the
case. Sub-test names outside tables read as full English claims:
`t.Run("metric zero falls back to default", ...)`.
- Every test gets a doc comment stating what invariant it locks and why,
ideally naming the real-world source: the distro or tool version, the
kernel flag, or the regression it pins. Spell out arithmetic inline so an
expected number isn't magic; the comment shows the terms that add up to it.
- Test helpers are small, local to the file, `t.Helper()`-marked, use
`t.Cleanup` to restore anything they change, and build realistic fixtures
laid out exactly as the real system writes them.
- Re-read from the source of truth before asserting. Re-construct the backend
from disk after a write, so the test exercises the written config rather
than in-memory state. Re-query the kernel rather than trusting a cached
handle.
## Real dependencies, never substitutes
- HTTP: `httptest` servers, or a real TLS listener with self-signed test
certs when the TLS path itself matters. Never live external services. Live
URLs get replaced with a local server plus fixture repos built with docker
dummy packages.
- Protocol peers: a real in-process server speaking the real wire protocol at
the boundary, fed fixture files authored in the protocol's native format,
or a fake that decodes real requests and streams captured responses. Drive
error paths by mutating the fake mid-test, walking happy, data-missing, and
unreachable phases with one golden file per phase.
- External commands: fake them as executable scripts on disk, selected via
config paths or a prepended `$PATH` entry. The exec boundary stays real.
- Databases: the real database engine in a container (testcontainers), never
a lighter engine standing in for the one production runs.
- Filesystem: real files in `t.TempDir()`, byte-for-byte like production.
Functions that walk absolute paths take a root-prefix parameter so a
tempdir can stand in for `/`.
- System and network APIs: a network namespace (with `runtime.LockOSThread()`,
dummy links, and `t.Skip` when not root) lets tests drive the real kernel
safely. A fresh namespace per test is the isolation.
- OS backends beyond that belong to the integration suite, which may be a
cross-compiled test binary shipped to a target host. Destructive
integration tests operate only on throwaway PID-named objects, never a
system default or live resource whose loss could cut the SSH session.
Cleanup uses `context.Background()` rather than the
possibly-cancelled test context, and pre-deletes idempotently.
Fixtures: prefer real captured output, checked into `testdata/` with
provenance noted (which host, which tool version). Assert on specific parsed
fields with a comment per quirk, rather than golden-diffing everything.
Synthetic samples are a fallback for conditions no real capture shows, and
get labeled synthetic. Whole-output golden files (metrics exposition,
rendered configs) are compared with a diff that prints the golden path on
mismatch. When two tools report the same underlying state, cross-check
their fixtures against each other.
Mocks are okay only when there is no safe way to exercise the real thing,
when no isolation mechanism (namespace, container, tempdir, httptest,
in-process protocol server) makes the real dependency safe or practical. The
order is real thing, then real thing isolated, then mock as last resort.
Never mock to dodge the setup cost of an isolation mechanism that exists.
When a test double is warranted, hand-write it at the external boundary: a
call-recording fake of the external interface, a plain function field, a
closure with a call counter. No gomock, no mockery, no testify-mock, and no
interfaces introduced solely for testing. Small documented seams in
production code are fine instead: a sentinel test value, a package-level
poll-interval knob swapped and restored via `t.Cleanup`, an override
timestamp field for deterministic output.
## Workflow
- Wire testing through the Makefile (`make test`, `make test-integration`)
and document that in the README. Verify tooling (schema and config
validation) gets its own command and runs under `make test`.
- Integration suites sit behind build tags (`//go:build <proj>_integration`)
so plain `go test ./...` stays dependency-free. Capability differences are
handled with `t.Skip` in both directions: an old server skips the new
feature, a new server skips the legacy guard.
- After adding or removing tests, `gofmt`, `go vet` (including other-GOOS
vets for platform-gated files), and the make target must pass. Confirm
removed tests left no dead helpers or unused imports.
- When asked to add tests, propose the small set that fits the rules above.
When touching an existing suite, look for tests that violate these rules
and offer to prune them.

154
unslop/SKILL.md Normal file
View file

@ -0,0 +1,154 @@
---
name: unslop
description: Cut AI tells from anything written in my projects. Apply to all prose I ship, including code comments, godoc, error strings, log lines, Svelte UI copy, docs, READMEs, and commit messages. Always apply.
---
# Unslop
Edit text so it reads like a person who works on this codebase wrote it. This
covers every writing surface in a Go/Svelte project: godoc and code comments,
error and log messages, UI copy, markdown docs, commit messages, and PR
descriptions.
## Process
1. Scan for the patterns below.
2. Rewrite. Preserve meaning, match the surrounding voice.
3. Self-audit: "What makes this obviously AI generated?" Fix remaining tells.
## Voice
- Have opinions. State what happened and what it means, don't neutrally list.
- Vary rhythm. Short sentences. Then longer ones that take their time.
- Be specific. Not "this can cause issues" but "the client stays disconnected
and the request times out after 2m".
- If a sentence could appear unchanged in another project's docs, it says
nothing about this one. Cut it.
- Match the existing voice. When two docs cover sibling features, they should
read like the same person wrote both: same structure, same phrasing for the
same concepts, same level of detail.
## Go code comments
- Every commented element (func, struct, var) gets a comment whose first
word is the element's name, written as a complete sentence ending in a
period. `// ParseSize converts a human-readable size ("2.5G") to bytes.`
- A comment states a constraint the code can't show, a real-world fact, or
the regression it guards. Never a narration of the next line, where the
code came from, or why the change is correct. That last kind is
review-speak and turns into noise the moment the change merges.
- The exception to the no-narration rule: a function with multiple logical
steps uses short section comments to label each phase. "Validate input.",
"Persist to database.", "Build response." Those segment the function;
they don't narrate individual lines.
- Operational tone, written for someone maintaining or debugging the
system. No conversational language, jokes, or speculative notes.
- Name the external source when there is one: the distro, the tool version,
the kernel behavior. Naming the exact distro quirk the branch exists for
beats "handle legacy services".
- Spell out arithmetic so numbers aren't magic:
`// reallocated 8 -> 40+40 capped 80; total 89 -> REPLACE_SOON.`
- Match the file's comment density. A file with sparse comments doesn't get
a narrated paragraph bolted onto one function.
## Errors and logs
- Error strings: lowercase, no trailing punctuation, no "Error:" prefix.
Chain context with colons or `%w`: `syncing repo: connecting to source:
connection refused`. Say what was being attempted, not "failed to failed
to".
- An error the operator will read should point at the fix when one is known:
"set license.key in the config, or pass --license-key".
- Log messages: terse, lowercase, structured fields for the variables, no
exclamation marks, no emoji. `task executed` with fields beats "Task was
executed successfully!".
- Never log or print chatbot phrases: "Successfully completed!", "Oops!",
"Something went wrong :(".
## Svelte and UI copy
- Sentence case everywhere: buttons, labels, headings, menu items. "Import
test", not "Import Test".
- Error states name what failed and what to do next. Not "Oops! Something
went wrong" but "the server is unreachable; check that the service is
running".
- Empty states say what will appear there or how to add the first item. No
cutesy filler, no illustrations-with-quips tone.
- No exclamation marks in UI text. No emoji in buttons, headings, toasts, or
placeholder text.
- Labels are literal. A field for a slug says "Slug", not "Your unique
identifier". Tooltips explain the consequence of the setting, not restate
its name.
- No marketing adjectives in product UI: "powerful", "seamless", "blazing
fast". The interface should describe, not sell.
## Docs and READMEs
- Say the thing directly. If a sentence can be replaced by a plainer one
that says the same thing, replace it.
- A doc stands alone for its task. Don't make the reader open a second doc
to use the first one; inline the few lines they need.
- Don't restate. If an example already appeared in an earlier section, later
sections reference it, they don't duplicate it.
- Don't explain external systems. A doc about configuring a third-party
integration covers how to configure it here and why, not how the third
party's API works.
- Structure follows the sibling doc. Same heading shapes, same table format,
same key: value conventions, so the docs read as one set.
- README covers how to build and test via the Makefile, actual usage, and
real configuration. No badge walls, no "Features" bullet lists of
adjectives, no roadmap promises.
- Headings in sentence case. No decorative emoji. No bold on every proper
noun.
## Commits and PRs
- Imperative, plain, specific: "Switch unit tests to testify", "Replace
polling with retry backoff". No "This PR introduces...", no "Enhanced",
no scope theater.
- The body states what changed and why, names the bug or behavior pinned,
and stops. No summary tables of the diff, no "Testing" section that just
says tests pass.
## Language tells
- AI vocabulary: additionally, crucial, delve, enhance, fostering, garner,
intricate, landscape (abstract), pivotal, robust, seamless, showcase,
streamline, tapestry, testament, underscore, utilize, leverage. Use the
plain word.
- Fancy ways to say "is": "serves as", "stands as", "boasts", "features".
Say "is" or "has".
- "Not just X, but Y." State the point directly.
- Rule of three. Don't force ideas into groups of three; use the natural
number.
- Filler: "in order to" becomes "to", "due to the fact that" becomes
"because", "it is important to note that" gets deleted.
- Hedging stacks: "could potentially possibly" becomes "may".
- Abstract metaphor nouns: substrate, wedge, vector, nexus, primitive (as
noun), harness (as metaphor), surface (as in "API surface"), scaffolding
(as metaphor), paradigm, north star, flywheel. Pick the concrete word.
- Say what it does, not how it feels. "SQL you can read" names a feeling;
"`.toSQL()` returns the exact string sent to the database" names a
mechanism. If a sentence can't be restated as a fact, instruction, or
number, cut it.
- Active voice. "The compiler validates queries", not "queries are
validated". Passive only when the actor is unknown or doesn't matter.
## Punctuation and style tells
- No em dashes, anywhere. Use periods or commas. Don't swap in parentheses
or en dashes as a substitute; end the sentence instead.
- Colons before a list or example only, not as mid-sentence connectors.
- No arrow chains in prose ("A → B → fails"). Write the sentence. Arrows
are fine inside code and code comments where they're the local idiom.
- Straight quotes, not curly.
- Bold sparingly. A bold lead-in that names an item and ends in a period is
fine; a bold label with a colon that restates the line is a tell.
- No decorative emoji in headings, bullets, commits, or code.
## Chat artifacts
Never let these into committed text: "I hope this helps!", "Let me know
if...", "Great question!", "You're absolutely right!", "Found the smoking
gun!", cutoff disclaimers like "while specific details are limited". Respond
directly, write directly.