hub v0.155.0: the floor can never deliver a withdrawn controller build (R-1085); RomM/Ghost evidence
gates / gates (push) Successful in 6m44s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-16 12:24:39 +02:00
parent 920c1aaf84
commit b17e9e993c
28 changed files with 955 additions and 6 deletions
@@ -43,6 +43,24 @@ this closes the half that let it happen.
number. A box already on the bad image is moved by the normal update path — never by re-tagging,
which would be the same defect wearing the fix's clothes.
**[DESIGN] A withdrawn build is never selected (hub v0.155.0, R-1081, R-1085).** The rule above
leaves the spoiled numbers in the registry, so nothing may pick them. Two controller tags hold
other code than their number says: **`0.315.0`** (re-pushed with v0.316.0's code under it) and
**`0.316.3`** (built from a working copy holding another session's uncommitted files). The
household's button and the daily auto-update take the HIGHEST tag and cannot reach them; the
**floor** targets a version exactly, so the floor is where the guard lives, in two halves:
- **at input** — both floor forms (global and per customer, by session or by token) and the
confirm dialog's impact probe refuse a listed tag (`flash=floor_spoiled`; the probe answers
`valid:false, spoiled:true`). The check is its own, NOT inside `normalizeFloorInput`, which also
validates agent and golden numbers;
- **at serve time** — `store.ResolveManagedFloor` HOLDS a listed floor (served as none,
`HeldSpoiled`, its own reason text) however it arrived: a value stored before the refusal, or the
config/env default (which no form checks; the hub also warns about it at start). The stored
value is not rewritten; the Configuration and customer pages say „withdrawn".
The list is `hub/internal/store/spoiled_tags.go`; the hand deploy reads the same list as a marked
line in the `felhom-build-deploy` skill, and a test fails when the two differ. A new spoiled
build is added to both in the same commit.
## 1. How an update works today, as measured
### 1.1 The button
@@ -0,0 +1,89 @@
11:29:43 catalog compose on the box: 73: - ${USERDATA_PATH}/roms:/romm/library
11:29:47 [1] made the drive paths this app requires: ['/mnt/felhom-drives/hdd_1/userdata/romm']
11:29:47 [1] required fields filled beyond DOMAIN/SUBDOMAIN: ['HDD_PATH']
11:29:49 [1] deploy -> 409 {'ok': False, 'data': {'code': 'kept_data_choice', 'title': 'Ennek az alkalmazásnak megvannak a régi adatai', 'body': 'A
11:29:49 deploy ok: False
11:29:49 gate: arcade not routed right now (404) — not cached, asked again next call
11:29:54 gate: arcade not routed right now (404) — not cached, asked again next call
11:29:59 gate: arcade not routed right now (404) — not cached, asked again next call
11:30:04 gate: arcade not routed right now (404) — not cached, asked again next call
11:30:09 gate: arcade not routed right now (404) — not cached, asked again next call
11:30:15 gate: arcade not routed right now (404) — not cached, asked again next call
11:30:20 gate: arcade not routed right now (404) — not cached, asked again next call
11:30:25 gate: arcade not routed right now (404) — not cached, asked again next call
11:30:30 gate: arcade not routed right now (404) — not cached, asked again next call
11:30:35 gate: arcade not routed right now (404) — not cached, asked again next call
11:30:40 gate: arcade not routed right now (404) — not cached, asked again next call
11:30:45 gate: arcade not routed right now (404) — not cached, asked again next call
11:30:50 gate: arcade not routed right now (404) — not cached, asked again next call
11:30:55 gate: arcade not routed right now (404) — not cached, asked again next call
11:31:00 gate: arcade not routed right now (404) — not cached, asked again next call
11:31:05 gate: arcade not routed right now (404) — not cached, asked again next call
11:31:10 gate: arcade not routed right now (404) — not cached, asked again next call
11:31:15 gate: arcade not routed right now (404) — not cached, asked again next call
11:31:20 gate: arcade not routed right now (404) — not cached, asked again next call
11:31:25 gate: arcade not routed right now (404) — not cached, asked again next call
11:31:30 gate: arcade not routed right now (404) — not cached, asked again next call
11:31:35 gate: arcade not routed right now (404) — not cached, asked again next call
11:31:40 gate: arcade not routed right now (404) — not cached, asked again next call
11:31:45 gate: arcade not routed right now (404) — not cached, asked again next call
11:31:50 gate: arcade not routed right now (404) — not cached, asked again next call
11:31:55 gate: arcade not routed right now (404) — not cached, asked again next call
11:32:00 gate: arcade not routed right now (404) — not cached, asked again next call
11:32:06 gate: arcade not routed right now (404) — not cached, asked again next call
11:32:11 gate: arcade not routed right now (404) — not cached, asked again next call
11:32:16 gate: arcade not routed right now (404) — not cached, asked again next call
11:32:21 gate: arcade not routed right now (404) — not cached, asked again next call
11:32:26 gate: arcade not routed right now (404) — not cached, asked again next call
11:32:31 gate: arcade not routed right now (404) — not cached, asked again next call
11:32:36 gate: arcade not routed right now (404) — not cached, asked again next call
11:32:41 gate: arcade not routed right now (404) — not cached, asked again next call
11:32:46 gate: arcade not routed right now (404) — not cached, asked again next call
11:32:51 gate: arcade not routed right now (404) — not cached, asked again next call
11:32:56 gate: arcade not routed right now (404) — not cached, asked again next call
11:33:01 gate: arcade not routed right now (404) — not cached, asked again next call
11:33:06 gate: arcade not routed right now (404) — not cached, asked again next call
11:33:11 gate: arcade not routed right now (404) — not cached, asked again next call
11:33:16 gate: arcade not routed right now (404) — not cached, asked again next call
11:33:21 gate: arcade not routed right now (404) — not cached, asked again next call
11:33:26 gate: arcade not routed right now (404) — not cached, asked again next call
11:33:31 gate: arcade not routed right now (404) — not cached, asked again next call
11:33:36 gate: arcade not routed right now (404) — not cached, asked again next call
11:33:41 gate: arcade not routed right now (404) — not cached, asked again next call
11:33:46 gate: arcade not routed right now (404) — not cached, asked again next call
11:33:51 gate: arcade not routed right now (404) — not cached, asked again next call
11:33:57 gate: arcade not routed right now (404) — not cached, asked again next call
11:34:02 gate: arcade not routed right now (404) — not cached, asked again next call
11:34:07 gate: arcade not routed right now (404) — not cached, asked again next call
11:34:12 gate: arcade not routed right now (404) — not cached, asked again next call
11:34:17 gate: arcade not routed right now (404) — not cached, asked again next call
11:34:22 gate: arcade not routed right now (404) — not cached, asked again next call
11:34:27 gate: arcade not routed right now (404) — not cached, asked again next call
11:34:32 gate: arcade not routed right now (404) — not cached, asked again next call
11:34:37 gate: arcade not routed right now (404) — not cached, asked again next call
11:34:42 gate: arcade not routed right now (404) — not cached, asked again next call
11:34:47 gate: arcade not routed right now (404) — not cached, asked again next call
11:34:52 app never answered on arcade/api/heartbeat (last rc=0 code=404)
11:34:52 app answers: False
11:34:55
11:35:38 [1] made the drive paths this app requires: ['/mnt/felhom-drives/hdd_1/userdata/romm']
11:35:38 [1] required fields filled beyond DOMAIN/SUBDOMAIN: ['HDD_PATH']
11:35:39 [1] deploy -> 202 {'ok': True, 'message': 'Telepítés elindítva – az állapot a kártyán követhető'}
11:36:59 [1] deployed, controller state=running, pinned={'romm': 'rommapp/romm:5.3.1', 'romm-db': 'mariadb:11.8', 'romm-redis': 'redis:7-alpine'}
11:36:59 deploy ok: True
11:36:59 gate: arcade is gated — passed as the household (cookie set)
11:37:10 app answers: True
11:37:13 romm Up 58 seconds (healthy)
romm-db Up About a minute (healthy)
romm-redis Up About a minute (healthy)
/var/lib/docker/volumes/6445a3c2c18f80fdeda1d2de2652be8872e5795b2f2a764d967f2b283ab7f4b9/_data -> /redis-data
/var/lib/docker/volumes/0f9051c4da8c562072e97a3ff57d36dadd9377699effea716b3eb8a9be4bac93/_data -> /romm
/var/lib/docker/volumes/romm_romm_config/_data -> /romm/config
/mnt/felhom-drives/hdd_1/userdata/roms -> /romm/library
/mnt/felhom-drives/hdd_1/appdata/romm/resources -> /romm/resources
drwxr-xr-x 3 root root 4096 Oct 16 09:36 romm
resources
@@ -0,0 +1,56 @@
11:37:36 gate: arcade is gated — passed as the household (cookie set)
11:37:36 heartbeat VERSION 5.3.1 SHOW_SETUP_WIZARD True platforms dir (FILESYSTEM) {"FS_PLATFORMS": [], "TITLE_ID_EXTRACTION_ENABLED": true}
11:37:36 first admin -> 403 CSRF token verification failed
11:37:36 me -> 401 None
11:37:36 == A1 step 1: a dummy file where the first steps say: roms/gba/Test Game (World).gba
11:37:40 /mnt/felhom-drives/hdd_1/userdata/roms
/mnt/felhom-drives/hdd_1/userdata/roms/gba
/mnt/felhom-drives/hdd_1/userdata/roms/gba/Test Game (World).gba
/mnt/felhom-drives/hdd_1/userdata/roms/roms
11:37:40 scan_library -> 401 {'detail': 'Not authenticated'}
11:40:40 task did not finish in 180 s
11:40:40 library after scan 1: ({'detail': 'Not authenticated'}, None)
11:41:12 gate: arcade is gated — passed as the household (cookie set)
11:41:12 == retry with RomM's CSRF token (token present: True )
11:41:12 first admin -> 201 ('felhomadmin', 'admin')
11:41:12 me -> 200 felhomadmin
11:41:12 SHOW_SETUP_WIZARD now False
11:41:13 scan_library -> 400 {'detail': "Task 'scan_library' cannot be run"}
11:44:13 task did not finish in 180 s
11:44:13 library after scan 1 (file at roms/gba/): ([], [])
11:44:17 /mnt/felhom-drives/hdd_1/userdata/roms
/mnt/felhom-drives/hdd_1/userdata/roms/gba
/mnt/felhom-drives/hdd_1/userdata/roms/gba/Test Game (World).gba
/mnt/felhom-drives/hdd_1/userdata/roms/roms
INFO: [RomM][nginx][2026-10-16 11:40:40] 172.18.0.4 | 192.168.0.180 | GET /api/platforms 401 | 30 | Unknown Unknown | 0.007
INFO: [RomM][nginx][2026-10-16 11:41:13] 172.18.0.4 | 192.168.0.180 | POST /api/tasks/run/scan_library 400 | 46 | Unknown Unknown | 0.255
11:45:20 == A1 step 1 (scan through the Scan page's socket event, quick scan, all platforms)
11:45:21 romm login -> 200 session cookie set
11:45:22 socket namespace: 40{"sid":"0R_1yzCv45NHDv4BAAAB"}
11:45:22 scan event sent -> 200 OK
11:45:30 scan finished: scan:done [{"total_platforms": 0, "total_roms": 0, "scanned_platforms": 0, "new_platforms": 0, "identified_platforms": 0, "scanned_roms": 0, "new_roms": 0, "identified_roms": 0, "scanned_firmware": 0, "new_firmware": 0, "updated_roms": 0, "new_files": 0}]
11:45:31 library after scan 1 (file at drive roms/gba/ = container /romm/library/gba): ([], [])
11:45:34 INFO: [RomM][auth][2026-10-16 11:45:21] Auto-created web device c0b92a75-760c-4bb6-bc2d-b28aeadfe537 for user felhomadmin
INFO: [RomM][scan][2026-10-16 11:45:22] 🔎 Scanning
11:45:22 Worker dd5e9075c9ed4cbeaea36aa313d3eb48: scans: endpoints.sockets.scan.scan_platforms(launchbox_remote_enabled=True, metadata_sources=[], platform_fs_slugs=[], platform_ids=[], roms_ids=[], scan_type=<ScanType.Q
WARNING: [RomM][scan][2026-10-16 11:45:30] ⚠️ No platforms found, verify that the folder structure is right and the volume is mounted correctly.Check RomM folder structure here:
INFO: [RomM][scan][2026-10-16 11:45:30] ✔️ Scan completed
11:45:30 Successfully completed endpoints.sockets.scan.scan_platforms(launchbox_remote_enabled=True, metadata_sources=[], platform_fs_slugs=[], platform_ids=[], roms_ids=[], scan_type=<ScanType.QUICK: 'quick'>) job in 0:
11:45:30 Worker dd5e9075c9ed4cbeaea36aa313d3eb48: scans: Job OK (0a5e6873-4731-42a7-a826-bec76bbc9d7a)
11:45:30 Worker dd5e9075c9ed4cbeaea36aa313d3eb48: job 0a5e6873-4731-42a7-a826-bec76bbc9d7a result is kept for 86400 seconds
11:45:43 == A1 step 2: the same dummy file at drive roms/roms/gba/ (= container /romm/library/roms/gba)
11:45:46 /mnt/felhom-drives/hdd_1/userdata/roms
/mnt/felhom-drives/hdd_1/userdata/roms/gba
/mnt/felhom-drives/hdd_1/userdata/roms/gba/Test Game (World).gba
/mnt/felhom-drives/hdd_1/userdata/roms/roms
/mnt/felhom-drives/hdd_1/userdata/roms/roms/gba
/mnt/felhom-drives/hdd_1/userdata/roms/roms/gba/Test Game (World).gba
11:45:46 romm login -> 200 session cookie set
11:45:46 socket namespace: 40{"sid":"Tl74vJttkDfal5zMAAAD"}
11:45:46 scan event sent -> 200 OK
11:45:55 scan finished: scan:done [{"total_platforms": 1, "total_roms": 1, "scanned_platforms": 1, "new_platforms": 1, "identified_platforms": 1, "scanned_roms": 1, "new_roms": 1, "identified_roms": 0, "scanned_firmware": 0, "new_firmware": 0, "updated_roms": 0, "new_files": 0}]
11:45:56 library after scan 2: ([('gba', 1)], [('Test Game (World).gba', 'gba', 'roms/gba')])
@@ -0,0 +1,29 @@
11:28:46 BEFORE: romm stack {'deployed': True, 'state': 'stopped'}
11:28:49 /mnt/felhom-drives/hdd_1/userdata/roms
/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21
/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/SENTINEL.txt
/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/binary-1mb.bin
/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/nested
/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/nested/őszibarack.md
/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/plain.txt
/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/árvíztűrő-tükörfúrógép.txt
/mnt/felhom-drives/hdd_1/userdata/roms/roms
romm_romm_config
romm_romm_db_data
romm_romm_redis_data
romm_romm_redis_data.pre-aof-fix-20260924T135937Z
11:29:16 remove (keep drive data, keep backups) -> 200 {'ok': True, 'data': {'removed': 'romm', 'volumes_removed': ['romm_romm_config', 'romm_romm_db_data', 'romm_romm_redis_data'], 'hdd_paths_removed': [], 'hdd_paths_preserved': ['/mnt/felhom-drives/hdd_
11:29:24 AFTER: romm stack {'deployed': False, 'state': 'not_deployed'}
11:29:28 /mnt/felhom-drives/hdd_1/userdata/roms
/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21
/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/SENTINEL.txt
/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/binary-1mb.bin
/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/nested
/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/nested/őszibarack.md
/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/plain.txt
/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/árvíztűrő-tükörfúrógép.txt
/mnt/felhom-drives/hdd_1/userdata/roms/roms
romm_romm_redis_data.pre-aof-fix-20260924T135937Z
docker-compose.yml
@@ -0,0 +1,14 @@
11:15:22 start -> 500 {'ok': False, 'error': 'starting stack romm: exit code 1\nstderr: ...romm-internal Created \n Container romm-redis Creating \n Container romm-db Creating \n Con
11:20:23 state stopped
11:23:48
Container romm-db Started
Container romm-redis Started
Container romm-redis Error dependency romm-redis failed to start
dependency failed to start: container romm-redis is unhealthy
2026/10/16 09:15:26 manager.go:904: [DEBUG] [stacks] restart-policy of down member "romm" = "unless-stopped"
2026/10/16 09:15:26 healthprobe.go:105: [DEBUG] [stacks] RunHealthProbes: romm has a health check but no container to probe — candidates: [romm(stopped) romm-redis(restarting) romm-db(starting)]
2026/10/16 09:15:26 notifier.go:353: [INFO] Event pushed: app_start_failed (warning) [hu-only] — Telepített alkalmazás nem fut: RomM
2026/10/16 09:15:57 update_guard.go:860: [WARN] [backup] romm is STOPPED by the box: crash_loop (trip 1 within 24h0m0s) — Start gives it one more try (decision 28)
Error response from daemon: No such container: romm-redis
@@ -0,0 +1,56 @@
12:06:23 stop -> 200
12:06:55 remove (keep drive data) -> 200 {'ok': True, 'data': {'removed': 'romm', 'volumes_removed': ['romm_romm_config', 'romm_romm_db_data', 'romm_romm_redis_data'], 'hdd_paths_removed': [], 'hdd_paths_preserved': ['/mnt/felhom-drives/hdd_1/appdata/romm/resou
12:06:59 REMAINING:
/mnt/felhom-drives/hdd_1/userdata/roms
/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21
/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/SENTINEL.txt
/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/binary-1mb.bin
/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/nested
/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/nested/őszibarack.md
/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/plain.txt
/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/árvíztűrő-tükörfúrógép.txt
/mnt/felhom-drives/hdd_1/userdata/roms/roms
find: '/mnt/felhom-drives/hdd_1/userdata/romm': No such file or directory
find: '/mnt/felhom-drives/hdd_1/userdata/roms-bios': No such file or directory
APPDATA:
/mnt/felhom-drives/hdd_1/appdata/romm
total 12
drwxr-xr-x 3 root root 4096 Oct 16 09:36 .
drwxr-xr-x 4 root root 4096 Oct 14 15:45 ..
drwxr-xr-x 3 root root 4096 Oct 16 09:36 resources
12:07:27 [1] made the drive paths this app requires: ['/nonexistent-not-used/romm']
12:07:27 [1] required fields filled beyond DOMAIN/SUBDOMAIN: ['HDD_PATH']
12:07:28 [1] deploy -> 202 {'ok': True, 'message': 'Telepítés elindítva – az állapot a kártyán követhető'}
12:08:48 [1] deployed, controller state=running, pinned={'romm': 'rommapp/romm:5.3.1', 'romm-db': 'mariadb:11.8', 'romm-redis': 'redis:7-alpine'}
12:08:48 gate: arcade is gated — passed as the household (cookie set)
12:09:00 deploy ok: True | answers: True
12:09:03 /mnt/felhom-drives/hdd_1/appdata/romm/resources -> /romm/resources
/mnt/felhom-drives/hdd_1/userdata/roms-bios -> /romm/library/bios
/mnt/felhom-drives/hdd_1/userdata/roms -> /romm/library/roms
12:09:04 first admin -> 201
12:09:04 == Scenario A: one dummy file EXACTLY where the fixed first steps say: roms/gba/
12:09:07 total 12
drwxr-sr-x 2 root 1000 4096 Oct 16 10:09 .
drwxrwsr-x 5 root 1000 4096 Oct 16 10:09 ..
-rw-r--r-- 1 root 1000 4096 Oct 16 10:09 Test Game (World).gba
12:09:07 romm login -> 200 session cookie set
12:09:08 socket namespace: 40{"sid":"eRLGRdKd24uTdIOcAAAB"}
12:09:08 scan event sent -> 200 OK
12:09:18 scan finished: scan:done [{"total_platforms": 3, "total_roms": 6, "scanned_platforms": 3, "new_platforms": 3, "identified_platforms": 1, "scanned_roms": 6, "new_roms": 6, "identified_roms": 0, "scanned_firmware": 0, "new_firmware": 0, "updated_roms": 0, "new_files": 0}]
12:09:18 rom: árvíztűrő-tükörfúrógép.txt | platform DRILL-2026-08-21 | fs_path roms/DRILL-2026-08-21 | missing False
12:09:18 rom: binary-1mb.bin | platform DRILL-2026-08-21 | fs_path roms/DRILL-2026-08-21 | missing False
12:09:18 rom: nested | platform DRILL-2026-08-21 | fs_path roms/DRILL-2026-08-21 | missing False
12:09:18 rom: plain.txt | platform DRILL-2026-08-21 | fs_path roms/DRILL-2026-08-21 | missing False
12:09:18 rom: SENTINEL.txt | platform DRILL-2026-08-21 | fs_path roms/DRILL-2026-08-21 | missing False
12:09:18 rom: Test Game (World).gba | platform gba | fs_path roms/gba | missing False
total 12
drwxr-xr-x 3 root root 4096 Oct 16 10:07 .
drwxr-xr-x 19 root root 4096 Oct 16 10:07 ..
drwxr-xr-x 2 root root 4096 Oct 16 10:07 romm
removed
ls: cannot access '/nonexistent-not-used': No such file or directory
@@ -0,0 +1,55 @@
12:03:44 drive BEFORE: roms/gba/Test Game (World).gba
roms/roms/gba/Test Game (World).gba
roms/roms/snes/Uploaded Game (World).sfc
ls: cannot access 'roms-bios': No such file or directory
12:03:44 sync -> 200 {'ok': True, 'data': {'ok': True, 'updated': ['ghost', 'romm'], 'message': 'Sablonok frissítve — frissítve: ghost, romm'
12:03:48 synced compose has library/roms (compose, applied): ['1', '1']
12:03:51 container mounts still OLD before restart: /mnt/felhom-drives/hdd_1/userdata/roms -> /romm/library
/mnt/felhom-drives/hdd_1/appdata/romm/resources -> /romm/resources
12:04:09 restart -> 200 {'ok': True, 'data': {'state': 'starting'}, 'message': 'Stack romm restart requested — state now: starting'}
12:04:49 state running
12:04:53 container mounts AFTER restart: /mnt/felhom-drives/hdd_1/appdata/romm/resources -> /romm/resources
/mnt/felhom-drives/hdd_1/userdata/roms-bios -> /romm/library/bios
/mnt/felhom-drives/hdd_1/userdata/roms -> /romm/library/roms
1
12:04:54 romm login -> 200 session cookie set
12:04:54 socket namespace: 40{"sid":"2tHxxZMy6Ed5YlRoAAAB"}
12:04:54 scan event sent -> 200 OK
12:05:05 scan finished: scan:done [{"total_platforms": 3, "total_roms": 8, "scanned_platforms": 3, "new_platforms": 2, "identified_platforms": 1, "scanned_roms": 8, "new_roms": 7, "identified_roms": 0, "scanned_firmware": 0, "new_firmware": 0, "updated_roms": 1, "new_files": 0}]
12:05:06 library AFTER the change: [[["DRILL-2026-08-21", 5], ["gba", 1], ["roms", 2], ["snes", 1]], [["árvíztűrő-tükörfúrógép.txt", "DRILL-2026-08-21", "roms/DRILL-2026-08-21"], ["binary-1mb.bin", "DRILL-2026-08-21", "roms/DRILL-2026-08-21"], ["gba", "roms", "roms/roms"], ["nested", "DRILL-2026-08-21", "roms/DRILL-2026-08-21"], ["plain.txt", "DRILL-2026-08-21", "roms/DRILL-2026-08-21"], ["SENTINEL.txt", "DRILL-2026-08-21", "roms/DRILL-2026-08-21"], ["snes", "roms", "roms/roms"], ["Test Game (World).gba", "gba", "roms/gba"], ["Uploaded Game (World).sfc", "snes", "roms/snes"]]]
12:05:06 rom: árvíztűrő-tükörfúrógép.txt | platform DRILL-2026-08-21 | fs_path roms/DRILL-2026-08-21 | missing_from_fs False
12:05:06 rom: binary-1mb.bin | platform DRILL-2026-08-21 | fs_path roms/DRILL-2026-08-21 | missing_from_fs False
12:05:06 rom: gba | platform roms | fs_path roms/roms | missing_from_fs False
12:05:06 rom: nested | platform DRILL-2026-08-21 | fs_path roms/DRILL-2026-08-21 | missing_from_fs False
12:05:06 rom: plain.txt | platform DRILL-2026-08-21 | fs_path roms/DRILL-2026-08-21 | missing_from_fs False
12:05:06 rom: SENTINEL.txt | platform DRILL-2026-08-21 | fs_path roms/DRILL-2026-08-21 | missing_from_fs False
12:05:06 rom: snes | platform roms | fs_path roms/roms | missing_from_fs False
12:05:06 rom: Test Game (World).gba | platform gba | fs_path roms/gba | missing_from_fs False
12:05:06 rom: Uploaded Game (World).sfc | platform snes | fs_path roms/snes | missing_from_fs True
12:05:10 drive AFTER: roms/gba/Test Game (World).gba
roms/roms/gba/Test Game (World).gba
roms/roms/snes/Uploaded Game (World).sfc
drwxrwsr-x 2 root 1000 4096 Oct 16 10:03 roms-bios
1061011
12:05:32 data tab [BIOS]: először be kell jelentkezned a Fájlkezelőbe. ROM gyűjtemény ↗ Itt tárolódnak a fájljaid. Biztonsági mentés készül róla. BIOS fájlok (RomM) ↗ Itt tárolódnak a fájljaid. Biztonsági mentés készül róla. Biztonsági mentés Az alkalmazás adatbázisa és
12:05:32 data tab [ROM gy]: a fájlokat? Ezeket a mappákat a Fájlkezelőben éred el. Előfordulhat, hogy először be kell jelentkezned a Fájlkezelőbe. ROM gyűjtemény ↗ Itt tárolódnak a fájljaid. Biztonsági mentés készül róla. BIOS fájlok (RomM) ↗ Itt tárolódnak a fájljaid. Biz
12:05:35 2026/10/16 09:29:16 delete.go:824: [DEBUG] [stacks] RemoveStack romm: processing 0 backup paths for removal (base=/mnt/felhom-drives/hdd_1/backups)
2026/10/16 09:35:39 manager.go:1659: [DEBUG] Env vars for compose: [PATH, HOSTNAME, FELHOM_BOOTSTRAP_PATH, HOME, DB_PASSWORD, MYSQL_ROOT_PASSWORD, ROMM_AUTH_SECRET_KEY, HDD_PATH, DOMAIN, SUBDOMAIN, DOMAIN, USERDATA_PATH, IMPORT_PATH] (13 app + 0 syst
2026/10/16 09:36:17 manager.go:1659: [DEBUG] Env vars for compose: [PATH, HOSTNAME, FELHOM_BOOTSTRAP_PATH, HOME, DOMAIN, ROMM_AUTH_SECRET_KEY, SUBDOMAIN, DB_PASSWORD, DOMAIN, HDD_PATH, MYSQL_ROOT_PASSWORD, USERDATA_PATH, IMPORT_PATH] (13 app + 0 syst
2026/10/16 10:03:51 manager.go:1659: [DEBUG] Env vars for compose: [PATH, HOSTNAME, FELHOM_BOOTSTRAP_PATH, HOME, DOMAIN, HDD_PATH, MYSQL_ROOT_PASSWORD, ROMM_AUTH_SECRET_KEY, SUBDOMAIN, DB_PASSWORD, DOMAIN, USERDATA_PATH, IMPORT_PATH] (13 app + 0 syst
2026/10/16 10:04:12 manager.go:1659: [DEBUG] Env vars for compose: [PATH, HOSTNAME, FELHOM_BOOTSTRAP_PATH, HOME, DOMAIN, HDD_PATH, MYSQL_ROOT_PASSWORD, ROMM_AUTH_SECRET_KEY, SUBDOMAIN, DB_PASSWORD, DOMAIN, USERDATA_PATH, IMPORT_PATH] (13 app + 0 syst
12:05:46 == BIOS: a dummy file at the drive's roms-bios/psx/ (= container /romm/library/bios/psx), and a psx game so the platform exists
12:05:50 /mnt/felhom-drives/hdd_1/userdata/roms-bios/psx/scph1001.bin
12:05:50 romm login -> 200 session cookie set
12:05:50 socket namespace: 40{"sid":"mzdx6XqGxwCwz9fuAAAD"}
12:05:51 scan event sent -> 200 OK
12:06:00 scan finished: scan:done [{"total_platforms": 4, "total_roms": 9, "scanned_platforms": 4, "new_platforms": 1, "identified_platforms": 2, "scanned_roms": 9, "new_roms": 1, "identified_roms": 0, "scanned_firmware": 1, "new_firmware": 1, "updated_roms": 0, "new_files": 0}]
12:06:01 psx platform: roms 1 firmware [('scph1001.bin', 'bios/psx')]
@@ -0,0 +1,13 @@
11:55:59 == Scenario B base (OLD mount): roms/gba/Test Game (World).gba = placed per the OLD first steps;
11:55:59 roms/roms/<platform>/ = where RomM's own upload writes under the old mount; add one there
11:56:02 /mnt/felhom-drives/hdd_1/userdata/roms/gba/Test Game (World).gba
/mnt/felhom-drives/hdd_1/userdata/roms/roms/gba/Test Game (World).gba
/mnt/felhom-drives/hdd_1/userdata/roms/roms/snes/Uploaded Game (World).sfc
1061011
11:56:03 romm login -> 200 session cookie set
11:56:04 socket namespace: 40{"sid":"YpuQ3Mat1FaN_fSeAAAB"}
11:56:04 scan event sent -> 200 OK
11:56:13 scan finished: scan:done [{"total_platforms": 2, "total_roms": 2, "scanned_platforms": 2, "new_platforms": 1, "identified_platforms": 2, "scanned_roms": 2, "new_roms": 1, "identified_roms": 0, "scanned_firmware": 0, "new_firmware": 0, "updated_roms": 0, "new_files": 0}]
11:56:13 library BEFORE the template change: ([('gba', 1), ('snes', 1)], [('Test Game (World).gba', 'gba', 'roms/gba'), ('Uploaded Game (World).sfc', 'snes', 'roms/snes')])
@@ -0,0 +1,11 @@
RED (good) serve-time hold removed: rc=1 ran=5 failed=['--- FAIL: TestResolveManagedFloor_HoldsAWithdrawnBuildFromEverySource (0.11s)', '--- FAIL: TestResolveManagedFloor_HoldsAWithdrawnBuildFromEverySource/stored_global_value (0.04s)', '--- FAIL: TestResolveManagedFloor_HoldsAWithdrawnBuildFromEverySource/per-customer_override (0.03s)', '--- FAIL: TestResolveManagedFloor_HoldsAWithdrawnBuildFromEverySource/config/env_default (0.04s)', '--- FAIL: TestReportACK_NeverServesAWithdrawnBuild (0.04s)']
RED (good) global form refusal removed: rc=1 ran=1 failed=['--- FAIL: TestGlobalFloorForm_RefusesAWithdrawnBuild (0.04s)']
RED (good) customer form refusal removed: rc=1 ran=1 failed=['--- FAIL: TestCustomerFloorForm_RefusesAWithdrawnBuild (0.05s)']
RED (good) impact probe check removed: rc=1 ran=1 failed=['--- FAIL: TestGlobalFloorImpact_WithdrawnBuildIsNotValid (0.04s)']
RED (good) configuration page notice removed: rc=1 ran=1 failed=['--- FAIL: TestConfigurationPage_SaysWithdrawnOnlyForAWithdrawnFloor (0.09s)']
RED (good) customer page notice removed: rc=1 ran=1 failed=['--- FAIL: TestCustomerPage_SaysWithdrawnOnlyForAWithdrawnFloor (0.10s)']
RED (good) warn logged on every report: rc=1 ran=1 failed=['--- FAIL: TestReportACK_NeverServesAWithdrawnBuild (0.04s)']
RED (good) hub list gains a tag: rc=1 ran=1 failed=['--- FAIL: TestSpoiledControllerTags_MatchTheSkill (0.00s)']
RED (good) skill pre-check loses a tag: rc=1 ran=1 failed=['--- FAIL: TestSpoiledControllerTags_MatchTheSkill (0.00s)']
RED (good) skill marked line loses a tag: rc=1 ran=1 failed=['--- FAIL: TestSpoiledControllerTags_MatchTheSkill (0.00s)']
restored, all green again: True ok gitea.dooplex.hu/admin/felhom-hub/internal/store 0.152s | ok gitea.dooplex.hu/admin/felhom-hub/internal/web 0.414s | ok gitea.dooplex.hu/admin/felhom-hub/internal/api 0.061s
@@ -0,0 +1,38 @@
11:51:42 [1] deploy -> 202 {'ok': True, 'message': 'Telepítés elindítva – az állapot a kártyán követhető'}
11:53:38 [1] deployed, controller state=running, pinned={'ghost': 'ghost:6.67.0-alpine'}
11:53:38 gate: blog is gated — passed as the household (cookie set)
11:53:38 deploy ok: True | app answers: True
11:53:39 owner setup (device 1, through the gate as the household) -> 201 {"users":[{"id":"6ad1f4164cd89c00016e5779","name":"Felhom Teszt","slug":"felhom","email":"owner@example.invalid","profile_image":null,"cover_image":null,"bio":n
11:53:39 setup status now -> 200 {"setup":[{"status":true}]}
11:53:39 DEVICE 2 sign-in (public address, fresh jar) -> 401 {"error":"this app is waiting for its first setup"}
11:53:39 DEVICE 2 /users/me -> 401 {"error":"this app is waiting for its first setup"}
11:53:43 [2026-10-16 11:53:39] WARN Missing mail.from config, falling back to a generated email address. Please update your config file and set a valid from address
[2026-10-16 11:53:39] WARN Missing mail.from config, falling back to a generated email address. Please update your config file and set a valid from address
[2026-10-16 11:53:39] ERROR Failed to send email. Reason: connect ECONNREFUSED 127.0.0.1:587.
Failed to send email. Reason: connect ECONNREFUSED 127.0.0.1:587.
"Unable to send welcome email, your site will continue to function."
"Please see https://docs.ghost.org/config/#mail for instructions on configuring email."
Error ID:
Error Code: 
Error: connect ECONNREFUSED 127.0.0.1:587
at createMailError (/var/lib/ghost/versions/6.67.0/core/server/services/mail/ghost-mailer.js:109:10)
11:54:10 public setup probe -> 200 after 15s: {"setup":[{"status":true}]}
11:54:10 DEVICE 2 sign-in (public address, fresh cookie jar) -> 201 Created
11:54:10 DEVICE 2 /users/me -> 200 {"users":[{"id":"6ad1f4164cd89c00016e5779","name":"Felhom Teszt","slug":"felhom","email":"owner@example.invalid","profile_image":null,"cover_image":null,"bio":n
11:54:14 Failed to send email. Reason: connect ECONNREFUSED 127.0.0.1:587.
"Unable to send welcome email, your site will continue to function."
"Please see https://docs.ghost.org/config/#mail for instructions on configuring email."
Error Code: 
Error: connect ECONNREFUSED 127.0.0.1:587
at createMailError (/var/lib/ghost/versions/6.67.0/core/server/services/mail/ghost-mailer.js:109:10)
11:54:43 == DEVICE 3: another fresh client after the user HAS logged in once (Ghost skips the code only on the first-ever login, api/endpoints/session.js:27)
11:54:43 DEVICE 3 sign-in -> 500 {"errors":[{"message":"Failed to send email. Please check your site configuration and try again.","context":null,"type":"EmailError","details":null,"property":null,"help":"Please see https://docs.ghost.org/config/#mail for instructions on configuring email.","code":"ESOCKET","id":"a100a239-2a07-4fe1-8203-13aba1f87e1b","ghostErrorCode":null}]}
11:54:43 DEVICE 3 /users/me -> 403 {"errors":[{"message":"Authorization failed","context":"Unable to determine the authenticated user or integration. Check that cookies are being passed through if using session authentication.","type":
11:54:43 DEVICE 2 (already verified) /users/me -> 200 {"users":[{"id":"6ad1f4164cd89c00016e5779","name":"Felhom Teszt","slug":"felhom"
11:54:47 [2026-10-16 11:54:43] WARN Missing mail.from config, falling back to a generated email address. Please update your config file and set a valid from address
Failed to send email. Please check your site configuration and try again.
"Please see https://docs.ghost.org/config/#mail for instructions on configuring email."
EmailError: Failed to send email. Please check your site configuration and try again.
@@ -0,0 +1,19 @@
12:09:50 settings tab: app e-mail box present: True | checked: False | global-off notice: True
12:09:53 compose on box: 21: # device it has not seen (`security.staffDeviceVerification`, default true: ghost/core/core/shared/
29: - security__staffDeviceVerification=${GHOST_STAFF_DEVICE_VERIFICATION:-false}
31: - mail__transport=SMTP
32: - mail__options__host=${GHOST_MAIL_HOST:-}
33: - mail__options__port=${GHOST_MAIL_PORT:-}
34: - mail__options__secure=false
35: - mail__from=${GHOST_MAIL_FROM:-}
12:09:55 restart -> 200
12:10:13 env in container (keys + the flag): mail__options__host=
mail__options__secure=false
security__staffDeviceVerification=false
mail__options__port=
mail__from=
mail__transport=SMTP
12:10:14 E (app e-mail OFF) — new device sign-in -> 201 Created
12:10:14 /users/me -> 200 {"users":[{"id":"6ad1f4164cd89c00016e5779","name":"Felhom Te
@@ -0,0 +1,42 @@
12:11:17 FOUND: box-wide app e-mail on = False | from name = ''
12:11:18 owner e-mail -> 200 ghost-r1090@felhom.eu
12:11:18 box-wide app e-mail ON -> 200
12:11:19 Ghost's own switch ON -> 303 https://192.168.0.155/apps/ghost/settings?flash=flash.tier2.app_email_on
12:11:27 env in container: mail__from=<set>
mail__options__host=felhom-controller
security__staffDeviceVerification=true
mail__transport=SMTP
mail__options__port=2526
mail__options__secure=false
12:11:28 E (app e-mail ON) — new device sign-in -> 404 {"errors":[{"message":"There is no user with that email address.","context":null,"type":"NotFoundError","details":null,"property":null,"help":null,"code":null,"id":"3f64c1e8-4577-43fa-a2ea-25e86869d845","ghostErrorCode":null}]}
12:11:39 controller shim/relay log: 2026/10/16 10:11:18 lifecycle.go:50: [INFO] [mailrelay] app-email shim ON (host=felhom-controller plain=[::]:2525 tls=[::]:2465)
2026/10/16 10:11:18 handlers.go:3094: [INFO] [web] App-email globally enabled (from_name="")
2026/10/16 10:11:18 auth.go:142: [DEBUG] [web] auth: valid session for POST /stacks/ghost/app-email
2026/10/16 10:11:18 server.go:624: [DEBUG] [web] ServeHTTP: POST /stacks/ghost/app-email from 172.18.0.4:37816
2026/10/16 10:11:18 mailenv.go:137: [INFO] [stacks] App-email for ghost set to true — recreating to apply
2026/10/16 10:11:18 manager.go:1659: [DEBUG] Env vars for compose: [PATH, HOSTNAME, FELHOM_BOOTSTRAP_PATH, HOME, DOMAIN, SUBDOMAIN, DOMAIN, IMPORT_PATH, GHOST_MAIL_HOST, GHOST_MAIL_PORT, GHOST_MAIL_FROM, GHOST_STAFF_DEVICE_VERIFICATION] (12 app + 0 system)
2026/10/16 10:11:19 tier2_config_handler.go:92: [INFO] [web] App-email for ghost set to true
2026/10/16 10:11:22 manager.go:1659: [DEBUG] Env vars for compose: [PATH, HOSTNAME, FELHOM_BOOTSTRAP_PATH, HOME, DOMAIN, SUBDOMAIN, DOMAIN, IMPORT_PATH, GHOST_MAIL_HOST, GHOST_MAIL_PORT, GHOST_MAIL_FROM, GHOST_STAFF_DEVICE_VERIFICATION] (12 app + 0 system)
12:11:42 ghost log: [2026-10-16 12:11:22] INFO [EmailAnalytics:gifts] Initialized with SEQUENTIAL processing mode
[2026-10-16 12:11:23] INFO Running milestone emails job on Sun Oct 18 2026 12:11:23 GMT+0200 (Central European Summer Time)
[2026-10-16 12:11:23] ERROR Could not get webhook secret for ActivityPub SyntaxError: Unexpected non-whitespace character after JSON at position 4 (line 1 column 5)
[2026-10-16 12:11:23] ERROR No webhook secret found - cannot initialise
12:11:52 == retry with the owner's new address
12:11:54 E (app e-mail ON) — new device sign-in -> 403 {"errors":[{"message":"User must verify session to login.","context":"A 6-digit sign-in verification code has been sent to your email to keep your account safe.","type":"Needs2FAError","details":null,"property":null,"help":null,"code":"2FA_NEW_DEVICE_DETECTED","id":"a93c0867-4fc3-4b0e-a334-ebb454607657","ghostErrorCode":null}]}
12:11:55 /users/me before the code -> 403 {"errors":[{"message":"Authorization failed","context":"Unable to determine the
12:12:08 controller mail log: 2026/10/16 10:11:18 server.go:153: [INFO] [mailrelay] plaintext+STARTTLS listener on [::]:2525
2026/10/16 10:11:18 server.go:154: [INFO] [mailrelay] implicit-TLS listener on [::]:2465
2026/10/16 10:11:18 server.go:155: [INFO] [mailrelay] plaintext-only (no STARTTLS) listener on [::]:2526
2026/10/16 10:11:18 lifecycle.go:50: [INFO] [mailrelay] app-email shim ON (host=felhom-controller plain=[::]:2525 tls=[::]:2465)
2026/10/16 10:11:54 server.go:299: [INFO] [mailrelay] message relayed (from="ghost@felhom.eu" rcpts=1 bytes=14585 hub=200)
12:12:11 ghost log: [2026-10-16 12:11:22] INFO [EmailAnalytics:gifts] Initialized with SEQUENTIAL processing mode
[2026-10-16 12:11:23] INFO Running milestone emails job on Sun Oct 18 2026 12:11:23 GMT+0200 (Central European Summer Time)
"A 6-digit sign-in verification code has been sent to your email to keep your account safe."
12:12:29 mail in the catch-all: from ghost@felhom.eu to ghost-r1090@felhom.eu at 10:11:55Z, subject '<code> is your Ghost sign in verification code'
12:12:29 verify with the mailed code -> 200 OK
12:12:29 /users/me after the code -> 200 {"users":[{"id":"6ad1f4164cd89c00016e5779","name":"Felhom Te
@@ -0,0 +1,22 @@
Fri Oct 16 10:14:13 UTC 2026
adventurelog ghcr.io/seanmorley15/adventurelog-backend:v0.13.0 Up 7 hours (healthy)
adventurelog-frontend ghcr.io/seanmorley15/adventurelog-frontend:v0.13.0 Up 7 hours (healthy)
adventurelog-postgres postgis/postgis:16-3.5-alpine Up 7 hours (healthy)
bentopdf ghcr.io/alam00000/bentopdf:v2.8.6 Up 7 hours (healthy)
bookstack lscr.io/linuxserver/bookstack:26.09.1 Up 7 hours (healthy)
bookstack-db mariadb:12.3 Up 7 hours (healthy)
calibre-web crocodilestick/calibre-web-automated:v4.0.8 Up 7 hours (healthy)
cloudflared cloudflare/cloudflared:2026.9.3 Up 7 hours (healthy)
docmost docmost/docmost:0.96.0 Up 7 hours (healthy)
docmost-postgres postgres:18-alpine Up 7 hours (healthy)
docmost-redis redis:7-alpine Up 7 hours (healthy)
felhom-controller gitea.dooplex.hu/admin/felhom-controller:0.319.0-rc.1 Up 8 seconds (health: starting)
filebrowser gtstef/filebrowser:1.5.6-stable Up 7 hours (healthy)
home-assistant ghcr.io/home-assistant/home-assistant:2026.9.4 Up 7 hours (healthy)
opengist ghcr.io/thomiceli/opengist:1.15 Up 7 hours (healthy)
paperless-postgres postgres:18-alpine Up 7 hours (healthy)
paperless-redis redis:7-alpine Up 7 hours (healthy)
paperless-webserver ghcr.io/paperless-ngx/paperless-ngx:2.20.15 Up 7 hours (healthy)
plex plexinc/pms-docker:1.41.4.9463-630c9f557 Up 7 hours (healthy)
privatebin privatebin/pdo:2.0.6 Up 7 hours (healthy)
traefik traefik:v3.7.13 Up 7 hours
@@ -0,0 +1,22 @@
Fri Oct 16 09:11:23 UTC 2026
adventurelog ghcr.io/seanmorley15/adventurelog-backend:v0.13.0 Up 6 hours (healthy)
adventurelog-frontend ghcr.io/seanmorley15/adventurelog-frontend:v0.13.0 Up 6 hours (healthy)
adventurelog-postgres postgis/postgis:16-3.5-alpine Up 6 hours (healthy)
bentopdf ghcr.io/alam00000/bentopdf:v2.8.6 Up 6 hours (healthy)
bookstack lscr.io/linuxserver/bookstack:26.09.1 Up 6 hours (healthy)
bookstack-db mariadb:12.3 Up 6 hours (healthy)
calibre-web crocodilestick/calibre-web-automated:v4.0.8 Up 6 hours (healthy)
cloudflared cloudflare/cloudflared:2026.9.3 Up 6 hours (healthy)
docmost docmost/docmost:0.96.0 Up 6 hours (healthy)
docmost-postgres postgres:18-alpine Up 6 hours (healthy)
docmost-redis redis:7-alpine Up 6 hours (healthy)
felhom-controller gitea.dooplex.hu/admin/felhom-controller:0.318.0 Up 2 hours (healthy)
filebrowser gtstef/filebrowser:1.5.6-stable Up 6 hours (healthy)
home-assistant ghcr.io/home-assistant/home-assistant:2026.9.4 Up 6 hours (healthy)
opengist ghcr.io/thomiceli/opengist:1.15 Up 6 hours (healthy)
paperless-postgres postgres:18-alpine Up 6 hours (healthy)
paperless-redis redis:7-alpine Up 6 hours (healthy)
paperless-webserver ghcr.io/paperless-ngx/paperless-ngx:2.20.15 Up 6 hours (healthy)
plex plexinc/pms-docker:1.41.4.9463-630c9f557 Up 6 hours (healthy)
privatebin privatebin/pdo:2.0.6 Up 6 hours (healthy)
traefik traefik:v3.7.13 Up 6 hours
@@ -0,0 +1,35 @@
12:12:48 Ghost's switch OFF -> 303 https://192.168.0.155/apps/ghost/settings?flash=flash.tier2.app_email_off
12:12:48 box-wide app e-mail back to found (on=False) -> 200
12:12:48 box-wide now on = False
12:12:59 ghost stop -> 200
12:13:31 ghost remove (drive data kept=False) -> 200 {'ok': True, 'data': {'removed': 'ghost', 'volumes_removed': ['ghost_ghost_content'], 'hdd_paths_removed': [], 'hdd_paths_preserved': [], 'hdd_note': 'Az alkalmazás nem tárolt saját adatot külső megha
12:13:36 romm stop -> 200
12:14:05 romm remove (drive data kept=True) -> 502 {'_raw': 'Bad Gateway'}
12:14:13 DRIVE:
/mnt/felhom-drives/hdd_1/userdata/roms
/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21
/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/SENTINEL.txt
/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/binary-1mb.bin
/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/nested
/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/nested/őszibarack.md
/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/plain.txt
/mnt/felhom-drives/hdd_1/userdata/roms/DRILL-2026-08-21/árvíztűrő-tükörfúrógép.txt
/mnt/felhom-drives/hdd_1/userdata/roms/roms
find: '/mnt/felhom-drives/hdd_1/userdata/roms-bios': No such file or directory
STACKS:
romm app.yaml: /opt/docker/stacks/romm/app.yaml
ghost app.yaml: ls: cannot access '/opt/docker/stacks/ghost/app.yaml': No such file or directory
VOLUMES:
romm_romm_redis_data.pre-aof-fix-20260924T135937Z
LEFT:
ls: cannot access '/nonexistent-not-used': No such file or directory
12:14:44 romm now: {'deployed': False, 'state': 'not_deployed'}
12:14:44 romm after: {'deployed': False, 'state': 'not_deployed'}
12:14:48 docker-compose.yml
0
romm_romm_redis_data.pre-aof-fix-20260924T135937Z
9
2026-10-16_113539
2026-10-16_120728
+17
View File
@@ -1,3 +1,20 @@
## v0.155.0 — the floor can never deliver a withdrawn controller build (R-1085) (2026-10-16)
`documentation/architecture/09-update-architecture.md` §0 (the [DESIGN] paragraph on withdrawn builds).
- One list: `internal/store/spoiled_tags.go` — `0.315.0` and `0.316.3` (R-1081), each with why it is withdrawn.
- **Input:** the global and per-customer floor forms refuse a listed tag (with or without `v`) with the new flash
`floor_spoiled`, store nothing, and log one `[WARN]`. The impact probe answers `valid:false, spoiled:true`.
A valid other version still saves.
- **Serve time:** `ResolveManagedFloor` holds a listed floor first (`HeldSpoiled`, its own `HoldReason`), whatever
stored it — an old value, a per-customer override, or the config/env default (also warned about at start).
The report ACK omits it and logs ONE `[WARN]` per change naming the tag. The Configuration and customer pages say
„this version is withdrawn"; the stored value is not rewritten.
- Tests: the store (every source held; an ordinary floor served), the forms, the probe, both branches of both page
notices, the real report handler (no floor in the ACK, one WARN across three reports), and the parity pin against
the `felhom-build-deploy` skill's two lines. Ten red-proofs, each seen failing
(`documentation/audits/romm-ghost-2026-10-16/B/hub-redproofs.txt`).
## v0.154.0 — the host page shows which physical disk each storage place sits on (2026-10-15)
Storage identity build A (`documentation/architecture/16-storage-physical-identity.md` §3; agent v0.157.0).
+6
View File
@@ -252,6 +252,12 @@ func main() {
// Phase 2 managed updates: seed the global controller-version floor fallback (config/env). A
// hub_settings row set via the operator UI overrides this at runtime.
dataStore.SetDefaultMinControllerVersion(cfg.ControllerUpdates.DefaultMinVersion)
if reason, spoiled := store.SpoiledControllerTag(cfg.ControllerUpdates.DefaultMinVersion); spoiled {
// R-1085: the config/env default is the one floor no form checks — ResolveManagedFloor holds it at serve
// time; say so at start, where whoever set it will look.
logger.Printf("[WARN] Default controller-version floor %s is a WITHDRAWN controller build (%s) — it is HELD for every box (R-1085)",
cfg.ControllerUpdates.DefaultMinVersion, reason)
}
if cfg.ControllerUpdates.DefaultMinVersion != "" {
logger.Printf("[INFO] Default controller-version floor: %s", cfg.ControllerUpdates.DefaultMinVersion)
}
+8
View File
@@ -677,6 +677,14 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) {
h.logger.Printf("[INFO] managed floor SERVED for %s: floor %s, agent requirement %q from %s (golden %s)",
payload.CustomerID, fd.Floor, fd.MinAgent, src, fd.GoldenVersion)
}
} else if fd.HeldSpoiled {
// R-1085: a withdrawn build — ONE [WARN] per change naming the tag and why, not one per report.
note := "spoiled|" + fd.RequestedFloor
if prev, ok := h.floorNotes.Load(payload.CustomerID); !ok || prev.(string) != note {
h.floorNotes.Store(payload.CustomerID, note)
h.logger.Printf("[WARN] managed floor HELD for %s: %s (controller floor withheld)",
payload.CustomerID, fd.HoldReason())
}
} else if fd.Held {
h.floorNotes.Delete(payload.CustomerID)
// ONE sentence, from the decision itself — the two hold reasons must never drift apart
@@ -0,0 +1,53 @@
package api
import (
"bytes"
"encoding/json"
"log"
"net/http"
"strings"
"testing"
)
// R-1085 over the wire — Scenario C through the PRODUCTION path (the real report handler → ResolveManagedFloor):
// a box is never told to install a withdrawn build, and the hub says why in ONE [WARN] naming the tag, not one per
// report. Red-proof: delete the SpoiledControllerTag block in store.ResolveManagedFloor → the ACK carries 0.316.3.
func TestReportACK_NeverServesAWithdrawnBuild(t *testing.T) {
h, st, _ := newTestHandler(t)
var logs bytes.Buffer
h.logger = log.New(&logs, "", 0)
ack := func() (interface{}, bool) {
rr := do(h, http.MethodPost, "/report", globalKey, `{"customer_id":"c"}`)
if rr.Code != http.StatusOK {
t.Fatalf("report status = %d", rr.Code)
}
var m map[string]interface{}
_ = json.Unmarshal(rr.Body.Bytes(), &m)
v, ok := m["min_controller_version"]
return v, ok
}
// A value stored the way an old hub (no form refusal) would have stored it.
if err := st.SetGlobalMinControllerVersion("0.316.3"); err != nil {
t.Fatal(err)
}
for i := 0; i < 3; i++ {
if v, ok := ack(); ok {
t.Fatalf("report %d: the ACK must OMIT a withdrawn floor, got %v", i, v)
}
}
if n := strings.Count(logs.String(), "[WARN] managed floor HELD for c:"); n != 1 {
t.Errorf("want exactly ONE [WARN] across three reports, got %d:\n%s", n, logs.String())
}
if !strings.Contains(logs.String(), "0.316.3") || !strings.Contains(logs.String(), "WITHDRAWN") {
t.Errorf("the [WARN] must name the tag and say withdrawn:\n%s", logs.String())
}
// The control, on the same handler: an ordinary floor is served.
if err := st.SetGlobalMinControllerVersion("0.317.0"); err != nil {
t.Fatal(err)
}
if v, ok := ack(); !ok || v != "0.317.0" {
t.Fatalf("an ordinary floor must be served, got %v (present=%v)", v, ok)
}
}
+41
View File
@@ -0,0 +1,41 @@
package store
import (
"sort"
"strings"
)
// spoiledControllerTags are published felhom-controller tags whose image does NOT hold the code their number
// says (R-1081). They are never re-pushed and never deleted — a version number is pushed once — so the only
// safe thing left is that nothing ever SELECTS them (R-1085). The household's button and the daily auto-update
// take the highest tag and cannot reach them; the floor targets a version EXACTLY, so the floor is the path this
// list closes: both floor forms refuse a listed tag (web/configs.go), and ResolveManagedFloor HOLDS one that
// arrives any other way (an old stored value, the config/env default).
//
// THE SAME LIST is the marked „never deploy" line in `skills/felhom-build-deploy/SKILL.md`, for the hand deploy
// that names a tag. TestSpoiledControllerTags_MatchTheSkill fails when the two differ.
var spoiledControllerTags = map[string]string{
// 2026-10-15: running the build refusal against a real tag rebuilt and re-pushed 0.315.0 a fourth time, with
// v0.316.0's code under it (digest 8887343011d6 → b809201841f7).
"0.315.0": "re-pushed with v0.316.0's code under it",
// 2026-10-15: built from the SHARED working copy, which held 31 uncommitted files of another session's work.
"0.316.3": "built from a working copy holding another session's uncommitted files",
}
// SpoiledControllerTag reports whether v (a leading "v" and spaces allowed) is a withdrawn controller build, and
// why. A version that is not listed answers ("", false).
func SpoiledControllerTag(v string) (reason string, spoiled bool) {
v = strings.TrimPrefix(strings.TrimSpace(v), "v")
reason, spoiled = spoiledControllerTags[v]
return reason, spoiled
}
// SpoiledControllerTags lists the withdrawn controller builds, sorted — for the parity test and the logs.
func SpoiledControllerTags() []string {
out := make([]string, 0, len(spoiledControllerTags))
for v := range spoiledControllerTags {
out = append(out, v)
}
sort.Strings(out)
return out
}
+110
View File
@@ -0,0 +1,110 @@
package store
import (
"os"
"path/filepath"
"reflect"
"regexp"
"strings"
"testing"
)
// R-1085, the serve-time half: a withdrawn build is HELD however it reached the floor — a stored global value (as
// one written before the form refusal existed), a per-customer override, or the config/env default. Red-proof:
// delete the SpoiledControllerTag block in ResolveManagedFloor → every case below serves the tag.
func TestResolveManagedFloor_HoldsAWithdrawnBuildFromEverySource(t *testing.T) {
cases := []struct {
name string
setup func(t *testing.T, st *Store)
tag string
}{
{"stored global value", func(t *testing.T, st *Store) {
if err := st.SetGlobalMinControllerVersion("0.316.3"); err != nil {
t.Fatal(err)
}
}, "0.316.3"},
{"per-customer override", func(t *testing.T, st *Store) {
if err := st.SetMinControllerVersion("c", "0.315.0"); err != nil {
t.Fatal(err)
}
}, "0.315.0"},
{"config/env default", func(t *testing.T, st *Store) { st.SetDefaultMinControllerVersion("0.316.3") }, "0.316.3"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
st := newTestStore(t)
if err := st.SaveCustomerConfig(&CustomerConfig{CustomerID: "c", RetrievalPassword: "pw", APIKey: "k"}); err != nil {
t.Fatal(err)
}
tc.setup(t, st)
d := st.ResolveManagedFloor("c")
if d.Floor != "" || !d.Held || !d.HeldSpoiled {
t.Fatalf("a withdrawn build must be HELD and served as no floor, got %+v", d)
}
if d.RequestedFloor != tc.tag {
t.Errorf("RequestedFloor = %q, want %q (the message needs it)", d.RequestedFloor, tc.tag)
}
if r := d.HoldReason(); !strings.Contains(r, "WITHDRAWN") || !strings.Contains(r, tc.tag) {
t.Errorf("the hold reason must say withdrawn and name the tag, got %q", r)
}
if got := st.EffectiveMinControllerVersion("c"); got != tc.tag {
t.Errorf("the stored value must be left as it is (the page shows why), got %q", got)
}
})
}
}
// The control: a version that is not listed is served exactly as before (uncoupled, no manifest).
func TestResolveManagedFloor_AnUnlistedFloorIsServed(t *testing.T) {
st := newTestStore(t)
if err := st.SetGlobalMinControllerVersion("0.317.0"); err != nil {
t.Fatal(err)
}
if d := st.ResolveManagedFloor("c"); d.Floor != "0.317.0" || d.Held || d.HeldSpoiled {
t.Fatalf("an ordinary floor must be served, got %+v", d)
}
}
func TestSpoiledControllerTag_AcceptsTheFormsSpellings(t *testing.T) {
for _, v := range []string{"0.316.3", "v0.316.3", " 0.315.0 ", "v0.315.0"} {
if _, ok := SpoiledControllerTag(v); !ok {
t.Errorf("%q must be recognised as withdrawn", v)
}
}
for _, v := range []string{"", "0.317.0", "0.316.30", "0.316", "0.315.0-rc.1"} {
if _, ok := SpoiledControllerTag(v); ok {
t.Errorf("%q must NOT be withdrawn", v)
}
}
}
// THE PARITY PIN (R-1085 B3): the hub's list equals BOTH places the hand deploy reads it — the marked „never deploy"
// line and the step-3 pre-check in skills/felhom-build-deploy/SKILL.md. A missing skill file is a failure, never a
// skip. Red-proof: add "0.316.4" to spoiledControllerTags, or drop a tag from either skill line → this fails.
func TestSpoiledControllerTags_MatchTheSkill(t *testing.T) {
p := filepath.Join("..", "..", "..", "skills", "felhom-build-deploy", "SKILL.md")
b, err := os.ReadFile(p)
if err != nil {
t.Fatalf("the skill holds the hand deploy's copy of the list and must be readable: %v", err)
}
ver := regexp.MustCompile(`\d+\.\d+\.\d+`)
var marked, precheck []string
for _, line := range strings.Split(string(b), "\n") {
switch {
case strings.Contains(line, "<!-- spoiled-controller-tags -->"):
for _, m := range regexp.MustCompile("`(\\d+\\.\\d+\\.\\d+)`").FindAllStringSubmatch(line, -1) {
marked = append(marked, m[1])
}
case strings.HasPrefix(line, "VER=<VER>; case \""):
q := line[len("VER=<VER>; case \""):]
precheck = ver.FindAllString(q[:strings.Index(q, "\"")], -1)
}
}
want := SpoiledControllerTags()
if !reflect.DeepEqual(marked, want) {
t.Errorf("the skill's marked never-deploy line lists %v, the hub %v", marked, want)
}
if !reflect.DeepEqual(precheck, want) {
t.Errorf("the skill's step-3 pre-check refuses %v, the hub %v", precheck, want)
}
}
+17
View File
@@ -2431,6 +2431,11 @@ type ManagedFloorDecision struct {
// MinAgentSourceDeclared (the floor's own declaration, used ONLY above the golden). "" = no
// requirement at all (an uncoupled release).
MinAgentSource string
// HeldSpoiled is the third hold reason (R-1085): the floor names a WITHDRAWN controller build
// (SpoiledControllerTag). It wins over the other two — no agent check makes a faulty image safe.
HeldSpoiled bool
// SpoiledReason says why that build is withdrawn ("" unless HeldSpoiled).
SpoiledReason string
}
// Where a managed floor's agent requirement came from. See ManagedFloorDecision.MinAgentSource.
@@ -2452,6 +2457,9 @@ func (d ManagedFloorDecision) HoldReason() string {
switch {
case !d.Held:
return ""
case d.HeldSpoiled:
return fmt.Sprintf("held: floor %s is a WITHDRAWN controller build (%s) — set another version (R-1085)",
d.EffectiveFloorForMessage(), d.SpoiledReason)
case d.HeldBeyondGolden:
return fmt.Sprintf("held: floor %s is ABOVE the vouched golden %s, so its agent requirement is unknown — vouch a golden carrying the floor's controller (publish-train rule 1)",
d.EffectiveFloorForMessage(), goldenOrUnknown(d.GoldenVersion))
@@ -2511,6 +2519,7 @@ func agentOrUnknownVersion(v string) string {
//
// Logic:
// - effective floor "" → nothing to serve (no floor configured);
// - effective floor is a WITHDRAWN build (spoiled_tags.go) → HOLD + HeldSpoiled (R-1085);
// - manifest MinAgent "" → UNCOUPLED release: serve the floor as-is (no agent gating);
// - floor ABOVE the vouched golden's controller → HOLD (requirement unknown) + flag;
// - agent_version known AND ≥ MinAgent → serve the floor;
@@ -2523,6 +2532,14 @@ func (s *Store) ResolveManagedFloor(customerID string) ManagedFloorDecision {
if d.Floor == "" {
return d
}
// R-1085: a withdrawn build is never served, whatever put it here — the forms refuse it, but a value stored
// before that refusal existed, or the config/env default, reaches this line unchecked. Checked FIRST: no agent
// comparison makes a faulty image safe. The stored value is left as it is; the pages say why it is held.
if reason, spoiled := SpoiledControllerTag(d.Floor); spoiled {
d.Held, d.HeldSpoiled, d.SpoiledReason = true, true, reason
d.Floor = ""
return d
}
man := s.GetArtifactManifest()
d.MinAgent, d.GoldenVersion = man.MinAgent, man.GoldenVersion
if d.MinAgent != "" {
+32 -1
View File
@@ -320,6 +320,9 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
GlobalFloor string // global default (hub_settings → config/env)
EffectiveFloor string // override else global ("" = no floor)
BelowFloor bool // current < effective floor (would auto-update)
// EffectiveFloorWithdrawn (R-1085) — why the effective floor is a withdrawn build, "" when it is not. The
// box is served no floor then (store.ResolveManagedFloor); the page says so instead of „will auto-update".
EffectiveFloorWithdrawn string
NotifPrefs *store.NotificationPrefs
RecentNotifications []store.NotificationLogEntry
@@ -500,6 +503,10 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
GlobalFloor: globalFloor,
EffectiveFloor: effectiveFloor,
BelowFloor: belowFloor,
EffectiveFloorWithdrawn: func() string {
r, _ := store.SpoiledControllerTag(effectiveFloor)
return r
}(),
NotifPrefs: notifPrefs,
RecentNotifications: recentNotifs,
@@ -1216,12 +1223,28 @@ func (s *Server) countBoxesBelowFloor(proposedGlobal string) int {
// (GET /configuration/global-floor/impact?v=X.Y.Z). Read-only JSON; blank v = 0.
func (s *Server) handleGlobalFloorImpact(w http.ResponseWriter, r *http.Request) {
v, ok := normalizeFloorInput(r.URL.Query().Get("v"))
_, spoiled := store.SpoiledControllerTag(v)
if spoiled {
ok = false // R-1085: a withdrawn build is not a floor the dialog may offer to confirm
}
count := 0
if ok && v != "" {
count = s.countBoxesBelowFloor(v)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"version": v, "valid": ok, "below": count})
_ = json.NewEncoder(w).Encode(map[string]any{"version": v, "valid": ok, "spoiled": spoiled, "below": count})
}
// refuseSpoiledFloor is the input half of R-1085, shared by the two floor forms (NOT normalizeFloorInput, which
// also validates agent and golden versions, whose numbering is their own): a withdrawn controller build is never
// saved as a floor. It logs the refusal and reports whether the caller must stop. The serve-time half is
// store.ResolveManagedFloor.
func (s *Server) refuseSpoiledFloor(v, where string) bool {
reason, spoiled := store.SpoiledControllerTag(v)
if spoiled {
s.logger.Printf("[WARN] %s floor %q REFUSED: a withdrawn controller build (%s) — nothing saved (R-1085)", where, v, reason)
}
return spoiled
}
// handleSetGlobalFloor sets (or clears) the global controller-version floor (Phase 2 managed
@@ -1236,6 +1259,10 @@ func (s *Server) handleSetGlobalFloor(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/configuration?flash=floor_invalid", http.StatusSeeOther)
return
}
if s.refuseSpoiledFloor(v, "global") {
http.Redirect(w, r, "/configuration?flash=floor_spoiled", http.StatusSeeOther)
return
}
ma, flash := s.floorDeclaredMinAgent(r, v)
if flash != "" {
http.Redirect(w, r, "/configuration?flash="+flash, http.StatusSeeOther)
@@ -1598,6 +1625,10 @@ func (s *Server) handleSetCustomerFloor(w http.ResponseWriter, r *http.Request,
http.Redirect(w, r, "/customers/"+customerID+"?flash=floor_invalid", http.StatusSeeOther)
return
}
if s.refuseSpoiledFloor(v, "customer "+customerID) {
http.Redirect(w, r, "/customers/"+customerID+"?flash=floor_spoiled", http.StatusSeeOther)
return
}
ma, flash := s.floorDeclaredMinAgent(r, v)
if flash != "" {
http.Redirect(w, r, "/customers/"+customerID+"?flash="+flash, http.StatusSeeOther)
+8 -3
View File
@@ -73,10 +73,10 @@ type Server struct {
// offsiteKeyAudit runs the daily off-site key check on demand (decision 69). nil → 503.
offsiteKeyAudit func(ctx context.Context) any
// offsiteWindowAdmin: operator one-shot grant / weekly switch (decision 68). nil → 503.
offsiteWindowGrant func(customerID string) error
offsiteWindowGrant func(customerID string) error
offsiteWindowGrantMax func(customerID string, maxRemove int) error
osUpdates OSUpdateAdmin
offsiteWindowSwitch func(on bool) error
offsiteWindowSwitch func(on bool) error
// operator key-file clean-up (decision 72) and abandonment cancel (decision 74). nil → 503.
offsiteRemoveUnpinned func(ctx context.Context, customerID string) (int, error)
offsiteAbandonCancel func(customerID, by string) (int, error)
@@ -139,7 +139,7 @@ func New(store *store.Store, passwordHash, apiKey, version string, staleThreshol
// mailHeld drives the MAIL-HOLD banner on every operator page (internal/mailhold). Read at render time, so the
// banner disappears the moment the marker is gone. Pinned by TestMailHoldBanner_*.
"mailHeld": func() bool { return srv != nil && srv.mailHold.Held() },
"timeAgo": timeAgo,
"timeAgo": timeAgo,
"timeAgoPtr": func(t *time.Time) string {
if t == nil {
return "—"
@@ -1234,6 +1234,11 @@ func (s *Server) renderConfiguration(w http.ResponseWriter, r *http.Request, cre
"AssetLastSync": assetLastSync,
"GlobalFloor": s.store.GetGlobalMinControllerVersion(),
"FloorRes": s.store.ResolveGlobalFloor(),
// R-1085: why the effective global floor is a withdrawn build ("" = it is not) — boxes are served no floor.
"FloorWithdrawn": func() string {
r, _ := store.SpoiledControllerTag(s.store.ResolveGlobalFloor().Effective)
return r
}(),
// R-472: the MinAgent declared with the global floor, and the golden it is compared against.
"FloorDeclaredMinAgent": s.store.GlobalFloorDeclaredMinAgent(),
"FloorGoldenVersion": s.store.GetArtifactManifest().GoldenVersion,
+130
View File
@@ -0,0 +1,130 @@
package web
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// R-1085, the input half — Scenario D: both floor forms refuse a withdrawn build (with or without the "v"), store
// NOTHING, and still save an ordinary version. Red-proof: remove a refuseSpoiledFloor call → its form saves the tag.
func TestGlobalFloorForm_RefusesAWithdrawnBuild(t *testing.T) {
s, st := newTestServer(t)
if err := st.SetGlobalMinControllerVersion("0.314.0"); err != nil {
t.Fatal(err)
}
for _, v := range []string{"0.315.0", "v0.316.3"} {
loc := postGlobalFloor(t, s, url.Values{"min_controller_version": {v}})
if !strings.Contains(loc, "flash=floor_spoiled") {
t.Fatalf("%s: want flash=floor_spoiled, got %q", v, loc)
}
if got := st.GetGlobalMinControllerVersion(); got != "0.314.0" {
t.Fatalf("%s: a refused form must store nothing — the floor is now %q", v, got)
}
}
if loc := postGlobalFloor(t, s, url.Values{"min_controller_version": {"0.317.0"}}); !strings.Contains(loc, "flash=floor_set") ||
st.GetGlobalMinControllerVersion() != "0.317.0" {
t.Fatalf("an ordinary version must still save, got %q / %q", loc, st.GetGlobalMinControllerVersion())
}
}
func TestCustomerFloorForm_RefusesAWithdrawnBuild(t *testing.T) {
s, st := newTestServer(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", RetrievalPassword: "x", APIKey: "y"}); err != nil {
t.Fatal(err)
}
post := func(v string) string {
form := url.Values{"min_controller_version": {v}}
r := httptest.NewRequest(http.MethodPost, "/customers/c1/floor", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.handleSetCustomerFloor(w, r, "c1")
return w.Header().Get("Location")
}
for _, v := range []string{"v0.315.0", "0.316.3"} {
if loc := post(v); !strings.Contains(loc, "flash=floor_spoiled") {
t.Fatalf("%s: want flash=floor_spoiled, got %q", v, loc)
}
if cfg, _ := st.GetCustomerConfig("c1"); cfg.MinControllerVersion != "" {
t.Fatalf("%s: a refused override must store nothing, got %q", v, cfg.MinControllerVersion)
}
}
if loc := post("0.317.0"); !strings.Contains(loc, "flash=floor_set") {
t.Fatalf("an ordinary version must still save, got %q", loc)
}
}
// The confirm dialog's probe answers valid:false, spoiled:true — and counts nothing — for a withdrawn build.
func TestGlobalFloorImpact_WithdrawnBuildIsNotValid(t *testing.T) {
s, st := newTestServer(t)
seedReport(t, st, "old", "0.300.0")
probe := func(v string) (valid, spoiled bool, below int) {
w := httptest.NewRecorder()
s.handleGlobalFloorImpact(w, httptest.NewRequest(http.MethodGet, "/configuration/global-floor/impact?v="+v, nil))
var resp struct {
Valid bool `json:"valid"`
Spoiled bool `json:"spoiled"`
Below int `json:"below"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
return resp.Valid, resp.Spoiled, resp.Below
}
if valid, spoiled, below := probe("0.316.3"); valid || !spoiled || below != 0 {
t.Errorf("withdrawn: want valid=false spoiled=true below=0, got %v %v %d", valid, spoiled, below)
}
if valid, spoiled, below := probe("0.317.0"); !valid || spoiled || below != 1 {
t.Errorf("ordinary: want valid=true spoiled=false below=1, got %v %v %d", valid, spoiled, below)
}
}
// Template gates, one render per branch (hub rules: seam-wiring covers template gates): the Configuration page and
// the customer page say „withdrawn" when the effective floor is a withdrawn build, and say nothing otherwise.
func TestConfigurationPage_SaysWithdrawnOnlyForAWithdrawnFloor(t *testing.T) {
for _, tc := range []struct {
floor string
want bool
}{{"0.316.3", true}, {"0.317.0", false}} {
s, st := newTestServer(t)
if err := st.SetGlobalMinControllerVersion(tc.floor); err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
s.handleConfiguration(rr, httptest.NewRequest(http.MethodGet, "/configuration", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d", rr.Code)
}
if got := strings.Contains(rr.Body.String(), `data-floor-withdrawn="1"`); got != tc.want {
t.Errorf("global floor %s: withdrawn notice shown = %v, want %v", tc.floor, got, tc.want)
}
}
}
func TestCustomerPage_SaysWithdrawnOnlyForAWithdrawnFloor(t *testing.T) {
for _, tc := range []struct {
floor string
want bool
}{{"0.315.0", true}, {"0.317.0", false}} {
s, st := newTestServer(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "acme", CustomerName: "Acme", Domain: "acme.hu",
RetrievalPassword: "pw", APIKey: "k", Status: "active"}); err != nil {
t.Fatal(err)
}
if err := st.SaveReport("acme", []byte(tabsTestReportJSON)); err != nil { // the floor card renders only for a reporting box
t.Fatal(err)
}
if err := st.SetMinControllerVersion("acme", tc.floor); err != nil {
t.Fatal(err)
}
html := renderCustomerPage(t, s, "acme")
if got := strings.Contains(html, `data-floor-withdrawn="1"`); got != tc.want {
t.Errorf("customer floor %s: withdrawn notice shown = %v, want %v", tc.floor, got, tc.want)
}
}
}
@@ -56,6 +56,9 @@
{{if eq .Flash "floor_invalid"}}
<div class="flash flash-error">Invalid version — use X.Y.Z (or blank to clear).</div>
{{end}}
{{if eq .Flash "floor_spoiled"}}
<div class="flash flash-error">This version is withdrawn (a faulty build) and cannot be set as the minimum. Nothing was saved.</div>
{{end}}
{{if eq .Flash "floor_needs_min_agent"}}
<div class="flash flash-error">This floor is above the vouched golden — declare its MinAgent (from the release's CHANGELOG header). Nothing was saved.</div>
{{end}}
@@ -125,6 +128,7 @@
<p style="margin: 0 0 0.75rem; font-size: 0.85em;">
Effective floor:
{{if .FloorRes.Effective}}<code>v{{.FloorRes.Effective}}</code>{{else}}<span class="text-muted">none</span>{{end}}
{{if .FloorWithdrawn}}<span data-floor-withdrawn="1" style="color: #ef4444;">— this version is withdrawn ({{.FloorWithdrawn}}): every box is served no floor; set another version</span>{{end}}
{{if eq .FloorRes.Source "db"}}
<span style="color: #cbd5e1;">— source: <strong>DB (hub_settings)</strong>{{if .FloorRes.EnvValue}}; env fallback would be <code>v{{.FloorRes.EnvValue}}</code>{{end}}</span>
{{else if eq .FloorRes.Source "env"}}
@@ -53,6 +53,7 @@
{{else if eq .Flash "pbsdr_reissued"}}PBS DR credentials re-issued — a fresh one-time token secret is staged for the agent. Confirm it actually landed: the host's <code>pvesm status</code> must show the PBS entry <strong>active</strong>. A converged agent can report <code>applied</code> while the storage still authenticates 401 (R-39).
{{else if eq .Flash "floor_set"}}Controller-version floor saved.
{{else if eq .Flash "floor_invalid"}}Invalid version — use X.Y.Z (or blank to clear).
{{else if eq .Flash "floor_spoiled"}}This version is withdrawn (a faulty build) and cannot be set as the minimum. Nothing was saved.
{{else if eq .Flash "floor_needs_min_agent"}}This floor is above the vouched golden — declare its MinAgent (from the release's CHANGELOG header). Nothing was saved.
{{else if eq .Flash "floor_min_agent_invalid"}}Invalid MinAgent — use X.Y.Z. Nothing was saved.
{{else if eq .Flash "offsite_frozen"}}Offsite storage FROZEN (read-only) — new backups and prune will fail until unfrozen.
@@ -696,7 +697,8 @@
<span class="label">Effective floor (min. version)</span>
<span class="value">
{{if .EffectiveFloor}}v{{.EffectiveFloor}}
{{if .BelowFloor}}<span style="color: #f59e0b; margin-left: 0.3em;">● below floor — will auto-update</span>
{{if .EffectiveFloorWithdrawn}}<span data-floor-withdrawn="1" style="color: #ef4444; margin-left: 0.3em;">● this version is withdrawn ({{.EffectiveFloorWithdrawn}}) — the box is served no floor; set another version</span>
{{else if .BelowFloor}}<span style="color: #f59e0b; margin-left: 0.3em;">● below floor — will auto-update</span>
{{else}}<span style="color: var(--text-2); margin-left: 0.3em;">— at/above floor</span>{{end}}
{{else}}<span style="color: var(--text-2);">none (Phase 2 inert)</span>{{end}}
</span>
+7 -1
View File
@@ -39,11 +39,17 @@ Version bumps are ldflags-only (`-X main.version` / `-X main.Version`) — build
9201 is golden/bootstrap-managed — **NO compose file**. `felhom-controller-bootstrap.service` docker-runs
the tag written in `/etc/felhom-controller-image` (anonymous Gitea pull). Data volume + encryption key persist.
**Never deploy these controller tags** <!-- spoiled-controller-tags --> `0.315.0` `0.316.3` — withdrawn builds (R-1081,
R-1085): their image is not the code their number says, and neither is ever re-pushed. The hub refuses them as a floor
and holds one that arrives any other way; step 3 below refuses them by hand. The hub's list
(`hub/internal/store/spoiled_tags.go`) and these two lines must agree — `TestSpoiledControllerTags_MatchTheSkill`.
```bash
# 1. commit+push the repo (clean-tree gate above)
# 2. build+push image, LOCAL (build.sh does NOT git-pull — the explicit pull is load-bearing)
cd $FELHOM_ROOT/build/felhom-controller && git -C $FELHOM_ROOT/git/felhom-controller pull && ./build.sh <VER> --push
# 3. deploy in the guest
# 3. deploy in the guest — refused for a withdrawn build (the list is the marked line above; spoiled-controller-tags)
VER=<VER>; case " 0.315.0 0.316.3 " in *" ${VER#v} "*) echo "REFUSED: $VER is a withdrawn build (R-1085)"; false;; esac && \
ssh felhom-pve "pct exec 9201 -- bash -c 'docker pull gitea.dooplex.hu/admin/felhom-controller:<VER> && echo gitea.dooplex.hu/admin/felhom-controller:<VER> > /etc/felhom-controller-image && systemctl restart felhom-controller-bootstrap.service'"
# 4. verify
ssh felhom-pve "pct exec 9201 -- docker ps --filter name=felhom-controller --format '{{.Image}} {{.Status}}'"