Changelog¶
All notable changes to heezy infrastructure and services.
[2026-10-02] Daily Discord alert for watchable hockey games¶
Added¶
hockey-notify, a CronJob at 11:00 America/New_York. Posts the day's NHL slate: the Live TV channels that actually play each game (candidates by network code or guide listing, each test-played through its Threadfin), or the league's broadcasters for games the tuners do not carry. Posts every day. See Live TV- FOX affiliates on the US tuner, for Stars and Kings games on KDFW and KTTV
Fixed¶
- Live TV tunnels looping on NordVPN
AUTH_FAILED. gluetun picked Dedicated IP servers at random. The US Threadfin tunnel now uses standard servers only and restarts itself after about 3 minutes down
[2026-10-01] Live TV on nebula Plex through two VPN tuners¶
Added¶
- Two Threadfin tuners feed nebula Plex live TV from iptv-org's public playlist, filtered to
sports networks.
threadfin-vpnexits in the US (69 channels: NBC, ABC and CBS affiliates, ESPN, FS1, NHL/NFL/NBA/MLB channels) andthreadfin-ca-vpnexits in Canada (TSN The Ocho, TVA, MSG). Each is a separate Plex DVR. See Live TV iptv-epg, an in-cluster iptv-org guide grabber. Its channel list is generated from the Threadfin seeds byheezy-k8s/scripts/build-iptv-epg-channels.py, so listings map to channels automatically. Local affiliates get their own station schedules, not a network feedthreadfin.heezy.infoandthreadfin-ca.heezy.info, LAN-only web UIs through SWAG
[2026-09-17] ut2k4's query port stops filling with ICMP errors¶
Fixed¶
- ut2k4's query port held one packet at a time, and DMZ-UT2K4 flapped. The engine sets
IP_RECVERRon 7778 and never reads the error queue, so every ICMP port unreachable from an internet poller stayed charged to the socket's receive buffer. Six days after the pod started the buffer was full, and 7778 dropped anything that arrived while another query was waiting. Master servers got one answer to each burst of three, and Uptime Kuma's check failed whenever it landed beside one: 18 times since 2026-09-13. An init container now drops ICMP port unreachable in the pod's network namespace. See the error queue trap
[2026-08-26] Every game moves to the cluster and the R610 is emptied¶
Added¶
- All six game servers run as pods on VLAN 3. cs16 at 192.168.3.2, both bedrock worlds at .3, ut2k4 at .4, and all three Minecraft Java worlds at .5. Each holds a real DMZ address on a macvlan interface, so traffic it sends to SHARED is off-subnet and the FortiGate decides. Every volume was seeded by restoring the NFS archive rather than copying off the VM
dmz-game-pods, a /29 covering 192.168.3.0 to .7. Every game pod sits in it, sok8s-to-dmz-monitoringreaches a fifth game without being edited- cs16 runs as a pod on VLAN 3. Real DMZ address on a macvlan interface, not behind a LoadBalancer VIP, so traffic it sends to SHARED is off-subnet and the FortiGate decides. The volume was seeded by restoring the NFS backup rather than copying off the running VM: a copy proves the bytes move, a restore proves the backup is restorable, and that is the thing that has to be true before the R610 is switched off
dmz-to-shared-deny, an explicit deny for DMZ initiating into SHARED, logged.fwpolicy-implicit-logis disabled globally and should stay that way on a 60E, so until now a blocked attempt left no trace and the rule the DMZ design rests on had no evidence either way. It sits belowdmz-to-lgtm-loki, still the one permitted exception- Game Server On Macvlan, covering the pattern and the two failures that look like nothing else in the lab
Fixed¶
- hlds bound to the Calico address, so no client could ever join. steamclient opened its
connections from
10.1.x.xwhile routing them out the DMZ interface, the FortiGate dropped them on reverse path check, and the server never reached Steam. Withsv_lan 0that is fatal to authenticating anyone. A2S queries are connectionless and need no Steam, so the server answered every probe from every network and looked healthy the whole time.-iphas to be passed, and passed before-game: after it, the engine accepts it and ignores it - The game-restore role could not render its own job.
game_restore_nfs_pathwas referenced by the template and defined nowhere, so every dispatch failed before it touched a volume - The restore guard counted
lost+foundas data. Longhorn hands out a freshly formatted ext4 volume, so a brand new PVC is never literally empty and the guard rejected every first restore - A successful restore was reported as a failure. The completion poll filtered on
[?(@.status=="True")], the command module split the argument the way a shell would, and kubectl received a filter matching nothing. The poll then ran its full 120 retries against a job that had finished in twenty seconds game-restoreno longer fires on push. It asserts that a game and a PVC were named, so a run with no inputs could only fail, and every push touching its role posted a red Discord message for a repo state that was fine
Changed¶
- Minecraft Java is reachable from the internet at all, and all three worlds are.
minecraft-java-externalwas set todeny, which is the same as having no policy: the VIP translated the address and the policy dropped the result. The host also runs three servers, 25565 java, 25566 adventure and 25567 cobblemon, and only 25565 had a VIP, so the other two worked on the LAN and nowhere else - Player traffic follows the pod. Three VIP
mappedipvalues, and separately the dnsmasq split-DNS override for the game's public name. Both halves are needed: the VIPs carry internet players, the override carries LAN players, and the per-game USERS firewall policies carry nobody becauseUsers to DMZis all sources to all destinations and sits above them - Players go to a new
dmz-cs16-podaddress object. Thedmz-cs16one was kept for the few hours the VM stayed up as the rollback, and went with it
Removed¶
- Every VM on the R610 destroyed.
shared-dnsmasq,shared-lgtm,dmz-cs16,dmz-minecraft,dmz-minecraft-javaanddmz-ut2k4. DNS had been a DaemonSet behind an anycast address since Phase 1 with every consumer repointed, LGTM had been on big-boi with its data since Phase 2, and cs16 became a pod today - Everything that only existed to point at them: the three DNS policies 309, 315 and 319, whose
anycast equivalents already carried that traffic;
big-boi-cs16-ssh; theshared-dnsmasq,shared-lgtmanddmz-cs16address objects; the DHCP reservations; three Uptime Kuma monitors; and the inventory groups, which each held exactly one host dmz-to-lgtm-loki. It was the single permitted DMZ to SHARED exception, for promtail on the game VMs shipping to Loki. There are no DMZ VMs left and the pods have their logs collected from the node filesystem, which never crosses the boundary. DMZ now initiates into SHARED for nothing at all, anddmz-to-shared-denyrecords every attemptpermit to prometheus, policy 20. It allowed USERS to tcp/9090 on 192.168.1.29, a host that ran no Prometheus. It was named for a testvm that had not existed in years and had been carried as a known problem since the legacy policies were adoptedplaybooks/dnsmasq.yml,playbooks/cs16.ymland the docker-compose half ofroles/dnsmasq.roles/cs16-serverwas kept:files/holds the only copy of the plugin source the pod's compiled plugins were built from
Known gaps¶
- No game server has a working backup. The game backup reaches four hosts over SSH and all four are destroyed. Six worlds now live on Longhorn PVCs that nothing is capturing, and Longhorn has no snapshot or backup RecurringJob either. The archives they were restored from are still on NFS, so the loss window starts today
- The Minecraft adventure world has not started since 2026-04-19. It fails the same way on the
VM it came from and in the pod, so it is the world data rather than the move:
Overworld settings missing, withlevel.datlast written in April. It sat in a restart loop for four months and nothing alerted, because the monitor for that host checked node-exporter and node-exporter never stopped - Bedrock has no monitor. Both worlds are UDP only and Uptime Kuma's port monitor cannot check UDP. The node-exporter that used to stand in for it does not exist in a pod
[2026-08-25] LGTM move follow-ups¶
Fixed¶
baselinewas reinstalling node_exporter over the role's unit. Bothroles/baselineandroles/prometheus-node-exporterwrote/etc/systemd/system/node_exporter.service, and baseline's copy omitted--collector.textfile.directory.playbooks/baseline.ymlruns the role after baseline so it always came out right;playbooks/lgtm.ymlruns baseline alone, and now that thelgtmgroup means big-boi it stripped the textfile collector off the one host that uses it. The GLX probe kept writing its.promfile, node_exporter stopped reading the directory, and Steam Headless GLX Probe Stale fired on NoData. Node Exporter is out ofbaselinenow, the same split already made for Promtail- steam-headless had genuinely fallen back off NVIDIA GL, which the blind probe could not
report.
glxinfowas failing withBadValueonX_GLXCreateNewContextwhile Xorg's ownglxserver_nvidiawas loaded cleanly, so it was device access lost to a systemd reload, not a driver mismatch. A container restart cleared it
Changed¶
- LGTM data lives on
/mnt/lgtm.lgtm_data_pathwas/opt/lgtm/data, inherited unchanged from the VM where/optwas the only disk, so the move put 27 GB of TSDB on big-boi's 114 GB root volume. Root went from 49% to 25% - Every LGTM path derives from
lgtm_root_pathnow. The compose file, the systemdWorkingDirectory, the weekly auto-update cron, the restart handler and both retention prunes inlgtm-cleanup.sheach spelled/opt/lgtmout; repointing the data alone would have left the nightly prunes runningfindagainst an empty directory and stopped retention silently - Reclaimed 3 GB on big-boi: 1.5 GB of Steam core dumps under
/var/lib/apport/coredumpfrom the GL fallback, three orphaned Docker volumes superseded by bind mounts, and the apt lists cache
Known gaps¶
- The
snmp/fortigatetarget is down. Theheezy-roSNMP community allow-lists 192.168.1.10, so a poll from big-boi gets no reply and the scrape times out. One line interraform-heezy/environments/production/heezy/snmp.tf lgtm-stackonshared-lgtmis inactive but stillenabled, so a reboot of that VM would start a second full stack scraping every target in the lab
[2026-08-25] LGTM moved to big-boi¶
Changed¶
- LGTM runs on big-boi. grafana, prometheus, loki, tempo and mimir all resolve to
192.168.1.21. Theshared-lgtmVM is stopped but intact as the rollback until the R610 is decommissioned - Mimir started fresh, 52 of the 78 GiB. Nothing read it: Prometheus is the default datasource and none of the five provisioned dashboards reference Mimir
- The two DMZ firewall policies,
dmz-to-lgtm-lokiand37 lgtm-dmz-node-exporter, follow it toshared-big-boi - DHCP
dns_server2on the DMZ path is unaffected; the DMZ hosts carry the address literally ininventory/group_vars/dmz.ymlbecause they resolve through public DNS
Migration¶
Pre-seeded 26 GiB while both stacks ran, then took the blackout for a second pass that moved 38 files and 235 MB in under 33 seconds. Prometheus history survived the move.
Corrected¶
- inotify does not pick up a ConfigMap update. The DNS page said a zone change needed no restart. It does. See the warning under Generated hosts files
- The
shared-lgtmPTR was removed rather than followed.lgtm_ipisbigboi_ipnow, and keeping both would have put two PTR records on192.168.1.21 loki_serverin the dnsmasq role defaults was deleted rather than updated. Nothing read it
[2026-08-25] DNS: anycast over BGP, five instances¶
Changed¶
- Internal DNS is now six dnsmasq instances, not one. A
DaemonSetof five pods behind the anycast address192.168.253.53, advertised to the FortiGate over BGP by every node with a healthy pod, plus theshared-dnsmasqVM as a secondary in a different failure domain - The address had to change.
192.168.1.29is inside the SHARED subnet, so VLAN 1 hosts reach it by ARP and never consult a route. A BGP/32inside a connected/24is dead weight for exactly the clients that matter most, so the anycast address lives outside every client subnet - DHCP on SHARED and USERS hands out both resolvers.
dns_server2was1.1.1.1, which is authoritative for nothing internal, so any client falling through to it got NXDOMAIN for every internal name rather than a slower correct answer. It is192.168.1.29now - CoreDNS and Pi-hole forward to both, anycast first. CoreDNS uses
policy sequential; the forward plugin defaults torandom, which would have sent half of every internal lookup to the VM being retired - The MetalLB default pool no longer overlaps live hosts. It was a flat
.25-.40withautoAssign: true, which let MetalLB hand out.26(Cisco switch mgmt) or.29(the dnsmasq VM) to any Service that did not pin its IP - The
L2Advertisementis scoped. It had nospecat all, which in MetalLB matches every pool, so it would have ARPed for the anycast address alongside the BGP path
Behaviour¶
| Failure | Detection | Result |
|---|---|---|
| Pod crashes or fails readiness | seconds | that node withdraws its path, four remain |
| Node reboots or dies | 9s worst case | path withdrawn on hold-timer expiry |
| Whole cluster down | not covered | DHCP secondary, the VM, answers |
Measured: deleting one pod dropped the route from five next-hops to four within seconds and restored it about 18 seconds later.
9s is the floor, not a tuning choice. MetalLB is v0.13.3 in native mode with no FRR sidecar, so
there is no BFD. Sub-second detection needs FRR mode and a BFDProfile, which touches every VIP in
the lab.
Two traps worth keeping¶
keepaliveTimeis in the MetalLBBGPPeerCRD schema but the admission webhook rejects it in native mode. It validates client-side and fails at apply. Native mode derives it asholdTime/3- The FortiGate ASN is
as_string, notas: the provider gates the numeric field to FortiOS <= 7.2.0 and this device is 7.4.3. A cleanterraform plandoes not prove afortiosresource will apply, because the version gate only fires once it is talking to the device
[2026-08-19] Backups: one tool, weekly off-site, 30 day retention¶
Changed¶
- All backups are Ansible. The
plex-config-backupanduptime-kuma-backupCronJobs are gone fromheezy-k8s; both are targets in theheezy-backuprole now. The only mechanism-specific case left is the OpenBao raft snapshot, which has to be an API call because a tar of a live raft store is not a restorable snapshot - Capture and shipping split into two plays.
heezy-backupcaptures to NFS daily at 02:00 UTC. The newheezy-backup-offsiteships to S3 weekly, Saturday 08:00 UTC. While they were one task the only lever for uploading less was capturing less - Plex is three paths, not all of
/config.Preferences.xml,Plug-in Support/Databases,Plug-in Support/Metadata Combination.Media,MetadataandCacheare regenerable artwork and transcode scratch that Plex rebuilds. 11.9 GiB to ~1.6 GiB - Off-site retention 365 days to 30, and storage class GLACIER to STANDARD_IA. GLACIER Flexible Retrieval bills a 90 day minimum per object, so a 30 day GLACIER tree is billed as a 90 day one: within pennies of STANDARD_IA at this size, and 3 to 5 hours slower to read. Restores from S3 no longer need a thaw
- The off-site run ships one file per series, not the whole tree. NFS holds 7 dailies and a weekly RPO needs one of them
Numbers¶
| Before | After | |
|---|---|---|
| Daily capture | ~21 GB | ~7.4 GB |
| Weekly upload | ~147 GB | ~7.4 GB |
| Off-site run time | 1h+, every day | ~15 min, once a week |
| NFS steady state | ~130 GB | ~52 GB |
| S3 stored | 136.5 GiB | ~30 GB |
Noted while doing it¶
- Local NFS retention must stay at or above the ship interval. At 7 days local and a 7 day
interval every snapshot is still on disk when the weekly run comes for it. This is not
hypothetical: the S3 step used to gate on
date +%d == 01, the daily schedule did not exist, and nothing reached S3 for 67 days - Moving off site to weekly widens the TrueNAS blast radius. A NAS failure now leaves only the last weekly generation, up to 7 days of exposure instead of 1
heezy-backup's role defaults advertisedopenbao_url: http://192.168.1.32:8200while every run actually usedhttp://192.168.1.15:30820. An unprefixed variable in a role default is the lowest precedence Ansible has, andgroup_vars/allreaches the implicit localhost thathosts: localhostplays run on. Both backup roles now use role-prefixed, name-based endpointsuptime-kuma.heezy.localresolves to SWAG (192.168.1.25), not the uptime-kuma VIP (192.168.1.33).k8s-services.j2maps every app name tovip_swag, so an app name gives you the reverse proxy, never the backend VIP on its own port. The .33 VIP has no name
[2026-08-14] Docs: four services that were running but undocumented¶
Added¶
- Maintenance and Projects — deployed
2026-08-02, no docs until now. Both are LAN-only Flask apps on their own Postgres databases
(
maintenanceandprojects, not the sharedheezyone), reached through SWAG at NodePorts 30861 and 30862 - Mailbot — the three Gmail CronJobs that replaced n8n on 2026-08-10.
All three now run
--live; they were cut over from shadow mode after the dry runs matched - CS1.6 Leaderboard — was listed on the home page with a URL and nothing else. Documents the Loki-tailer pipeline and its nanosecond-timestamp dedupe
Noted while documenting¶
- Neither Maintenance nor Projects has any authentication in the app. The only access control is that the hostname resolves solely through the dnsmasq override — no public DNS record, no Cloudflare tunnel hostname, no Access application. Publishing a DNS record for either without an Access app would expose an unauthenticated CRUD API
heezy-projectsorders the index bypriority DESC, which is a lexical sort on text.mediumsorts abovelowabovehigh, so high-priority projects appear lastproject_tasksandproject_logexist inschema.sqland have no routes inapp.pyPUT /api/projects/<id>writes every column from the payload; a partial body blanks what it omits
[2026-08-12] Receipts: concurrent uploads, lost receipts, and duplicates¶
Fixed¶
Concurrent uploads overwrote each other
- The OCR poller was never cancelled when a second upload started, and there is one result panel.
Two pollers raced and the last to finish owned the DOM
- The quick-edit form used bare element ids (qe-total), so the Save button carried one receipt's id
while getElementById could resolve to a form rendered for another. Corrections landed on the
wrong receipt, which read as "the second upload didn't save"
- Field ids are now suffixed with the receipt id, the previous poller is retired, and the panel
carries the id it belongs to
Receipts that disappeared
- /upload saved the image to NFS before inserting the row. A failed INSERT lost the receipt while
the bytes stayed on disk. The image is now removed when the row cannot be written
- receipts.upload_date was TEXT holding two incompatible formats — isoformat() from /upload and
NOW()::text from heezy-finance. Lexicographic ordering put a space before T, so every
finance-created receipt sank below the uploaded ones from the same day. Migrated to timestamptz
- GET /receipts was a hardcoded LIMIT 100 with no pagination. At 157 rows, 57 receipts were
unreachable from the UI. Now takes limit and offset
"Error loading items" on every receipt with line items
- NUMERIC columns return Decimal, which Flask serialises as a quoted string. The items table
called .toFixed() on it and threw. A NumericJSONProvider now renders Decimal as a number
The per-item edit button never worked
- It inlined JSON.stringify(item) into a double-quoted onclick, so the attribute terminated at
the first quote in the JSON. Every line item on every receipt was uneditable
Line item quality
- A trailing number was treated as a quantity using \b, and a hyphen is a word boundary, so
997895 KS OIL OW-20 became twenty units of 997895 KS OIL OW-. UPCs were read as quantities up
to 716,281,006,230. Now requires whitespace and caps at 100
- Descriptions were built by stripping trailing digits, turning 997920 OIL 5W20 into 997920 OIL 5W
- 9 existing items repaired from their raw_line; total_price was correct throughout, so no
financial figure moved
Category list drift
- Alcohol, Transportation, Retirement Funds, Stock Funds and MittenTech were selectable in
heezy-finance but missing from receipts. A test now fails the build on drift
Added¶
reconcile_orphans.py— re-imports images with no database row, dry-run by defaultGET /receiptsreturnshas_image, so the UI stops guessing from the filename- Delete control on orphaned receipts in the heezy-finance Reconcile tab, proxied to the receipts service so the image goes with the row
- Receipts pod raised to
3000mCPU so concurrent OCR pipelines do not share one core
Incident¶
Recovering 52 orphaned images created 36 duplicate receipts, including seventeen copies of one Costco receipt. The recovery assumed an image without a row meant a lost receipt; in fact the delete path had been dropping rows while failing to remove files, so most orphans were receipts deleted on purpose. The duplicates were removed and the tool now rolls back any import whose amount and date already exist.
[2026-06-20] heezy-finance Phase 1-2 + Phase 4 Deployment¶
Added¶
Phase 1: Global Date Range Selector - React date picker controls all dashboard views - Persists to localStorage across sessions - Default: last 90 days
Phase 2: Bank Integration & Complete Coverage - Dedicated Bank tab with category breakdown cards - Monthly bar chart showing bank transaction trends - Top merchants table - All views (Overview, Purchases, Categories) now include bank data - 137 bank transactions integrated (debits only, transfers excluded) - Rule-based categorization: Household, Health, Travel, Food, Finance, etc.
Phase 3: Category Drill-Down Modal - Click category to see all transactions in that category - Paginated list (20 per page) - Sortable columns: Date, Source, Vendor, Item, Total, Category - Filters: date range, source (order/receipt/bank)
Phase 4: Hockey Holdem Receipt Ingestion - New receipt category for event expenses - Receipts automatically parsed and categorized - Verified: data in DB with proper timestamps and merchant normalization
Changed¶
Database schema updates:
- receipts: Added date_ts (timestamptz), merchant_normalized, payment_type
- bank_transactions: Added category, merchant_normalized
- All existing data backfilled
Dashboard API:
- /api/overview now includes bank transaction totals
- /api/purchases returns all three sources with sortable columns
- /api/sync reports counts from all sources
- /api/category-drilldown new endpoint for drill-down modal
- New /api/bank-stats endpoint for Bank page aggregations
Deployment:
- k8s: heezy-finance pinned to nebula-5 (node affinity issue noted)
- All code pushed to Gitea (canonical) + GitHub (backup mirror)
- Commit: 45d3a4af
Fixed¶
Gas spend now accurate: $140.52 (was $36)
- Root cause: Receipt dates in inconsistent text formats caused silent parse failures
- Fix: Use proper date_ts column instead of parsing text dates
Bank transactions visibility: - Root cause: Zero integration in original dashboard - Fix: Full integration into all API endpoints and UI views
Receipts merchant name consistency:
- Root cause: "COSTCO" vs "Costco" vs "costco" treated as different merchants
- Fix: Backfilled merchant_normalized on all 43 receipts + new receipts auto-normalized
Known Issues¶
Node Affinity Pinning (heezy-finance) - Pod pinned to nebula-5; cannot reschedule if node fails - Blocking resilience work next session - Fix: Remove affinity + switch PVC to ReadWriteMany or eliminate persistent storage
June 2026 Statements Missing - BoA coverage ends May 2026 - June transactions not yet loaded - Action: Trigger statement parser or manually ingest
Capital One Backfill Incomplete - Only 1 CC statement loaded - Historical backfill pending
CI/CD Lesson Learned - Never push to GitHub origin directly - Gitea is the canonical repo; GitHub is a backup mirror only - Push to Gitea first, then GitHub - Deployments only trigger on Gitea push
See CI/CD docs and Roadmap.
Next Session¶
Phase 3: Infrastructure Resilience
- Remove node affinity pinning from heezy-finance
- Fix PVC storage pattern (NFS or remove if not needed)
- Load June 2026 statements
- Verify all features working correctly
[2026-06-18] heezy-finance Documentation & Data Audit¶
Added¶
- Comprehensive data source audit: orders, receipts, bank transactions
- Roadmap for 3-phase data cleanup + reconciliation
- Known issues list (priority-ordered)
- Daily progress log in README
Fixed¶
- Identified root causes of data gaps:
- Receipt dates in inconsistent formats (text field)
- Bank transactions completely disconnected from dashboard
- Gas spend under-reported due to category/date handling
[Earlier] Infrastructure Setup¶
- k8s cluster: 5 nodes (nebula-1 through nebula-5)
- Gitea: http://192.168.1.15:30360/heezy-admin
- heezy-finance: Initial Flask + vanilla JS dashboard
- Receipts service: AWS Textract + Ollama categorization
- Bank statement parser: pdfplumber on big-boi cron job