diff --git a/documentation/audits/kernel-night-2026-10-07/A/redproof.txt b/documentation/audits/kernel-night-2026-10-07/A/redproof.txt new file mode 100644 index 00000000..d705a71b --- /dev/null +++ b/documentation/audits/kernel-night-2026-10-07/A/redproof.txt @@ -0,0 +1,4 @@ +go: ring 0 back to select pending-kernel -> TestKernel_Ring0ToldNightStagesThenReboots: --- FAIL: TestKernel_Ring0ToldNightStagesThenReboots (0.00s) +hub: no reply_to in the payload -> TestReplyTo_HouseholdMailRepliesReachTheOperator: --- FAIL: TestReplyTo_HouseholdMailRepliesReachTheOperator (0.00s) +hub: R7 treated as ended for good -> TestKernel_ToldKernelGoneIsTemporaryAndTheNewerIsOffered: --- FAIL: TestKernel_ToldKernelGoneIsTemporaryAndTheNewerIsOffered (0.04s) +controller: no driveReady in the capture sweep -> TestDriveReady_RefreshWaitsForTheBindThenCaptures: --- FAIL: TestDriveReady_RefreshWaitsForTheBindThenCaptures (0.00s) diff --git a/documentation/audits/kernel-night-2026-10-07/demo-hp-inventory-pass.txt b/documentation/audits/kernel-night-2026-10-07/demo-hp-inventory-pass.txt new file mode 100644 index 00000000..7301cf57 --- /dev/null +++ b/documentation/audits/kernel-night-2026-10-07/demo-hp-inventory-pass.txt @@ -0,0 +1,25 @@ + "pve-firmware", + "proxmox-kernel-7.0.14-22-pve-signed", + "proxmox-kernel-7.0", + "proxmox-kernel-helper", + "pve-edk2-firmware-aarch64", + "pve-edk2-firmware", + "zfs-initramfs", + "zfsutils-linux", + "zfs-zed" + ], + "outcome": "inventory", + "pending": 80, + "reboot_needed": false, + "refused": null, + "release_id": "ring0-20261007T163720Z", + "restart_needed": [ + "kvm" + ], + "ring": 0, + "run_id": "20261007T163720Z", + "upgraded": null, + "wrapper_seconds": 14.4 + } + docker step: skipped (see the log line above) + pass took 30.8s diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index 0fb8cb81..7ccc3085 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,3 +1,17 @@ +## v0.143.1 — a household's reply reaches the operator; a told kernel that is gone is retried (`09` §3 decisions 174, 176; R-898) (2026-10-07) + +**Operator action on deploy: none.** Deployed with the operator attending (decision 162). + +- **Reply-To** (decision 174): every mail to a HOUSEHOLD carries `reply_to` = the operator's address + (`operator_email`, `admin@felhom.eu`). They are sent from `monitoring@felhom.eu`, which nobody reads, and they invite + a reply or contact: the kernel notice ("reply to this e-mail"), the event mails' sign-off ("contact your operator"), + the setup and link mails. A mail TO the operator carries none. `notify.replyToFor`; test + `TestReplyTo_HouseholdMailRepliesReachTheOperator` (red-proved). +- **R-898 (hub half):** a kernel step refused because the told version can no longer be installed (R7) is temporary, + never "ended": the box stays due, and once its report offers a newer kernel the household is told again (inside the + 3-mail limit). Pinned by `TestKernel_ToldKernelGoneIsTemporaryAndTheNewerIsOffered` (red-proved: R7 as "for good" + fails it). No code change was needed — the test pins the behaviour agent v0.153.0 relies on. + ## v0.143.0 — the kernel lane: the day-before household mail, the night instruction, the operator's kernel set (R-836; `09` §3 decision 172; `11` §5.11) (2026-10-07) **Operator action on deploy: none.** Built and deployed only in a session the operator attends (decision 162). Needs diff --git a/hub/internal/notify/dispatcher.go b/hub/internal/notify/dispatcher.go index 5f94e864..bd876156 100644 --- a/hub/internal/notify/dispatcher.go +++ b/hub/internal/notify/dispatcher.go @@ -895,6 +895,9 @@ func (d *Dispatcher) sendEmail(to, subject, textBody string, headers map[string] if len(headers) > 0 { payload["headers"] = headers } + if rt := d.replyToFor(to); rt != "" { + payload["reply_to"] = rt + } jsonData, err := json.Marshal(payload) if err != nil { @@ -922,6 +925,18 @@ func (d *Dispatcher) sendEmail(to, subject, textBody string, headers map[string] return nil } +// replyToFor is the Reply-To of a mail (`09` §3 decision 174): every HOUSEHOLD mail invites a reply or contact — the +// kernel notice ("reply to this e-mail"), the event sign-off ("contact your operator"), the setup and link mails — and +// it is sent from monitoring@, which no person reads. A reply goes to the operator's address. A mail TO the operator +// gets none (a reply to it is the operator's own). "" when no operator address is configured. Pinned by TestReplyTo_*. +func (d *Dispatcher) replyToFor(to string) string { + op := strings.TrimSpace(d.operatorEmail) + if op == "" || strings.EqualFold(strings.TrimSpace(to), op) { + return "" + } + return op +} + func isEventEnabled(enabledEvents []string, eventType string) bool { for _, e := range enabledEvents { if e == eventType { diff --git a/hub/internal/notify/reply_to_test.go b/hub/internal/notify/reply_to_test.go new file mode 100644 index 00000000..59e80751 --- /dev/null +++ b/hub/internal/notify/reply_to_test.go @@ -0,0 +1,45 @@ +package notify + +import ( + "encoding/json" + "io" + "log" + "net/http" + "os" + "testing" +) + +type captureRT struct{ bodies []map[string]any } + +func (c *captureRT) RoundTrip(r *http.Request) (*http.Response, error) { + b, _ := io.ReadAll(r.Body) + var m map[string]any + _ = json.Unmarshal(b, &m) + c.bodies = append(c.bodies, m) + return &http.Response{StatusCode: 200, Body: io.NopCloser(nil), Header: http.Header{}}, nil +} + +// `09` §3 decision 174: a household mail carries Reply-To = the operator's address (it is sent from monitoring@, and the +// kernel notice says "reply to this e-mail"); a mail to the operator carries none. +// COMPANION RED-PROOF (observed): drop the reply_to line in sendEmail → "household mail has no reply_to". +func TestReplyTo_HouseholdMailRepliesReachTheOperator(t *testing.T) { + d := NewDispatcher(nil, "k", "monitoring@felhom.eu", "admin@felhom.eu", true, log.New(os.Stderr, "", 0)) + c := &captureRT{} + d.httpClient = &http.Client{Transport: c} + if err := d.sendEmail("household@example.com", "s", "b", nil); err != nil { + t.Fatal(err) + } + if err := d.sendEmail("admin@felhom.eu", "s", "b", nil); err != nil { + t.Fatal(err) + } + if got := c.bodies[0]["reply_to"]; got != "admin@felhom.eu" { + t.Fatalf("household mail has no reply_to to the operator: %v", c.bodies[0]) + } + if _, ok := c.bodies[1]["reply_to"]; ok { + t.Fatalf("a mail to the operator must carry no reply_to: %v", c.bodies[1]) + } + none := NewDispatcher(nil, "k", "monitoring@felhom.eu", "", true, log.New(os.Stderr, "", 0)) + if none.replyToFor("x@y") != "" { + t.Fatal("no operator address → no reply_to") + } +} diff --git a/hub/internal/osupdates/kernel_test.go b/hub/internal/osupdates/kernel_test.go index f58849eb..2d2d2912 100644 --- a/hub/internal/osupdates/kernel_test.go +++ b/hub/internal/osupdates/kernel_test.go @@ -282,3 +282,30 @@ func countOf(xs []string, x string) int { } return n } + +// R-898: a told kernel that can no longer be installed is refused by the box BEFORE any change (R7) — temporary, never +// "ended for good": the box stays due, and once its report offers the newer kernel the household is told again (inside +// the 3-mail limit). COMPANION RED-PROOF (observed): add "R7" to kernelRefusedForGood → "an R7 refusal ended the step". +func TestKernel_ToldKernelGoneIsTemporaryAndTheNewerIsOffered(t *testing.T) { + f := newFix(t) + f.hostWithPendingKernel(t, "hp") // pending: 7.0.14-22 + f.laneFacts(t, "hp", "c-hp", kOld, "none", "") + r := kreport("refused", "night", false) + r.Refused = json.RawMessage(`{"code":"R7","reason":"the simulation failed: E: Version '7.0.14-22' for 'proxmox-kernel-7.0' was not found"}`) + f.ingest(t, "hp", r) + if k, why := f.s.KernelDue("hp"); k != kNew { + t.Fatalf("an R7 refusal ended the step: %q %q", k, why) + } + // the next night's report offers a newer kernel → that one is due, and its household mail is a new one + f.ingest(t, "hp", Report{Layer: LayerHost, Trigger: "night", Mode: "apply", Outcome: "nothing", Healthy: true, + Pending: []PendingPkg{{Name: "proxmox-kernel-7.0", From: "7.0.2-6", To: "7.0.14-23", Origin: []string{"Proxmox Debian Repository"}}}}) + if k, _ := f.s.KernelDue("hp"); k != "7.0.14-23-pve" { + t.Fatalf("the newer kernel is not offered: %q", k) + } + var sent []string + f.mail(&sent, nil) + f.now = budapest(t, 10) + if f.s.KernelNotify(); len(sent) != 1 || sent[0] != "c-hp:7.0.14-23-pve" { + t.Fatalf("the household must be told again for the newer kernel: %v", sent) + } +}