Files
felhom-controller/REPORT.md
T

13 KiB
Raw Blame History

REPORT — green-gate restoration + F7 export-page fix + remediation-arc cleanup

Date: 2026-07-20 · Repo: felhom-controller (v0.149.0 → v0.150.0) · Trunk, pushed to main. Origin: the three open hygiene items from the 2026-07-20 remediation arc (REPORT §Green gate, §Observations) + R-53.


1. Baselines used

Repo Baseline @ start Gate After
felhom-controller 4646be1 HEAD == origin/main, tree clean ✔ 9f436c8 (code), then this docs commit
felhom.eu 30fd9d3 HEAD == origin/main 1e11819 (ROADMAP)

Deployed controller before: felhom-controller:0.149.0 (Up, healthy) — matched the baseline. Deployed controller after: felhom-controller:0.150.0 (Up 6 seconds, healthy).


2. Part 1 — the 7 red internal/backup tests

2.1 Classification (1.1) — one class, not several

The v0.149.0 REPORT's device-identity hypothesis is confirmed, and the code said so in Hungarian without anyone having to infer it. TestSharesTier2MirrorsBothClasses failed with:

LastStatus:no_target
LastError:/tmp/…/hdd_1: nincs másik fizikai meghajtó — a 2. mentéshez 2. meghajtó szükséges

Tier-2's whole purpose is to refuse a target on the source disk; it asks system.SamePhysicalDeviceisSameBlockDevicestatA.Dev == statB.Dev (internal/system/mounts_linux.go:225). On DooPlex every t.TempDir() lands on one filesystem, so a fixture's hdd_1 and hdd_2 share an st_dev, the guard correctly refuses, and the test can never reach the behaviour it exists to check. The tests were right and the code was right — the environment could not express the fixture's premise.

Test Failing assertion Root-cause class
TestSharesTier2MirrorsBothClasses shares not mirrored; payload + marker missing; LastStatus != ok device-identity
TestSharesTier2SkipsDeadMountAndContinues "the healthy share must still be mirrored" device-identity
TestSharesTier2ReconcilePrunesRemovedShare precondition: dest dir absent after first run device-identity
TestSharesTier2NotifierNeverLeaksReservedKey "precondition: the notifier should have fired" device-identity
TestTier2V2_MigrationAndMarkerLast flat appdata/ not removed; legs not mirrored; no marker device-identity
TestTier2V2_Reconcile stale sibling / unrelated dirs not reconciled away device-identity
TestTier2V2_NetworkExclusion picked …/sys/felhom-data instead of the local drive device-identity

No test failed for a non-environmental reason — no real defect was found hiding behind the red, so there is no §1.1 STOP finding.

2.2 Fix — tool 2 (minimal seam), once, for all seven

Tool 1 (test-only) was considered and rejected: the only way to get two genuinely distinct devices on this host is to place one fixture drive on /mnt/5_hdd and the other on /tmp, which hard-codes DooPlex's disk layout into the tests — non-hermetic, and it would re-break on any other machine. Tool 3 (t.Skip) would have hidden seven real assertions behind an environment excuse.

The seam (internal/backup/backup.go) — one field + one wrapper, modelled on the package's existing tier2Mirror / tier2SSDFits / sharesPassdbCapture nil-defaulted field seams:

samePhysicalDevice func(a, b string) bool   // nil → system.SamePhysicalDevice

func (m *Manager) sameDevice(a, b string) bool {
	if m.samePhysicalDevice != nil { return m.samePhysicalDevice(a, b) }
	return system.SamePhysicalDevice(a, b)
}

Seam justification (one line, per §9.4): the off-drive predicate is the single environmental fact the tier-2 tests cannot supply on a one-filesystem host; routing it through a nil-defaulted field makes it injectable while leaving production on the identical st_dev call.

Nil-check lives in the wrapper rather than in NewManager deliberately: newSharesEnv builds &Manager{…} by literal, so a constructor-only default would leave the field nil and panic.

Seven call sites now go through m.sameDevice(...): tier2.go ×5 (selectTier2TargetFrom ×3, RunTier2, Tier2Info), tier2_shares.go ×1 (RunSharesTier2), backup.go ×1 (hasOffDriveTarget). Behaviour with a nil seam is byte-for-byte the previous code.

Test-side (device_seam_test.go, new): oneDrivePerSubtree models one drive per directory subtree — two paths share a device only when one contains the other, which is exactly how real mountpoints behave. Installed in the two fixtures only (newTier2V2, newSharesEnv).

Nothing was weakened: the guard still runs and still refuses same-device targets — the same-drive refusal test TestSharesTier2NeverTargetsItsOwnSourceDrive passes under the seam, and it is the test that would catch a fake that simply said "always different".

Surviving t.Skips: none. No test was skipped, deleted, renamed, or had an expected value changed.

2.3 Scenario B — mutation table

Every one of the seven had the defect it guards re-introduced in production code, one at a time, then reverted:

Test Mutation Result
TestSharesTier2MirrorsBothClasses skip the payload mirror to the target FAIL
TestSharesTier2SkipsDeadMountAndContinues stop skipping Disconnected/Decommissioned drives FAIL ✔ (dead-mount share reached a mirror call)
TestSharesTier2ReconcilePrunesRemovedShare tier2ReconcileRoots → no-op FAIL
TestSharesTier2NotifierNeverLeaksReservedKey pass SharesPseudoStack raw on the success path FAIL ✔ (the reserved key reached the notification boundary raw: "_shares")
TestTier2V2_MigrationAndMarkerLast skip the old-flat-appdata/ migration cleanup FAIL
TestTier2V2_Reconcile tier2ReconcileRoots → no-op FAIL
TestTier2V2_NetworkExclusion disable the auto sp.IsNetwork() skip FAIL

Honest note on method: my first attempt at two of these mutated the wrong code path (the mirror-failure notify branch instead of the success one; the unregistered-root skip instead of the disconnected-drive one) and both tests stayed green. That was a bad mutation, not a hollow test — the corrected mutations bite, and the notifier one reproduces that test's own documented red-proof verbatim. Tree verified free of mutation residue afterwards (grep -c "// MUT:" → 0).

2.4 Scenario A — green gate, twice

Run Command Result
build / vet go build ./..., go vet ./... OK
1 go test ./... -count=1 exit 0 — 23 packages ok, 0 FAIL
2 go test ./... -count=1 exit 0 — 23 packages ok, 0 FAIL

grep -c SKIP over run 2 → 0. The gate is genuinely green, not green-by-omission.


3. Part 2 — F7 / R-53, the export page

app_export.html L93 read var domain = '{{.Stack.Meta.Subdomain}}' ? '{{…}}.{{$.CSRFToken}}' : ''; — the session CSRF token where the customer domain belongs.

A spec premise turned out to be wrong, and the fix needed one more line than planned. §5 states {{$.Domain}} is "set by baseData". It is (handlers.go:120) — but exportPageHandler (handler_export.go) never calls baseData; it builds {"Stack", "Drives"} and executeTemplate injects only CSRFField / CSRFToken. Swapping the token alone would have rendered an empty domain — trading a wrong link for a broken one. So the fix is two lines:

  • template: {{$.CSRFToken}}{{$.Domain}};
  • handler: "Domain": s.cfg.Customer.Domain added to the map, mirroring baseData's own line.

csrfH() and the meta-tag CSRF flow are the correct usage and are untouched.

Tests (app_export_domain_test.go, new — v0.149.0's render harness): the var domain line is isolated first so an assertion cannot match the token where it legitimately appears (the meta tag).

Scenario Test Result
C — joined from the customer domain, token absent TestAppExportDomainUsesCustomerDomainNotCSRFToken PASS
C — empty-subdomain branch still '' TestAppExportDomainEmptyWithoutSubdomain PASS

Red-proof: restored the pre-fix template line → both tests fail, showing the defect verbatim:

--- FAIL: TestAppExportDomainUsesCustomerDomainNotCSRFToken
    export link must be built from the customer domain, got:
      var domain = 'photos' ? 'photos.deadbeefcafebabe…' : '';
    the CSRF token must NEVER appear in the export URL, got: … (same line)
--- FAIL: TestAppExportDomainEmptyWithoutSubdomain

Template restored; both green.

Live verification (Scenario C) — authenticated endpoint fetch of the real export page /stacks/immich/export (no browser on DooPlex), ASCII-safe grep per the new gotcha:

line 237:  var domain = 'photos' ? 'photos.demo-felhom.eu' : '';
meta tag:  csrf-token" content="f7f928f1…      ← the token lives here, and only here

The rendered link is correct and the token appears nowhere in it. Note this also confirms the handler-side Domain key, which the render tests alone could not prove.


4. §3.4 — the orphaned dhclient

Evidence first. Exactly one match, on an interface that does not exist:

BEFORE: 1922 /sbin/dhclient -pf /run/dhclient.eth0.pid -lf /var/lib/dhcp/dhclient.eth0.leases eth0
        PPID 1652, started Mon Jul 20 07:25:30 (boot)
interfaces: lo enp1s0 wlp2s0 tailscale0 vmbr0 wg-felhom veth9201i0   ← no eth0
vmbr0: iface vmbr0 inet static / address 192.168.0.162/24            ← no dhclient should run at all
neither /run/dhclient.eth0.pid nor /var/lib/dhcp/dhclient.eth0.leases existed

Killed. Re-checked immediately and again ~25 min later: pgrep -a dhclientnone, no respawn. vmbr0 still 192.168.0.162/24, felhom-agent still active. No other host mutation.


5. Files changed

File Change
internal/backup/backup.go seam field + sameDevice wrapper; 1 call site routed
internal/backup/tier2.go 5 call sites routed through the seam
internal/backup/tier2_shares.go 1 call site routed; unused system import dropped
internal/backup/device_seam_test.go newoneDrivePerSubtree
internal/backup/tier2_v2_test.go, shares_test.go seam injected in the two fixtures (1 line each)
internal/web/templates/app_export.html 1 token: {{$.CSRFToken}}{{$.Domain}}
internal/web/handler_export.go "Domain" added to the export page's data map
internal/web/app_export_domain_test.go new — 2 tests + 2 helpers
CLAUDE.md 2 gotchas in the live-validation section
CHANGELOG.md / CONTEXT.md / REPORT.md v0.150.0 bookkeeping

Commits: felhom-controller 9f436c8 (code) + this docs commit; felhom.eu 1e11819 (ROADMAP).


6. ROADMAP corrections (felhom.eu)

  • R-50 factual correction. The entry claimed an address move needs the cert's SAN set to cover the new address. Read against source (internal/agentapi/client.go L105129): the controller→agent leg sets InsecureSkipVerify: true and replaces chain verification with a VerifyPeerCertificate that does a raw SHA-256 match on the leaf DER. Hostname/SAN never enters verification on this leg, so an address move most likely needs no cert re-issuance — only the endpoint the guest dials. The entry now says so, and still requires the spike to confirm it empirically.
  • R-53 collapsed to its one-liner + SHIPPED (controller v0.150.0), noting the handler-side Domain key as part of the fix.

7. Not done / open

R-50 (durable F1 — spike-first, not started), R-51, R-52, R-39(b)/F6 — all untouched, as scoped.


8. Observations (noticed, not acted on)

  1. Four files in internal/backup are not gofmt-clean at HEADoffbox_3a_test.go, recovery_unit.go, tier2.go, tier2_v2_test.go. I verified this is pre-existing (checked each file's HEAD blob through gofmt -l before my edits) and left them alone: gofmt is not part of the green gate (build/vet/test), and reformatting them would have buried this task's real diff in noise. Worth a one-shot gofmt -w pass in its own commit.
  2. system.SamePhysicalDevice is mount/device-granular, not disk-granular — its own doc comment says two partitions on one physical disk look "different" here, with the agent's durable-id as the stronger guarantee. Unchanged by this work, but it means the tier-2 off-drive promise is partition-level on a box that partitions one disk.
  3. The tier-2 tests silently stopped exercising their subject rather than failing loudly at the premise. Each failed on a downstream assertion ("share was not mirrored") instead of on "this fixture needs two devices and this host has one". A fixture-level precondition check would have turned a week of ambiguous red into one clear message.