2 Commits

10 changed files with 683 additions and 1 deletions
@@ -0,0 +1,3 @@
07:47:02 anonymous round trip
HTTP 200 648132539 bytes
34bd8f5c37ec8423d4c23d66f4069835837485b867119326455248952827f6f7
@@ -0,0 +1,60 @@
# Golden 0.295.0 — bake + publish + vouch, 2026-10-05 (afternoon)
Procedure: `documentation/runbooks/RUNBOOK-manual-build.md` §4.0 and §4.1 steps 1–5, in the drill VM on DooPlex.
| | Previous (`../golden-0.294.0-2026-10-05/`) | This bake |
|---|---|---|
| `build-golden.sh` | v3.2.0 (sha256 `645b3b659cba…`) | same file, unchanged (sha256 `645b3b659cba…`; VM copy matched) |
| Controller | `felhom-controller:0.294.0` | **`felhom-controller:0.295.0`** (MinAgent 0.131.0, unchanged) |
| Docker engine | the operator-approved set `os-docker-20261004-142842` | same pinned set (still in force) |
| Guest packages | template | template — `GOLDEN_GUEST_PKGS` EMPTY: no guest release is in force (the earliest real approval is after ~11:07 UTC today, `audits/night-2026-10-04/PREDICTION.md`) |
## Launch
- Drill VM reverted to `virgin`, cold-booted per §4.0 (07:28:27 UTC); `pveversion` = `pve-manager/9.2.2`.
- `pveam update` → `update successful`; template `debian-13-standard_13.6-1_amd64.tar.zst` (the only `_amd64`
debian-13 entry), downloaded, `checksum verified`.
- `/root/bake-run.sh` in the VM reads the token from the file; launched as transient unit `golden-bake`.
- Token copied file → file (`scp`). `systemctl show golden-bake -p Environment -p ExecStart | grep -c -F <token>` =
**0** (control with the token appended = **1**).
## Pass markers (from `bake.log`)
```
[golden] Docker engine set PINNED to the approved release: containerd.io=2.3.6-1~debian.13~trixie … docker-ce=5:29.8.2-1~debian.13~trixie …
[golden] no approved guest release given - the template versions stay; first-night count vs an approved release: n/a
[golden] pending Debian upgrades in the baked guest (what a FUTURE approval may bring): 49
docker OK (overlay2; data-root /var/lib/docker)
live-restore: on
INFO: including mount point rootfs ('/') in backup
INFO: including mount point mp0 ('/var/lib/felhom') in backup
[golden] pre-delete existing: HTTP 404 (404/204 expected)
[golden] upload OK (HTTP 201)
GOLDEN_VERSION=0.295.0
GOLDEN_SHA256=34bd8f5c37ec8423d4c23d66f4069835837485b867119326455248952827f6f7
```
No `excluding` and no `FATAL` in the log.
## Round trip
Anonymous GET of `…/generic/felhom-golden/0.295.0/golden.tar.zst`: HTTP 200, **648132539 bytes**, sha256
`34bd8f5c37ec8423d4c23d66f4069835837485b867119326455248952827f6f7` = the printed sha (`02-round-trip.txt`).
## Secrets
Saved-log leak grep for the literal token: **0**; positive control (a throwaway copy with the token appended): **1**,
copy shredded.
## Vouch (step 5)
`POST /configuration/artifacts` (operator Basic auth): agent **0.145.0** (sha256 `894da35c…`), golden **0.295.0**
(sha256 `34bd8f5c…`), `min_agent` **0.131.0**, wrapper sha empty. Result and hub log line: `../../audits/catchup-2026-10-05/
partE/vouch.txt`. The controller floor stays per customer (demo-hp, demo-felhom, tester-1 at 0.295.0); the global floor
is unchanged (Tester 2 not moved).
## Teardown
`pct destroy 9100 --purge`; `shred -u` of the token, the runner script and the log in the VM (log copied off first);
`poweroff`; qemu gone (`ps -eo comm | grep -c qemu-system-x86` = 0); `qemu-img snapshot -a virgin`. Host: nothing
provisioned.
@@ -0,0 +1,342 @@
[golden] build-golden.sh v3.2.0 — baking controller gitea.dooplex.hu/admin/felhom-controller:0.295.0
[golden] creating build LXC 9100 (nesting=1,keyctl=1, unprivileged; rootfs 32G + ONE data volume 24G @ /var/lib/felhom, backup=1) …
Logical volume "vm-9100-disk-0" created.
Logical volume pve/vm-9100-disk-0 changed.
Creating filesystem with 8388608 4k blocks and 2097152 inodes
Filesystem UUID: 77b84d7b-4822-4462-998f-0e82e1eafa88
Superblock backups stored on blocks:
32768, 98304, 163840, 229376, 294912, 819200, 884736, 1605632, 2654208,
4096000, 7962624
Logical volume "vm-9100-disk-1" created.
Logical volume pve/vm-9100-disk-1 changed.
Creating filesystem with 6291456 4k blocks and 1572864 inodes
Filesystem UUID: c8d23fa2-f92c-4d6c-a235-34396bc69999
Superblock backups stored on blocks:
32768, 98304, 163840, 229376, 294912, 819200, 884736, 1605632, 2654208,
extracting archive '/var/lib/vz/template/cache/debian-13-standard_13.6-1_amd64.tar.zst'
Total bytes read: 553512960 (528MiB, 77MiB/s)
Detected container architecture: amd64
Creating SSH host key 'ssh_host_ed25519_key' - this may take some time ...
done: SHA256:N/1fFvLeCS9JgSJrtXpUAqOPFmmuzXk40NRGFTnjbqU root@felhom-golden
Creating SSH host key 'ssh_host_rsa_key' - this may take some time ...
done: SHA256:tD+MhaLIvIpDvzR/Q2JHv7v3LAcuk/RMTzBZ54sDY1s root@felhom-golden
Creating SSH host key 'ssh_host_ecdsa_key' - this may take some time ...
done: SHA256:YY8fM8Gh9pl6FtLg/BoA5tXILyryRGPNHK/9Xl53MWg root@felhom-golden
[golden] starting + installing Docker (official repo, trixie channel) …
[golden] Docker engine set PINNED to the approved release: containerd.io=2.3.6-1~debian.13~trixie docker-buildx-plugin=0.37.1-1~debian.13~trixie docker-ce=5:29.8.2-1~debian.13~trixie docker-ce-cli=5:29.8.2-1~debian.13~trixie docker-ce-rootless-extras=5:29.8.2-1~debian.13~trixie docker-compose-plugin=5.6.0-1~debian.13~trixie
apt-listchanges: Can't set locale; make sure $LC_* and $LANG are correct!
perl: warning: Setting locale failed.
perl: warning: Please check that your locale settings:
LANGUAGE = (unset),
LC_ALL = (unset),
LC_CTYPE = (unset),
LC_NUMERIC = (unset),
LC_COLLATE = (unset),
LC_TIME = (unset),
LC_MESSAGES = (unset),
LC_MONETARY = (unset),
LC_ADDRESS = (unset),
LC_IDENTIFICATION = (unset),
LC_MEASUREMENT = (unset),
LC_PAPER = (unset),
LC_TELEPHONE = (unset),
LC_NAME = (unset),
LANG = "en_US.UTF-8"
are supported and installed on your system.
perl: warning: Falling back to the standard locale ("C").
locale: Cannot set LC_CTYPE to default locale: No such file or directory
locale: Cannot set LC_MESSAGES to default locale: No such file or directory
locale: Cannot set LC_ALL to default locale: No such file or directory
apt-listchanges: Can't set locale; make sure $LC_* and $LANG are correct!
perl: warning: Setting locale failed.
perl: warning: Please check that your locale settings:
LANGUAGE = (unset),
LC_ALL = (unset),
LC_CTYPE = (unset),
LC_NUMERIC = (unset),
LC_COLLATE = (unset),
LC_TIME = (unset),
LC_MESSAGES = (unset),
LC_MONETARY = (unset),
LC_ADDRESS = (unset),
LC_IDENTIFICATION = (unset),
LC_MEASUREMENT = (unset),
LC_PAPER = (unset),
LC_TELEPHONE = (unset),
LC_NAME = (unset),
LANG = "en_US.UTF-8"
are supported and installed on your system.
perl: warning: Falling back to the standard locale ("C").
locale: Cannot set LC_CTYPE to default locale: No such file or directory
locale: Cannot set LC_MESSAGES to default locale: No such file or directory
locale: Cannot set LC_ALL to default locale: No such file or directory
installed: containerd.io 2.3.6-1~debian.13~trixie
installed: docker-buildx-plugin 0.37.1-1~debian.13~trixie
installed: docker-ce 5:29.8.2-1~debian.13~trixie
installed: docker-ce-cli 5:29.8.2-1~debian.13~trixie
installed: docker-ce-rootless-extras 5:29.8.2-1~debian.13~trixie
installed: docker-compose-plugin 5.6.0-1~debian.13~trixie
[golden] no approved guest release given - the template versions stay; first-night count vs an approved release: n/a
[golden] pending Debian upgrades in the baked guest (what a FUTURE approval may bring): 49
[golden] baking daemon.json: classic overlay2 driver (containerd-snapshotter OFF) + log rotation …
[golden] wiring the single data volume (R-165 variant V-c): /var/lib/felhom/{docker,sys_drive} -> binds …
[golden] verifying Docker works in the build guest (storage driver should be overlay2 on the ext4 data volume) …
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
4f55086f7dd0: Pulling fs layer
4f55086f7dd0: Download complete
4f55086f7dd0: Pull complete
Digest: sha256:5e23090353324d887c48ad5e5c56d294eab81588df9605b07d1afe895f9cc8f8
Status: Downloaded newer image for hello-world:latest
docker OK (overlay2; data-root /var/lib/docker)
live-restore: on
/var/lib/docker is a real mount: /dev/mapper/pve-vm--9100--disk--1[/docker] ext4
/mnt/sys_drive is a real mount: /dev/mapper/pve-vm--9100--disk--1[/sys_drive] ext4
both paths are ONE filesystem: /dev/mapper/pve-vm--9100--disk--1 23317576
[golden] baking the in-guest controller image gitea.dooplex.hu/admin/felhom-controller:0.295.0 (no registry cred at deploy) …
WARNING! Your credentials are stored unencrypted in '/root/.docker/config.json'.
Configure a credential helper to remove this warning. See
https://docs.docker.com/go/credential-store/
0.295.0: Pulling from admin/felhom-controller
774043ccc8cc: Pulling fs layer
ab6b448d4be9: Pulling fs layer
23a5bfa58353: Pulling fs layer
862a57157567: Pulling fs layer
ec5d49fd2de1: Pulling fs layer
049d42a206a8: Pulling fs layer
862a57157567: Waiting
ec5d49fd2de1: Waiting
049d42a206a8: Waiting
23a5bfa58353: Verifying Checksum
23a5bfa58353: Download complete
862a57157567: Verifying Checksum
862a57157567: Download complete
774043ccc8cc: Verifying Checksum
774043ccc8cc: Download complete
ec5d49fd2de1: Verifying Checksum
ec5d49fd2de1: Download complete
049d42a206a8: Verifying Checksum
049d42a206a8: Download complete
ab6b448d4be9: Verifying Checksum
ab6b448d4be9: Download complete
774043ccc8cc: Pull complete
ab6b448d4be9: Pull complete
23a5bfa58353: Pull complete
862a57157567: Pull complete
ec5d49fd2de1: Pull complete
049d42a206a8: Pull complete
Digest: sha256:43b75dcb8f618affb19dab5c1e2419ca824c050447ec3f9ebfa44a434354ade9
Status: Downloaded newer image for gitea.dooplex.hu/admin/felhom-controller:0.295.0
gitea.dooplex.hu/admin/felhom-controller:0.295.0
[golden] asking the controller which infra images it manages …
[golden] baking infra images (4): traefik:v3.7.13 cloudflare/cloudflared:2026.9.3 gtstef/filebrowser:1.5.6-stable gitea.dooplex.hu/admin/felhom-samba:1.1.0 …
v3.7.13: Pulling from library/traefik
e2de96513ba9: Pulling fs layer
b686a4f73445: Pulling fs layer
78cb21c375ca: Pulling fs layer
acb2f33459b1: Pulling fs layer
acb2f33459b1: Waiting
e2de96513ba9: Verifying Checksum
e2de96513ba9: Download complete
b686a4f73445: Verifying Checksum
b686a4f73445: Download complete
acb2f33459b1: Verifying Checksum
acb2f33459b1: Download complete
e2de96513ba9: Pull complete
78cb21c375ca: Verifying Checksum
78cb21c375ca: Download complete
b686a4f73445: Pull complete
78cb21c375ca: Pull complete
acb2f33459b1: Pull complete
Digest: sha256:24841fe2de7304c149343d877d2923b4c8800a38ba015dea9174c23b20e344a0
Status: Downloaded newer image for traefik:v3.7.13
docker.io/library/traefik:v3.7.13
2026.9.3: Pulling from cloudflare/cloudflared
2cc7ee286bf3: Pulling fs layer
c172f21841df: Pulling fs layer
218cf840d0d9: Pulling fs layer
f6069939f718: Pulling fs layer
d6b1b89eccac: Pulling fs layer
2780920e5dbf: Pulling fs layer
7c12895b777b: Pulling fs layer
3214acf345c0: Pulling fs layer
52630fc75a18: Pulling fs layer
dd64bf2dd177: Pulling fs layer
b839dfae01f6: Pulling fs layer
ebddc55facdc: Pulling fs layer
c4bc6f35ff5e: Pulling fs layer
b96fe2995f90: Pulling fs layer
58c0c263dc73: Pulling fs layer
bd8962e29291: Pulling fs layer
cac2ae0193cb: Pulling fs layer
f0383d5ebc47: Pulling fs layer
d6b1b89eccac: Waiting
2780920e5dbf: Waiting
7c12895b777b: Waiting
3214acf345c0: Waiting
52630fc75a18: Waiting
dd64bf2dd177: Waiting
b839dfae01f6: Waiting
ebddc55facdc: Waiting
c4bc6f35ff5e: Waiting
b96fe2995f90: Waiting
58c0c263dc73: Waiting
bd8962e29291: Waiting
cac2ae0193cb: Waiting
f0383d5ebc47: Waiting
f6069939f718: Waiting
2cc7ee286bf3: Download complete
218cf840d0d9: Verifying Checksum
218cf840d0d9: Download complete
c172f21841df: Verifying Checksum
c172f21841df: Download complete
f6069939f718: Verifying Checksum
f6069939f718: Download complete
d6b1b89eccac: Verifying Checksum
d6b1b89eccac: Download complete
2780920e5dbf: Verifying Checksum
2780920e5dbf: Download complete
2cc7ee286bf3: Pull complete
7c12895b777b: Verifying Checksum
7c12895b777b: Download complete
3214acf345c0: Verifying Checksum
3214acf345c0: Download complete
52630fc75a18: Verifying Checksum
52630fc75a18: Download complete
dd64bf2dd177: Verifying Checksum
dd64bf2dd177: Download complete
b839dfae01f6: Verifying Checksum
b839dfae01f6: Download complete
c172f21841df: Pull complete
ebddc55facdc: Verifying Checksum
ebddc55facdc: Download complete
c4bc6f35ff5e: Verifying Checksum
c4bc6f35ff5e: Download complete
b96fe2995f90: Verifying Checksum
b96fe2995f90: Download complete
bd8962e29291: Verifying Checksum
bd8962e29291: Download complete
58c0c263dc73: Verifying Checksum
58c0c263dc73: Download complete
cac2ae0193cb: Verifying Checksum
cac2ae0193cb: Download complete
218cf840d0d9: Pull complete
f0383d5ebc47: Verifying Checksum
f0383d5ebc47: Download complete
f6069939f718: Pull complete
d6b1b89eccac: Pull complete
2780920e5dbf: Pull complete
7c12895b777b: Pull complete
3214acf345c0: Pull complete
52630fc75a18: Pull complete
dd64bf2dd177: Pull complete
b839dfae01f6: Pull complete
ebddc55facdc: Pull complete
c4bc6f35ff5e: Pull complete
b96fe2995f90: Pull complete
58c0c263dc73: Pull complete
bd8962e29291: Pull complete
cac2ae0193cb: Pull complete
f0383d5ebc47: Pull complete
Digest: sha256:072c067d25ccbe61d46e18f0d0723255f2bb5304f7317caa95b27031520ff92c
Status: Downloaded newer image for cloudflare/cloudflared:2026.9.3
docker.io/cloudflare/cloudflared:2026.9.3
1.5.6-stable: Pulling from gtstef/filebrowser
55afa1ecc21d: Pulling fs layer
8ed8f35f8d4f: Pulling fs layer
989b226a579c: Pulling fs layer
660aeead31d5: Pulling fs layer
4f4fb700ef54: Pulling fs layer
adce24567e4c: Pulling fs layer
f17ea56b313b: Pulling fs layer
6b6f3b3efe88: Pulling fs layer
4ed1ca4f3fce: Pulling fs layer
e6fc9c6a5757: Pulling fs layer
d47782d1182a: Pulling fs layer
660aeead31d5: Waiting
4ed1ca4f3fce: Waiting
e6fc9c6a5757: Waiting
d47782d1182a: Waiting
4f4fb700ef54: Waiting
adce24567e4c: Waiting
f17ea56b313b: Waiting
6b6f3b3efe88: Waiting
55afa1ecc21d: Verifying Checksum
55afa1ecc21d: Download complete
660aeead31d5: Verifying Checksum
660aeead31d5: Download complete
989b226a579c: Verifying Checksum
989b226a579c: Download complete
4f4fb700ef54: Verifying Checksum
4f4fb700ef54: Download complete
55afa1ecc21d: Pull complete
adce24567e4c: Verifying Checksum
adce24567e4c: Download complete
f17ea56b313b: Verifying Checksum
f17ea56b313b: Download complete
4ed1ca4f3fce: Verifying Checksum
4ed1ca4f3fce: Download complete
6b6f3b3efe88: Verifying Checksum
6b6f3b3efe88: Download complete
8ed8f35f8d4f: Verifying Checksum
8ed8f35f8d4f: Download complete
d47782d1182a: Verifying Checksum
d47782d1182a: Download complete
e6fc9c6a5757: Verifying Checksum
e6fc9c6a5757: Download complete
8ed8f35f8d4f: Pull complete
989b226a579c: Pull complete
660aeead31d5: Pull complete
4f4fb700ef54: Pull complete
adce24567e4c: Pull complete
f17ea56b313b: Pull complete
6b6f3b3efe88: Pull complete
4ed1ca4f3fce: Pull complete
e6fc9c6a5757: Pull complete
d47782d1182a: Pull complete
Digest: sha256:7c5d7ac8ffda31294d278063cf9d2e04303b39e6dce1f4c691342240ca7703b8
Status: Downloaded newer image for gtstef/filebrowser:1.5.6-stable
docker.io/gtstef/filebrowser:1.5.6-stable
1.1.0: Pulling from admin/felhom-samba
897d797d2723: Pulling fs layer
3051591aa250: Pulling fs layer
ce57a3f93416: Pulling fs layer
fb94eeec2fe1: Pulling fs layer
fb94eeec2fe1: Waiting
ce57a3f93416: Verifying Checksum
ce57a3f93416: Download complete
fb94eeec2fe1: Verifying Checksum
fb94eeec2fe1: Download complete
897d797d2723: Verifying Checksum
897d797d2723: Download complete
3051591aa250: Verifying Checksum
3051591aa250: Download complete
897d797d2723: Pull complete
3051591aa250: Pull complete
ce57a3f93416: Pull complete
fb94eeec2fe1: Pull complete
Digest: sha256:1c17c09422bec0366d7cf0e0fcfc1486ba6c90334a0a5d5c851073a9342f8f10
Status: Downloaded newer image for gitea.dooplex.hu/admin/felhom-samba:1.1.0
gitea.dooplex.hu/admin/felhom-samba:1.1.0
[golden] baking the controller-bootstrap unit (deploys the BAKED controller from the config mount) …
Created symlink '/etc/systemd/system/multi-user.target.wants/felhom-controller-bootstrap.service' → '/etc/systemd/system/felhom-controller-bootstrap.service'.
[golden] baking the controller-bootstrap PATH unit (starts the service on bootstrap-mount hot-plug — B1) …
Created symlink '/etc/systemd/system/multi-user.target.wants/felhom-controller-bootstrap.path' → '/etc/systemd/system/felhom-controller-bootstrap.path'.
[golden] baking the first-boot SSH host-key regeneration unit (F3) …
Created symlink '/etc/systemd/system/multi-user.target.wants/felhom-regen-hostkeys.service' → '/etc/systemd/system/felhom-regen-hostkeys.service'.
[golden] identity-clean + minimize …
[golden] stop + archive …
INFO: including mount point rootfs ('/') in backup
INFO: including mount point mp0 ('/var/lib/felhom') in backup
INFO: archive file size: 618MB
INFO: Finished Backup of VM 9100 (00:00:33)
[golden] DONE. golden archive volid: local:backup/vzdump-lxc-9100-2026_10_05-09_33_33.tar.zst (rootfs 32G + ONE data volume 24G @ /var/lib/felhom, all in the archive)
[golden] publishing golden (648132539 bytes, sha256 34bd8f5c37ec8423…) → https://gitea.dooplex.hu/api/packages/admin/generic/felhom-golden/0.295.0/golden.tar.zst
[golden] pre-delete existing: HTTP 404 (404/204 expected)
[golden] upload OK (HTTP 201)
GOLDEN_VERSION=0.295.0
GOLDEN_SHA256=34bd8f5c37ec8423d4c23d66f4069835837485b867119326455248952827f6f7
[golden] Record in the hub operator UI (Configs → Day-0 artifacts): golden 0.295.0 / 34bd8f5c37ec8423d4c23d66f4069835837485b867119326455248952827f6f7
[golden] (the build guest 9100 is stopped; destroy it with: pct destroy 9100 --purge)
+16
View File
@@ -1,3 +1,19 @@
## v0.134.0 — a box that is off at night: the missed-backup alarm judges a down box; the household hears an outage at most weekly; the catch-up line is allowed (R-872, R-873, R-871) (2026-10-05)
**Controller v0.295.0** sends `backup_catchup_done`; an older controller never does.
- **R-872 (`08` §6.4).** The 05:00 deadline check no longer skips a customer whose node is `down`. A box down at
EVERY deadline (a laptop switched off at night — measured 2026-10-05: `1 skipped (down)`) was never judged. A down
box is now judged on longer lines: `expected_dbdump_missed` after 48 h without `db_dump_completed`,
`expected_backup_missed` after 72 h without a whole-guest backup in any retained host report, and nothing for a box
first seen less than 48 h ago. A box that died last night still raises only its staleness alarm. `disabled` boxes are
still skipped (R-321). `monitor/r872_down_box_test.go`, 3 red-proofs.
- **R-873 (`08` §6.4).** "Your server cannot be reached" (`node_stale`, `node_down`, `host_stale`, `host_down`) reaches the
HOUSEHOLD at most once per 7 days, read from the persisted notification log; the operator still gets every edge; the
recovery mail stays paired (a held-back down mail also holds back its recovery). `notify/r873_liveness_weekly_test.go`.
- **R-871.** `backup_catchup_done` is an allowed event type (info: recorded, never mailed).
- CC decisions 115–116 (`09` §3), operator may reverse.
## v0.133.0 — test approvals end with the test (R-859); the root-file bundle on the System page, in the install manifest, and its alarm (R-840) (2026-10-04) ## v0.133.0 — test approvals end with the test (R-859); the root-file bundle on the System page, in the install manifest, and its alarm (R-840) (2026-10-04)
**Agent v0.143.0** reports the bundle; an older agent shows `unknown` in the new column (never a guess). **Agent v0.143.0** reports the bundle; an older agent shows `unknown` in the new column (never a guess).
+4
View File
@@ -2050,6 +2050,10 @@ var allowedEventTypes = map[string]bool{
// of memory. Household + operator; hu AND en `mail.event.*`; per-app cooldown on both legs. // of memory. Household + operator; hu AND en `mail.event.*`; per-app cooldown on both legs.
"app_stopped_unhealthy": true, "app_stopped_unhealthy": true,
// R-871 (v0.134.0, controller v0.295.0, `09` decision 109): the box was off at its backup time and made the
// missed backups when it came back. Info — recorded on the box's timeline, never mailed.
"backup_catchup_done": true,
// Controller-pushed events // Controller-pushed events
"controller_started": true, "controller_started": true,
"claim_lockout": true, // v0.50.0 — claim/reset code brute-force lockout tripped "claim_lockout": true, // v0.50.0 — claim/reset code brute-force lockout tripped
@@ -0,0 +1,12 @@
package api
import "testing"
// R-871 (v0.134.0): the box's "missed backup made now" line (controller v0.295.0, event backup_catchup_done) must be
// an allowed type, or POST /event answers 400 and the household's timeline line vanishes (hub-event-allowlist trap).
// COMPANION RED-PROOF: remove "backup_catchup_done" from allowedEventTypes → this fails.
func TestR871_CatchUpEventIsAllowed(t *testing.T) {
if !allowedEventTypes["backup_catchup_done"] {
t.Fatal("backup_catchup_done is not an allowed event type — the box's catch-up line would be dropped with a 400")
}
}
+80 -1
View File
@@ -334,6 +334,68 @@ func stalenessState(staleness *StalenessChecker, customerID string) string {
return staleness.GetState(customerID) return staleness.GetState(customerID)
} }
// downDumpMissedAfter / downBackupMissedAfter (R-872): the lines a DOWN box is judged on. 48 h = two nights without
// a database dump (a box that died last night is the staleness alarm's alone). 72 h for the whole-guest backup = the
// agent's own catch-up valve (cadence 24 h + 24 h) plus a day. Decided by CC — operator may reverse.
const (
downDumpMissedAfter = 48 * time.Hour
downBackupMissedAfter = 72 * time.Hour
)
// judgeDownCustomer is R-872's judgement of a box that is down at the deadline. It raises at most one
// expected_dbdump_missed and one expected_backup_missed, each only past its longer line, and never for a box that
// was bound or first reported less than downDumpMissedAfter ago (nothing has been expected yet).
func judgeDownCustomer(s *store.Store, id string, now time.Time, onEvent EventNotifyFunc, logger *log.Logger) (backupMissed, dbdumpMissed int) {
if bound, err := s.HasEverBoundHost(id); err == nil && !bound {
return 0, 0
}
first, ferr := s.GetFirstHostReportAt(id)
if ferr != nil || first.IsZero() || now.Sub(first) < downDumpMissedAfter {
return 0, 0
}
raise := func(typ, msg string) {
if _, err := s.SaveEvent(id, typ, "error", msg, "{}", "hub"); err != nil {
logger.Printf("[WARN] Failed to save %s for %s: %v", typ, id, err)
} else if onEvent != nil {
onEvent(id, typ, "error", msg, "{}", "hub")
}
}
// Database dumps: the newest db_dump_completed in the last 7 days.
if dumps, err := s.GetEventsByType(id, "db_dump_completed", now.Add(-backupEvidenceLookback)); err == nil {
var newest time.Time
for _, e := range dumps {
if e.CreatedAt.After(newest) {
newest = e.CreatedAt
}
}
if newest.IsZero() || now.Sub(newest) > downDumpMissedAfter {
last := "none in the last 7 days"
if !newest.IsZero() {
last = newest.UTC().Format(time.RFC3339)
}
raise("expected_dbdump_missed", fmt.Sprintf("No DB dump for over %s while the box is down at its deadline (last: %s) — it may be switched off at its backup time (R-872)",
downDumpMissedAfter, last))
dbdumpMissed = 1
}
}
// Whole-guest backup: the newest backup any retained host report shows.
if rows, err := s.GetHostReportsSince(id, now.Add(-backupEvidenceLookback)); err == nil {
newest, have := newestBackupEvidence(rows, now)
if !have || now.Sub(newest) > downBackupMissedAfter {
last := "none in the last 7 days"
if have {
last = newest.UTC().Format(time.RFC3339)
}
raise("expected_backup_missed", fmt.Sprintf("No fresh verified backup for over %s while the box is down at its deadline (last: %s) (R-872)",
downBackupMissedAfter, last))
backupMissed = 1
}
}
logger.Printf("[INFO] Deadline check: %s is DOWN — judged on the longer lines (dump %s, whole-guest %s): dump missed=%d backup missed=%d",
id, downDumpMissedAfter, downBackupMissedAfter, dbdumpMissed, backupMissed)
return backupMissed, dbdumpMissed
}
func CheckBackupDeadlines(s *store.Store, staleness *StalenessChecker, onEvent EventNotifyFunc, logger *log.Logger) { func CheckBackupDeadlines(s *store.Store, staleness *StalenessChecker, onEvent EventNotifyFunc, logger *log.Logger) {
customerIDs, err := s.GetActiveCustomerIDs() customerIDs, err := s.GetActiveCustomerIDs()
if err != nil { if err != nil {
@@ -357,7 +419,24 @@ func CheckBackupDeadlines(s *store.Store, staleness *StalenessChecker, onEvent E
// `expected_backup_missed` / `expected_dbdump_missed` every morning about a machine we asked // `expected_backup_missed` / `expected_dbdump_missed` every morning about a machine we asked
// to be quiet. That is R-195's shape exactly — a skip keyed off the wrong fact missing the // to be quiet. That is R-195's shape exactly — a skip keyed off the wrong fact missing the
// customer it would most obviously cover — which is why both doors are closed together. // customer it would most obviously cover — which is why both doors are closed together.
if st := stalenessState(staleness, id); st == "down" || st == StateDisabled { st := stalenessState(staleness, id)
if st == StateDisabled {
skipped++
continue
}
// R-872 (v0.134.0): a box that is DOWN at 05:00 is no longer skipped outright. It was, so that a box that
// just died raises its staleness alarm and not a backup alarm too — but a box that is down at EVERY
// deadline (a laptop switched off at night, Tester 2, measured 2026-10-05: "1 skipped (down)") was then
// never judged at all, and its missing backups stayed silent for ever. A down box is now judged on a
// LONGER line (downDumpMissedAfter / downBackupMissedAfter): a box that died last night still raises only
// its staleness alarm; a box that has gone two nights without a dump raises the backup alarm, down or not.
// Pinned by TestR872_*.
if st == "down" {
if !s.IsCustomerBlocked(id) {
b, d := judgeDownCustomer(s, id, time.Now().UTC(), onEvent, logger)
backupMissed += b
dbdumpMissed += d
}
skipped++ skipped++
continue continue
} }
@@ -0,0 +1,86 @@
package monitor
import (
"database/sql"
"io"
"log"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// R-872 (v0.134.0) — a box DOWN at the 05:00 deadline is judged on longer lines instead of being skipped.
// THE MEASURED SHAPE (Tester 2, 2026-10-05 05:00 Budapest): "Deadline check: … 0 backup missed … 1 skipped (down)" —
// a laptop off at every deadline, no dump ever, no alarm ever.
// COMPANION RED-PROOF: restore `if st == "down" || st == StateDisabled { skipped++; continue }` → the Tester 2
// shape raises nothing.
func downBox(t *testing.T, firstReportAge time.Duration) (*store.Store, string, *StalenessChecker) {
t.Helper()
st, path := seedStalenessCustomer(t, "ok", 3*time.Hour) // the controller report is 3 h old → down
if err := st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "k1"}); err != nil {
t.Fatal(err)
}
if err := st.SaveHostReport("h1", "c1", []byte(`{"pbs_snapshots":[],"backups":[]}`), store.HostReportDenorm{}); err != nil {
t.Fatal(err)
}
r872Backdate(t, path, "UPDATE host_reports SET received_at = ?", time.Now().UTC().Add(-firstReportAge))
sc, _ := newChecker(t, st)
sc.Check()
if sc.GetState("c1") != "down" {
t.Fatalf("setup: state %q, want down", sc.GetState("c1"))
}
return st, path, sc
}
func r872Backdate(t *testing.T, path, q string, at time.Time) {
t.Helper()
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(q, at.Format("2006-01-02 15:04:05")); err != nil {
t.Fatal(err)
}
}
func deadlineEvents(st *store.Store, sc *StalenessChecker) map[string]int {
got := map[string]int{}
CheckBackupDeadlines(st, sc, func(cid, et, sev, msg, det, src string) { got[et]++ }, log.New(io.Discard, "", 0))
return got
}
// Tester 2's shape: bound 5 days ago, down at every deadline, no dump, no backup → both alarms.
func TestR872_DownEveryNightRaisesTheMissedAlarms(t *testing.T) {
st, _, sc := downBox(t, 5*24*time.Hour)
got := deadlineEvents(st, sc)
if got["expected_dbdump_missed"] != 1 || got["expected_backup_missed"] != 1 {
t.Fatalf("events %v — a box off at every deadline must raise the missed-backup alarms, down or not", got)
}
}
// A box that went down last night but made its dump yesterday evening (the catch-up) → no alarm (staleness owns it).
func TestR872_DownWithARecentDumpIsQuiet(t *testing.T) {
st, path, sc := downBox(t, 5*24*time.Hour)
if _, err := st.SaveEvent("c1", "db_dump_completed", "info", "ok", "{}", "controller"); err != nil {
t.Fatal(err)
}
r872Backdate(t, path, "UPDATE events SET created_at = ? WHERE event_type = 'db_dump_completed'", time.Now().UTC().Add(-20*time.Hour))
ts := time.Now().UTC().Add(-30 * time.Hour).Format(time.RFC3339)
if err := st.SaveHostReport("h1", "c1", []byte(`{"pbs_snapshots":[{"backup_time":"`+ts+`","verify_state":"ok"}],"backups":[]}`), store.HostReportDenorm{}); err != nil {
t.Fatal(err)
}
if got := deadlineEvents(st, sc); got["expected_dbdump_missed"] != 0 || got["expected_backup_missed"] != 0 {
t.Fatalf("events %v — a dump 20 h ago and a backup 30 h ago are inside the down box's lines", got)
}
}
// A new box (first report a day ago) that is down → nothing expected yet.
func TestR872_NewDownBoxIsQuiet(t *testing.T) {
st, _, sc := downBox(t, 24*time.Hour)
if got := deadlineEvents(st, sc); len(got) != 0 {
t.Fatalf("events %v for a box bound a day ago", got)
}
}
+25
View File
@@ -758,6 +758,15 @@ var operatorOnlyEvents = map[string]bool{
// Read-only: the register itself stays unexported so nothing can widen it at runtime. // Read-only: the register itself stays unexported so nothing can widen it at runtime.
func IsOperatorOnly(eventType string) bool { return operatorOnlyEvents[eventType] } func IsOperatorOnly(eventType string) bool { return operatorOnlyEvents[eventType] }
// householdLivenessTypes are the "your server cannot be reached" family; householdLivenessQuiet is how long after
// one reached the household the next is held back (R-873).
var (
householdLivenessTypes = []string{"node_stale", "node_down", "host_stale", "host_down"}
householdLivenessWeekly = map[string]bool{"node_stale": true, "node_down": true, "host_stale": true, "host_down": true}
)
const householdLivenessQuiet = 7 * 24 * time.Hour
func (d *Dispatcher) processCustomer(customerID, eventType, severity, message, messageCustomer, detailsJSON, source string) { func (d *Dispatcher) processCustomer(customerID, eventType, severity, message, messageCustomer, detailsJSON, source string) {
// R-97c: operator-tier events stop here, BEFORE prefs are consulted — the point is that no // R-97c: operator-tier events stop here, BEFORE prefs are consulted — the point is that no
// customer configuration can opt in. Logged rather than dropped, so the skip is visible in // customer configuration can opt in. Logged rather than dropped, so the skip is visible in
@@ -785,6 +794,22 @@ func (d *Dispatcher) processCustomer(customerID, eventType, severity, message, m
return return
} }
// R-873 (v0.134.0): "your server cannot be reached" reaches the HOUSEHOLD at most once per 7 days. A box that
// is switched off every night (Tester 2, a laptop — measured 2026-10-05) mailed the household every night and
// "reachable again" every morning. The operator still gets every edge (processOperator, above), and the
// recovery mail stays paired with a down mail the household actually received (processRecovery), so a skipped
// down mail also skips its recovery. Persisted (notification_log), so a hub restart does not reset it.
// Decided by CC — operator may reverse (`08` §3.1). Pinned by TestR873_*.
if householdLivenessWeekly[eventType] {
if last, ok, err := d.store.LastCustomerSentAt(customerID, householdLivenessTypes); err == nil && ok && time.Since(last) < householdLivenessQuiet {
d.store.LogNotification(customerID, eventType, severity, message, "skipped",
"liveness: the household hears this at most once per 7 days (R-873)", "customer")
d.logger.Printf("[INFO] Customer mail skipped for %s/%s — the household was told about an outage %s ago (R-873: at most once per 7 days)",
customerID, eventType, time.Since(last).Round(time.Minute))
return
}
}
// Customer cooldown (from prefs, default 6h) // Customer cooldown (from prefs, default 6h)
cooldownHours := prefs.CooldownHours cooldownHours := prefs.CooldownHours
if cooldownHours <= 0 { if cooldownHours <= 0 {
@@ -0,0 +1,55 @@
package notify
import (
"database/sql"
"io"
"log"
"path/filepath"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// R-873 (v0.134.0) — a household whose box is OFF EVERY NIGHT (Tester 2, a laptop) is told "your server cannot be
// reached" at most once per 7 days; the operator still hears every edge; the recovery mail stays paired.
// COMPANION RED-PROOF: drop the householdLivenessWeekly block in processCustomer → night 2 mails the household.
func TestR873_HouseholdHearsAnOutageAtMostWeekly(t *testing.T) {
path := filepath.Join(t.TempDir(), "d.db")
st, err := store.New(path, log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
defer st.Close()
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "k", RetrievalPassword: "p"})
if err := st.SaveNotificationPrefs("c1", "cust@example.com", []string{"node_down"}, 6); err != nil {
t.Fatal(err)
}
night := func() (cust, op int) {
// a fresh dispatcher each night: the in-memory 6 h cooldown is gone by the next evening anyway
d := NewDispatcher(st, "test-key", "from@felhom.eu", "op@felhom.eu", true, log.New(io.Discard, "", 0))
sent := captureSeam(d)
d.ProcessEvent("c1", "node_down", "error", "No report received for 90m", "{}", "hub")
d.ProcessEvent("c1", "node_recovered", "info", "Reports resumed", "{}", "hub")
return len(mailsFor(*sent, "cust@example.com")), len(mailsFor(*sent, "op@felhom.eu"))
}
if c, o := night(); c != 2 || o != 2 {
t.Fatalf("night 1: household %d mails, operator %d — want down+recovered to both", c, o)
}
if c, o := night(); c != 0 || o != 2 {
t.Fatalf("night 2: household %d mails (want 0 — told once this week), operator %d (want every edge)", c, o)
}
// Eight days later the household is told again.
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
defer db.Close()
old := time.Now().UTC().Add(-8 * 24 * time.Hour).Format("2006-01-02 15:04:05")
if _, err := db.Exec(`UPDATE notification_log SET created_at = ?`, old); err != nil {
t.Fatal(err)
}
if c, _ := night(); c != 2 {
t.Fatalf("after 8 days the household got %d mails, want down+recovered again", c)
}
}