Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b228b44f0e | |||
| 91b9405c81 | |||
| 4c69c25b48 | |||
| c9013bb47d | |||
| 2d1e5d0774 | |||
| f09c53efc1 | |||
| 92d647a6f6 |
+49
-2
@@ -1,4 +1,51 @@
|
|||||||
## Unreleased — part of v0.152.0: the kernel lane (R-836; `09` §3 decisions 164, 172; `11` §5.11) (2026-10-07)
|
## Unreleased (2026-10-08) — no OS leg after a household press; the after-boot kernel report carries the real ring (R-899); no „wrong code" when older packages were not checked (R-304) — ships with tomorrow's release
|
||||||
|
|
||||||
|
**Delivery: the agent binary only** — no root file changed.
|
||||||
|
|
||||||
|
- **R-899 (operator ruling 2026-10-08, option A):** `POST /backup?trigger=manual` (a „Mentés most" press, sent by the
|
||||||
|
controller from its next release) runs no OS leg after it: the leg belongs to the night, after the night's own copy.
|
||||||
|
Before, a press ran the leg at once (in the day; on 2026-10-07 08:51 demo-hp skipped it only by the 20-hour rule). A
|
||||||
|
request without the parameter (an older controller, the scheduled path) behaves as before.
|
||||||
|
`internal/localapi/server.go`; `TestAfterPrimaryBackup` gained two sub-cases (red-proved: ignoring `trigger`, the leg
|
||||||
|
ran once after a press).
|
||||||
|
- **R-304 — „wrong code" only when every earlier package was tried.** `POST /escrow/recover-offsite-password` answered
|
||||||
|
400 („the recovery code did not open the sealed bundle") whenever the current package and the TRIED earlier packages
|
||||||
|
refused the code — even when the hub withheld earlier packages (rows with no key material, rows over its serve cap), a
|
||||||
|
served package was malformed, the 6-attempt cap stopped the loop, or the retained list could not be read at all. Now
|
||||||
|
those cases answer **424** with `older_unchecked` (the count, -1 = unknown) and a sentence that does not call the code
|
||||||
|
wrong. `escrow.ErrRetainedUnchecked` / `RetainedUncheckedError`; the retained fetcher's second value is now every
|
||||||
|
withheld package (unopenable + truncated + malformed). Tests `TestR304_*` (real age crypto; red-proved: without the
|
||||||
|
check, four cases returned the wrong-code error) and a 424 row in `TestRecoverOffsitePassword_EachSituationGetsItsOwnStatus`.
|
||||||
|
An older controller maps the unknown 424 to its neutral „we do not know why" sentence.
|
||||||
|
- **The „ring 1" label after a boot:** in the first second after a reboot the agent has not fetched the hub's block, and
|
||||||
|
the after-boot kernel reports (`judging`, `good`, `revert`, `fell_back`…) said ring 1 on a ring-0 box (demo-felhom,
|
||||||
|
2026-10-08 night). Now they read the fetched block, else the block the daemon saved on disk before the reboot (R-866),
|
||||||
|
else ring 1 as before. A label only — the hub's approval reads its own ring list. `kernelReportRing`,
|
||||||
|
`TestKernelReportRing_BeforeFirstFetch` (red-proved: ring 1, want 0).
|
||||||
|
|
||||||
|
## v0.153.0 — ring 0 stages exactly the told kernel (R-898; `09` §3 decision 176) (2026-10-07)
|
||||||
|
|
||||||
|
Released by `scripts/release-agent.sh`: binary sha256 `b204ebe6d65944f43b70dcfa4ae0dfb90da38c92e2ba6a38a1826e488eeb6630`, bundle
|
||||||
|
`6db216275eb0dd39188d93481a2045998a69e8cb7838ad908a58d65d9a6a9b57` (tag `v0.153.0` = `2d1e5d0`). Delivered (binary only — the
|
||||||
|
bundle files are unchanged from 0.152.0) to demo-hp, demo-felhom, Tester 1 on 2026-10-07 19:03.
|
||||||
|
|
||||||
|
**Delivery: the agent binary only** — no root file changed (the wrapper is unchanged; its tests gained two cases).
|
||||||
|
|
||||||
|
- `internal/osupdate/kernel.go`: ring 0's night kernel step stages EXACTLY the kernel the household was told about
|
||||||
|
(select `listed`, `KernelSet(kver)` = the series meta-package and the signed image at the kernel's own version) instead
|
||||||
|
of "whatever is pending tonight". Seen 2026-10-07: demo-felhom was told about 7.0.14-20 while its sources offered
|
||||||
|
7.0.14-22 by night — the old code staged `pending-kernel` and the wrapper refused it (R23), losing the night. A told
|
||||||
|
version that is no longer installable is refused by the wrapper before any change (R7) and the hub tells the household
|
||||||
|
again for the newer kernel (hub v0.143.1). Tests `TestKernel_Ring0ToldNightStagesThenReboots` (red-proved against the
|
||||||
|
old select), `TestKernelSet`; wrapper `test_ring0_listed_installs_the_told_kernel_not_the_newest`,
|
||||||
|
`test_ring0_told_kernel_gone_is_refused_before_any_change`.
|
||||||
|
|
||||||
|
## v0.152.0 — the kernel lane (R-836; `09` §3 decisions 164, 172; `11` §5.11) (2026-10-07)
|
||||||
|
|
||||||
|
Released by `scripts/release-agent.sh`: binary sha256 `95ff42208e36ba49b6e2b09a97e81a6fa11562ecc8042f1ed18d378b8b1f88b1`,
|
||||||
|
config bundle sha256 `f0c2cec374b711b3c131c955012b33fd0ad495337d049b7a743c3eef9e85c20b` (tag `v0.152.0` = `d03ab7f`).
|
||||||
|
Step bundle `0.152.0-step1` sha256 `0b71d32b054cf3b7ade0234ffcbb0df159901f542cde540adaee411db466f48e` (the 0.151.0 bundle
|
||||||
|
with only `felhom-os-apply` replaced; `scripts/build-step-bundle.py`), published as package version `0.152.0-step1`.
|
||||||
|
|
||||||
**Delivery: agent binary, then the STEP bundle `0.152.0-step1`, then the bundle `0.152.0`** — the bundle ADDS two paths
|
**Delivery: agent binary, then the STEP bundle `0.152.0-step1`, then the bundle `0.152.0`** — the bundle ADDS two paths
|
||||||
(the GRUB generators), and an installed `felhom-os-apply` refuses a path its own table lacks (R16, R-880).
|
(the GRUB generators), and an installed `felhom-os-apply` refuses a path its own table lacks (R16, R-880).
|
||||||
@@ -34,7 +81,7 @@ candidate 2 (`audits/kernel-spike-2026-10-07/`), with option C on the one-shot e
|
|||||||
signed `os_kernel_step` staged (`KernelStepExecutor`: stage only, under the heavy-op gate). The hub hears `staged`
|
signed `os_kernel_step` staged (`KernelStepExecutor`: stage only, under the heavy-op gate). The hub hears `staged`
|
||||||
BEFORE the reboot. At every start `KernelAfterBoot`: on the new kernel it JUDGES the boot — `KernelVerdict` = the
|
BEFORE the reboot. At every start `KernelAfterBoot`: on the new kernel it JUDGES the boot — `KernelVerdict` = the
|
||||||
host health rule (`11` §8.2) AND the box reached the hub (the `judging` report itself) — for 20 minutes (measured:
|
host health rule (`11` §8.2) AND the box reached the hub (the `judging` report itself) — for 20 minutes (measured:
|
||||||
everything healthy 68 s after the reboot on demo-felhom, 272 s on demo-hp; under the hub's 30-minute `host_stale`).
|
everything healthy 68 s after the reboot on demo-felhom, 272 s on demo-hp; under the hub's 45-minute `host_stale` (`alerting.stale_threshold`)).
|
||||||
Healthy → `kernel-good`, outcome `applied`; not healthy → outcome `health_failed`, then ONE `kernel-revert`.
|
Healthy → `kernel-good`, outcome `applied`; not healthy → outcome `health_failed`, then ONE `kernel-revert`.
|
||||||
Tests: `TestKernel*` (13); red-proofs in the same file.
|
Tests: `TestKernel*` (13); red-proofs in the same file.
|
||||||
- `internal/hub`: `WireOSUpdate.Kernel` {kver, tonight, notified_at}. `internal/reconcile`: `os_kernel_step` is
|
- `internal/hub`: `WireOSUpdate.Kernel` {kver, tonight, notified_at}. `internal/reconcile`: `os_kernel_step` is
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# CONTEXT — felhom-agent working state
|
# CONTEXT — felhom-agent working state
|
||||||
|
|
||||||
|
> **2026-10-08 (day) — UNRELEASED on main, ships with tomorrow's release (decision 178).** R-899: `POST /backup?trigger=manual`
|
||||||
|
> runs no OS leg (`localapi/server.go`); after-boot kernel reports carry the saved block's ring (`kernelReportRing`). R-304:
|
||||||
|
> `escrow.ErrRetainedUnchecked` → HTTP 424 `older_unchecked` when not every earlier package was tried (withheld, caps,
|
||||||
|
> malformed, unreadable list). Binary-only delivery; no wrapper or root file changed.
|
||||||
|
|
||||||
|
> **2026-10-07 (evening) — v0.152.0, the kernel lane (R-836, decision 172, `11` §5.11).** Wrapper layer `kernel` + two GRUB generators in the bundle (delivered with step bundle `0.152.0-step1` — the bundle adds paths, R-880); `osupdate/kernel.go`: night step on told nights only, after-boot judge (host rule + hub reached, 20 min), ONE self-revert, `os_kernel_step` stages only. Proven on Tester 1 (panic → fell_back, held guest → self_reverted, healthy → 7.0.14-22 default). Open: R-898, R-897; the ring-0 night run.
|
||||||
|
|
||||||
> **2026-10-04 night — v0.143.0 RELEASED + vouched (R-840, decision 96): the config bundle.** `felhom-os-apply` mode
|
> **2026-10-04 night — v0.143.0 RELEASED + vouched (R-840, decision 96): the config bundle.** `felhom-os-apply` mode
|
||||||
> `bundle` (signed `agent_config_update`, verified by the wrapper itself; trust files never bundle paths) +
|
> `bundle` (signed `agent_config_update`, verified by the wrapper itself; trust files never bundle paths) +
|
||||||
> `--install-bundle` (installer 1.31.0); `BUNDLE_FILES` is the one table; `scripts/build-config-bundle.py`;
|
> `--install-bundle` (installer 1.31.0); `BUNDLE_FILES` is the one table; `scripts/build-config-bundle.py`;
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
# REPORT — v0.151.0 released and delivered (2026-10-07 day)
|
# REPORT — agent, 2026-10-08 (day): R-899 and R-304 on main, unreleased
|
||||||
|
|
||||||
On the operator's word (`09` §3 decisions 163, 165, 168, 169). sha `0464354f…`, bundle `bacd1d17…`, tag `v0.151.0` = `dd7cdc0`.
|
**No release, no delivery today** (kernel night 8→9; `09` §3 decision 178). Binary-only; ships with tomorrow's release.
|
||||||
Delivered binary first, then the bundle (its sudoers drops the `tee` grant 0.150.0 used), to demo-hp, demo-felhom and
|
|
||||||
Tester 1 — probe 68/68 on each. **Vouch refused by the hub** (`golden_behind_fleet`): new installs keep 0.150.0 until the
|
| Item | Commit | Tests | Red-proof |
|
||||||
weekly golden. Carries: the Proxmox package lane + `/etc/pve` write gate (R-812 A — proven on demo-felhom: 65 packages,
|
|---|---|---|---|
|
||||||
70 s, healthy), the `controller-image` root verb (R-861 a — on demo-hp an `alpine` ref is refused; a managed swap not yet
|
| R-899 — `trigger=manual` (a press) runs no OS leg | `4c69c25` | `TestAfterPrimaryBackup` + 2 sub-cases | ignoring `trigger` → the leg ran once after a press (`[8200]`) |
|
||||||
seen), anchored felhom-op lines (B2), the other-key archive ledger (R-366), the `-directive` flag removed (R-105). Evidence
|
| „ring 1" label in after-boot kernel reports (seen 2026-10-08 night, demo-felhom) | `4c69c25` | `TestKernelReportRing_BeforeFirstFetch` | `Block().Ring` → `ring 1, want 0` |
|
||||||
`felhom.eu/documentation/audits/day-2026-10-07/`. Shared rule file: decision 162 line added.
|
| R-304 — 424 `older_unchecked` instead of „did not open the sealed bundle" when earlier packages were not all tried | `91b9405` | `TestR304_*` (real age crypto), 424 row in `TestRecoverOffsitePassword_EachSituationGetsItsOwnStatus` | check off → four cases returned the wrong-code error |
|
||||||
|
|
||||||
|
**Read before the change (read-only, demo-hp):** on 2026-10-07 08:51 the morning press DID reach the OS leg
|
||||||
|
(`osupdate: skipped — already ran tonight`, saved by the 20-hour rule only); `os-update-block.json` on demo-hp holds
|
||||||
|
`ring 0` (the fallback the label fix reads).
|
||||||
|
|
||||||
|
**Gates:** `go build/vet/test ./...` rc 0; `agent_gates.py --fast` rc 0. **CI:** job 1529 (`4c69c25`) success; job 1530
|
||||||
|
(`91b9405`) success.
|
||||||
|
|||||||
@@ -881,7 +881,8 @@ func runDaemon(cfg config.Config, logger *slog.Logger, logRing *applog.Ring) int
|
|||||||
// judge it (the host health rule + the hub reached) for KernelJudgeWait, then make it the default or revert ONCE.
|
// judge it (the host health rule + the hub reached) for KernelJudgeWait, then make it the default or revert ONCE.
|
||||||
// The wait: measured 2026-10-07 (`audits/kernel-lane-2026-10-07/B/`) — every container healthy 68 s after the
|
// The wait: measured 2026-10-07 (`audits/kernel-lane-2026-10-07/B/`) — every container healthy 68 s after the
|
||||||
// reboot on demo-felhom and 272 s on demo-hp (the hub reached at 63 s / 189 s); 20 minutes leaves room for a slow
|
// reboot on demo-felhom and 272 s on demo-hp (the hub reached at 63 s / 189 s); 20 minutes leaves room for a slow
|
||||||
// network and stays under the hub's 30-minute host_stale. On a box without the kernel lane (an older wrapper, a BYO host) the check is refused and logged.
|
// network and stays under the hub's 45-minute host_stale (its
|
||||||
|
// alerting.stale_threshold; host_down at 90). On a box without the kernel lane (an older wrapper, a BYO host) the check is refused and logged.
|
||||||
go osLeg.KernelAfterBoot(ctx, 0, osupdate.KernelJudge{Wait: osupdate.DefaultKernelJudgeWait})
|
go osLeg.KernelAfterBoot(ctx, 0, osupdate.KernelJudge{Wait: osupdate.DefaultKernelJudgeWait})
|
||||||
|
|
||||||
// Reconcile (slice 4) runs alongside the hub loop, sharing the per-guest queue
|
// Reconcile (slice 4) runs alongside the hub loop, sharing the per-guest queue
|
||||||
@@ -1903,11 +1904,15 @@ func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.St
|
|||||||
return nil, 0, ferr
|
return nil, 0, ferr
|
||||||
}
|
}
|
||||||
out := make([]escrow.RetainedBlob, 0, len(resp.Packages))
|
out := make([]escrow.RetainedBlob, 0, len(resp.Packages))
|
||||||
|
// R-304: every package the hub holds and the code will NOT be tried against — no key material,
|
||||||
|
// over the hub's cap, or malformed here. A refusal may call the code wrong only when this is 0.
|
||||||
|
withheld := resp.UnopenableCount + resp.TruncatedCount
|
||||||
for _, p := range resp.Packages {
|
for _, p := range resp.Packages {
|
||||||
blob, derr := base64.StdEncoding.DecodeString(p.IdentityEscrowB64)
|
blob, derr := base64.StdEncoding.DecodeString(p.IdentityEscrowB64)
|
||||||
if derr != nil || len(blob) == 0 {
|
if derr != nil || len(blob) == 0 {
|
||||||
// One malformed package must not sink the rest — the customer's code may open a
|
// One malformed package must not sink the rest — the customer's code may open a
|
||||||
// later one, and a skipped entry is strictly better than a refusal we cannot justify.
|
// later one, and a skipped entry is strictly better than a refusal we cannot justify.
|
||||||
|
withheld++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, escrow.RetainedBlob{
|
out = append(out, escrow.RetainedBlob{
|
||||||
@@ -1917,7 +1922,7 @@ func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.St
|
|||||||
Index: p.Index,
|
Index: p.Index,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out, resp.UnopenableCount, nil
|
return out, withheld, nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
srv, err := localapi.NewServer(localapi.Options{
|
srv, err := localapi.NewServer(localapi.Options{
|
||||||
|
|||||||
@@ -1838,6 +1838,33 @@ class KernelLane(unittest.TestCase):
|
|||||||
m.origins = {"proxmox-kernel-7.0": DEB}
|
m.origins = {"proxmox-kernel-7.0": DEB}
|
||||||
self.refused(m, "R2")
|
self.refused(m, "R2")
|
||||||
|
|
||||||
|
# R-898: ring 0 stages EXACTLY the told kernel (select listed, the set derived from it) — even when the sources
|
||||||
|
# offer a newer one by night; a told version that can no longer be installed is refused BEFORE any change (R7).
|
||||||
|
def test_ring0_listed_installs_the_told_kernel_not_the_newest(self):
|
||||||
|
f = kfake()
|
||||||
|
f.live["proxmox-kernel-7.0"] = {"7.0.14-20", "7.0.14-22", "7.0.2-6"}
|
||||||
|
f.live["proxmox-kernel-7.0.14-20-pve-signed"] = {"7.0.14-20"}
|
||||||
|
told = [{"name": "proxmox-kernel-7.0", "version": "7.0.14-20", "origin": "Proxmox Debian Repository"},
|
||||||
|
{"name": "proxmox-kernel-7.0.14-20-pve-signed", "version": "7.0.14-20", "origin": "Proxmox Debian Repository"}]
|
||||||
|
f.plan.update(select="listed", packages=told, expect_kver="7.0.14-20-pve")
|
||||||
|
rc, rep = run(f)
|
||||||
|
self.assertEqual(rc, 0, rep)
|
||||||
|
self.assertEqual(rep["authority"], "ring0")
|
||||||
|
self.assertEqual(f.env, {"felhom_next": "7.0.14-20-pve"})
|
||||||
|
self.assertEqual(f.installed["proxmox-kernel-7.0"], "7.0.14-20")
|
||||||
|
self.assertNotIn("7.0.14-22-pve", f.boot)
|
||||||
|
|
||||||
|
def test_ring0_told_kernel_gone_is_refused_before_any_change(self):
|
||||||
|
f = kfake()
|
||||||
|
f.live["proxmox-kernel-7.0"] = {"7.0.14-22"} # 7.0.14-20 is no longer in the archive
|
||||||
|
told = [{"name": "proxmox-kernel-7.0", "version": "7.0.14-20", "origin": "Proxmox Debian Repository"},
|
||||||
|
{"name": "proxmox-kernel-7.0.14-20-pve-signed", "version": "7.0.14-20", "origin": "Proxmox Debian Repository"}]
|
||||||
|
f.plan.update(select="listed", packages=told, expect_kver="7.0.14-20-pve")
|
||||||
|
rep = self.refused(f, "R7")
|
||||||
|
self.assertIsNone(f.env)
|
||||||
|
self.assertNotIn(osapply.KERNEL_DEFAULT_CFG, f.tree, "refused before the default was even pinned")
|
||||||
|
self.assertEqual(f.installed["proxmox-kernel-7.0"], "7.0.2-6")
|
||||||
|
|
||||||
def test_nothing_pending_changes_nothing(self):
|
def test_nothing_pending_changes_nothing(self):
|
||||||
f = kfake(kernel_pending=[])
|
f = kfake(kernel_pending=[])
|
||||||
rc, rep = run(f)
|
rc, rep = run(f)
|
||||||
|
|||||||
+50
-10
@@ -59,8 +59,26 @@ var (
|
|||||||
// It carries no material and no code: only WHICH earlier package opened, by its supersession date,
|
// It carries no material and no code: only WHICH earlier package opened, by its supersession date,
|
||||||
// which is the one fact the customer needs to recognise it.
|
// which is the one fact the customer needs to recognise it.
|
||||||
ErrCodeOpensRetained = errors.New("escrow: the recovery code did not open the CURRENT sealed package, but it DID open a retained earlier one")
|
ErrCodeOpensRetained = errors.New("escrow: the recovery code did not open the CURRENT sealed package, but it DID open a retained earlier one")
|
||||||
|
// ErrRetainedUnchecked — the code did NOT open the current package, and NOT every earlier package the hub
|
||||||
|
// holds for this host was tried (R-304, 2026-10-08): the retained list could not be fetched, the hub
|
||||||
|
// withheld rows (over its serve cap, or rows with no key material), a package was malformed, or the
|
||||||
|
// attempt cap stopped the loop. So „the code is wrong" is NOT known — it may be right for a package
|
||||||
|
// nobody tried. Distinct from a mistype for exactly the R-224 reason: never accuse the customer of
|
||||||
|
// something we did not check.
|
||||||
|
ErrRetainedUnchecked = errors.New("escrow: the recovery code did not open the current sealed package, and some earlier packages were NOT checked")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// RetainedUncheckedError wraps ErrRetainedUnchecked with how many earlier packages went unchecked
|
||||||
|
// (-1 = unknown: the retained list itself could not be read). No secret.
|
||||||
|
type RetainedUncheckedError struct {
|
||||||
|
Unchecked int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *RetainedUncheckedError) Error() string {
|
||||||
|
return fmt.Sprintf("%s (unchecked=%d)", ErrRetainedUnchecked.Error(), e.Unchecked)
|
||||||
|
}
|
||||||
|
func (e *RetainedUncheckedError) Unwrap() error { return ErrRetainedUnchecked }
|
||||||
|
|
||||||
// RetainedMatch says which retained package a code opened. Returned inside RetainedOpenedError; it
|
// RetainedMatch says which retained package a code opened. Returned inside RetainedOpenedError; it
|
||||||
// carries no secret — not the code, not the bundle, not the repository password.
|
// carries no secret — not the code, not the bundle, not the repository password.
|
||||||
type RetainedMatch struct {
|
type RetainedMatch struct {
|
||||||
@@ -102,8 +120,10 @@ type RetainedBlob struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RetainedFetcher yields this host's RETAINED sealed packages, newest-superseded first. An empty
|
// RetainedFetcher yields this host's RETAINED sealed packages, newest-superseded first. An empty
|
||||||
// slice is a clean "none". R-311.
|
// slice is a clean "none". R-311. `withheld` counts the earlier packages the hub holds that are NOT in
|
||||||
type RetainedFetcher func(ctx context.Context) (blobs []RetainedBlob, unopenable int, err error)
|
// blobs — rows with no key material, rows over the hub's serve cap, and packages dropped as malformed
|
||||||
|
// (R-304): each is a package the code was never tried against.
|
||||||
|
type RetainedFetcher func(ctx context.Context) (blobs []RetainedBlob, withheld int, err error)
|
||||||
|
|
||||||
// OffsiteKeyRecoverer is the assembled links 6→8. Construct it with a fetcher; call it with R.
|
// OffsiteKeyRecoverer is the assembled links 6→8. Construct it with a fetcher; call it with R.
|
||||||
type OffsiteKeyRecoverer struct {
|
type OffsiteKeyRecoverer struct {
|
||||||
@@ -154,9 +174,14 @@ func (r OffsiteKeyRecoverer) RecoverOffsiteRepoPassword(ctx context.Context, rec
|
|||||||
// from the unwrap alone; the only way to tell is to try. Until this existed nobody tried, and
|
// from the unwrap alone; the only way to tell is to try. Until this existed nobody tried, and
|
||||||
// the screen said so out loud ("innen nem tudjuk megkülönböztetni őket") — a true sentence
|
// the screen said so out loud ("innen nem tudjuk megkülönböztetni őket") — a true sentence
|
||||||
// about our own incuriosity, read by the customer as a statement about their code.
|
// about our own incuriosity, read by the customer as a statement about their code.
|
||||||
if m, ok := r.tryRetained(ctx, recoveryCode); ok {
|
m, ok, unchecked := r.tryRetained(ctx, recoveryCode)
|
||||||
|
if ok {
|
||||||
return "", &RetainedOpenedError{Match: m}
|
return "", &RetainedOpenedError{Match: m}
|
||||||
}
|
}
|
||||||
|
// R-304: only when EVERY earlier package the hub holds was tried may this stay a wrong code.
|
||||||
|
if unchecked != 0 {
|
||||||
|
return "", &RetainedUncheckedError{Unchecked: unchecked}
|
||||||
|
}
|
||||||
return "", err // the fail-closed "the recovery code did not unwrap…" message; no secret in it
|
return "", err // the fail-closed "the recovery code did not unwrap…" message; no secret in it
|
||||||
}
|
}
|
||||||
if bundle.ResticRepoPassword == "" {
|
if bundle.ResticRepoPassword == "" {
|
||||||
@@ -173,23 +198,38 @@ func (r OffsiteKeyRecoverer) RecoverOffsiteRepoPassword(ctx context.Context, rec
|
|||||||
// function breaking is the behaviour we had before it existed.
|
// function breaking is the behaviour we had before it existed.
|
||||||
//
|
//
|
||||||
// NOTHING IS LOGGED HERE and no return value carries the code, a bundle or a password.
|
// NOTHING IS LOGGED HERE and no return value carries the code, a bundle or a password.
|
||||||
func (r OffsiteKeyRecoverer) tryRetained(ctx context.Context, recoveryCode string) (RetainedMatch, bool) {
|
//
|
||||||
|
// R-304 (2026-10-08): it also returns how many earlier packages were NOT tried — `withheld` from the hub, plus
|
||||||
|
// the ones past the attempt cap — or -1 when the retained list could not be read at all. A nil FetchRetained
|
||||||
|
// (an agent wired without the lookup) reports 0: the pre-R-311 refusal, unchanged.
|
||||||
|
func (r OffsiteKeyRecoverer) tryRetained(ctx context.Context, recoveryCode string) (RetainedMatch, bool, int) {
|
||||||
if r.FetchRetained == nil {
|
if r.FetchRetained == nil {
|
||||||
return RetainedMatch{}, false
|
return RetainedMatch{}, false, 0
|
||||||
}
|
}
|
||||||
blobs, _, err := r.FetchRetained(ctx)
|
blobs, withheld, err := r.FetchRetained(ctx)
|
||||||
if err != nil || len(blobs) == 0 {
|
if err != nil {
|
||||||
return RetainedMatch{}, false
|
return RetainedMatch{}, false, -1
|
||||||
|
}
|
||||||
|
if withheld < 0 {
|
||||||
|
withheld = 0
|
||||||
|
}
|
||||||
|
if len(blobs) == 0 {
|
||||||
|
return RetainedMatch{}, false, withheld
|
||||||
}
|
}
|
||||||
limit := r.MaxRetainedTried
|
limit := r.MaxRetainedTried
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = defaultMaxRetainedTried
|
limit = defaultMaxRetainedTried
|
||||||
}
|
}
|
||||||
|
unchecked := withheld
|
||||||
|
if len(blobs) > limit {
|
||||||
|
unchecked += len(blobs) - limit
|
||||||
|
}
|
||||||
for i, rb := range blobs {
|
for i, rb := range blobs {
|
||||||
if i >= limit {
|
if i >= limit {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if len(rb.Blob) == 0 {
|
if len(rb.Blob) == 0 {
|
||||||
|
unchecked++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
bundle, uerr := UnwrapIdentityBundle(ctx, rb.Blob, recoveryCode)
|
bundle, uerr := UnwrapIdentityBundle(ctx, rb.Blob, recoveryCode)
|
||||||
@@ -204,7 +244,7 @@ func (r OffsiteKeyRecoverer) tryRetained(ctx context.Context, recoveryCode strin
|
|||||||
// correct and must be told so — but the history behind it still cannot be reopened, and
|
// correct and must be told so — but the history behind it still cannot be reopened, and
|
||||||
// saying otherwise would be a promise this path cannot keep.
|
// saying otherwise would be a promise this path cannot keep.
|
||||||
HasResticPassword: bundle.ResticRepoPassword != "",
|
HasResticPassword: bundle.ResticRepoPassword != "",
|
||||||
}, true
|
}, true, 0
|
||||||
}
|
}
|
||||||
return RetainedMatch{}, false
|
return RetainedMatch{}, false, unchecked
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,8 +115,9 @@ func TestRecover_WrongCode_StaysAPlainRefusal(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// FAIL-SAFE — if the retained lookup itself fails, the original refusal must stand UNCHANGED. The
|
// FAIL-SAFE — if the retained lookup itself fails, the lookup's error never reaches the customer and no
|
||||||
// worst outcome of this feature breaking is the behaviour we had before it.
|
// retained package is claimed. R-304 (2026-10-08) changed what stands instead: not the wrong-code refusal (the
|
||||||
|
// earlier packages were never tried, so „wrong" is not known) but ErrRetainedUnchecked with Unchecked = -1.
|
||||||
//
|
//
|
||||||
// RED-PROOF: make tryRetained propagate the fetch error instead of returning false → the customer
|
// RED-PROOF: make tryRetained propagate the fetch error instead of returning false → the customer
|
||||||
// gets a new, unexplained failure mode → this FAILS.
|
// gets a new, unexplained failure mode → this FAILS.
|
||||||
@@ -140,6 +141,10 @@ func TestRecover_RetainedFetchFails_OriginalRefusalStands(t *testing.T) {
|
|||||||
if containsStr(err.Error(), "hub exploded") {
|
if containsStr(err.Error(), "hub exploded") {
|
||||||
t.Error("the retained-lookup failure leaked into the customer-facing refusal — it must be silent")
|
t.Error("the retained-lookup failure leaked into the customer-facing refusal — it must be silent")
|
||||||
}
|
}
|
||||||
|
var ue *RetainedUncheckedError
|
||||||
|
if !errors.As(err, &ue) || ue.Unchecked != -1 {
|
||||||
|
t.Errorf("err = %v, want RetainedUncheckedError{-1} — the earlier packages were never tried (R-304)", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A nil FetchRetained keeps the pre-R-311 behaviour EXACTLY. An agent wired without it must be
|
// A nil FetchRetained keeps the pre-R-311 behaviour EXACTLY. An agent wired without it must be
|
||||||
@@ -228,3 +233,72 @@ func containsStr(hay, needle string) bool {
|
|||||||
return false
|
return false
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── R-304 (2026-10-08) — „wrong code" only when every earlier package was tried ──────────────────────
|
||||||
|
//
|
||||||
|
// The consequence asserted: is the refusal a WRONG CODE (the only error the screen may answer with „check your
|
||||||
|
// typing")? It may be only when the hub withheld nothing and every served package was tried.
|
||||||
|
//
|
||||||
|
// RED-PROOF: make tryRetained return 0 for `unchecked` → the three „unchecked" cases return the plain refusal
|
||||||
|
// → they FAIL; the all-tried case keeps passing.
|
||||||
|
func TestR304_WrongCodeOnlyWhenEveryEarlierPackageWasTried(t *testing.T) {
|
||||||
|
ensureAge(t)
|
||||||
|
current := sealBundle(t, IdentityBundle{ResticRepoPassword: "4444567890abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}, testR)
|
||||||
|
other := sealBundle(t, IdentityBundle{ResticRepoPassword: "5555567890abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}, testR)
|
||||||
|
const code = "a code that opens nothing whatsoever in this test"
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
blobs int
|
||||||
|
withheld int
|
||||||
|
limit int
|
||||||
|
unchecked int // 0 = must be a plain wrong code
|
||||||
|
}{
|
||||||
|
{"all tried, nothing withheld", 2, 0, 6, 0},
|
||||||
|
{"the hub withheld rows (no key material / over its cap)", 1, 3, 6, 3},
|
||||||
|
{"only withheld rows, nothing served", 0, 2, 6, 2},
|
||||||
|
{"the attempt cap stopped the loop", 5, 0, 2, 3},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
blobs := make([]RetainedBlob, 0, c.blobs)
|
||||||
|
for i := 0; i < c.blobs; i++ {
|
||||||
|
blobs = append(blobs, RetainedBlob{Blob: other, SupersededAt: "2026-08-01 00:00:00", Index: i})
|
||||||
|
}
|
||||||
|
_, err := OffsiteKeyRecoverer{
|
||||||
|
Fetch: fetcherFor(current),
|
||||||
|
FetchRetained: func(context.Context) ([]RetainedBlob, int, error) {
|
||||||
|
return blobs, c.withheld, nil
|
||||||
|
},
|
||||||
|
MaxRetainedTried: c.limit,
|
||||||
|
}.RecoverOffsiteRepoPassword(context.Background(), code)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a code that opens nothing succeeded")
|
||||||
|
}
|
||||||
|
var ue *RetainedUncheckedError
|
||||||
|
isUnchecked := errors.As(err, &ue)
|
||||||
|
if c.unchecked == 0 {
|
||||||
|
if isUnchecked {
|
||||||
|
t.Fatalf("every earlier package was tried — this IS a wrong code, got %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isUnchecked || ue.Unchecked != c.unchecked || !errors.Is(err, ErrRetainedUnchecked) {
|
||||||
|
t.Fatalf("err = %v, want RetainedUncheckedError{%d} — never call the code wrong when packages were not tried", err, c.unchecked)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A malformed (empty) served package counts as not tried.
|
||||||
|
func TestR304_EmptyServedPackageCountsAsUnchecked(t *testing.T) {
|
||||||
|
ensureAge(t)
|
||||||
|
current := sealBundle(t, IdentityBundle{ResticRepoPassword: "6666567890abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}, testR)
|
||||||
|
_, err := OffsiteKeyRecoverer{
|
||||||
|
Fetch: fetcherFor(current),
|
||||||
|
FetchRetained: retainedFetcherFor(RetainedBlob{Blob: nil, SupersededAt: "2026-08-01 00:00:00"}),
|
||||||
|
}.RecoverOffsiteRepoPassword(context.Background(), testR2)
|
||||||
|
var ue *RetainedUncheckedError
|
||||||
|
if !errors.As(err, &ue) || ue.Unchecked != 1 {
|
||||||
|
t.Fatalf("err = %v, want RetainedUncheckedError{1}", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
// Red-proof: drop the `b.Success &&` guard and the failed-backup sub-case fails; move the call after release()
|
// Red-proof: drop the `b.Success &&` guard and the failed-backup sub-case fails; move the call after release()
|
||||||
// and the gate sub-case fails.
|
// and the gate sub-case fails.
|
||||||
func TestAfterPrimaryBackup(t *testing.T) {
|
func TestAfterPrimaryBackup(t *testing.T) {
|
||||||
run := func(t *testing.T, failErr string) (calls []int, gateHeld bool) {
|
run := func(t *testing.T, failErr, path string) (calls []int, gateHeld bool) {
|
||||||
gate := &backup.InFlight{}
|
gate := &backup.InFlight{}
|
||||||
b := &fakeBackups{failErr: failErr}
|
b := &fakeBackups{failErr: failErr}
|
||||||
srv := newTestServerS(t, &fakeGuests{}, b, &fakeStore{}, nil)
|
srv := newTestServerS(t, &fakeGuests{}, b, &fakeStore{}, nil)
|
||||||
@@ -34,7 +34,7 @@ func TestAfterPrimaryBackup(t *testing.T) {
|
|||||||
done <- struct{}{}
|
done <- struct{}{}
|
||||||
})
|
})
|
||||||
h := srv.Handler()
|
h := srv.Handler()
|
||||||
if do(t, h, "POST", "/backup", "A", "").Code != http.StatusAccepted {
|
if do(t, h, "POST", path, "A", "").Code != http.StatusAccepted {
|
||||||
t.Fatal("POST /backup not accepted")
|
t.Fatal("POST /backup not accepted")
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
@@ -47,7 +47,7 @@ func TestAfterPrimaryBackup(t *testing.T) {
|
|||||||
return calls, gateHeld
|
return calls, gateHeld
|
||||||
}
|
}
|
||||||
t.Run("success runs the leg under the gate", func(t *testing.T) {
|
t.Run("success runs the leg under the gate", func(t *testing.T) {
|
||||||
calls, held := run(t, "")
|
calls, held := run(t, "", "/backup")
|
||||||
if len(calls) != 1 {
|
if len(calls) != 1 {
|
||||||
t.Fatalf("the leg ran %d time(s), want 1", len(calls))
|
t.Fatalf("the leg ran %d time(s), want 1", len(calls))
|
||||||
}
|
}
|
||||||
@@ -56,8 +56,21 @@ func TestAfterPrimaryBackup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
t.Run("a failed backup runs nothing", func(t *testing.T) {
|
t.Run("a failed backup runs nothing", func(t *testing.T) {
|
||||||
if calls, _ := run(t, "vzdump exploded"); len(calls) != 0 {
|
if calls, _ := run(t, "vzdump exploded", "/backup"); len(calls) != 0 {
|
||||||
t.Fatalf("the leg ran after a FAILED backup: %v", calls)
|
t.Fatalf("the leg ran after a FAILED backup: %v", calls)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
// R-899: a household press is not the night's backup — no OS leg after it. Same fake, same successful backup as
|
||||||
|
// the first sub-case; only the query differs. Red-proof: make handleBackup ignore `trigger` and this sub-case
|
||||||
|
// fails with the leg run once.
|
||||||
|
t.Run("a manual press runs nothing", func(t *testing.T) {
|
||||||
|
if calls, _ := run(t, "", "/backup?trigger=manual"); len(calls) != 0 {
|
||||||
|
t.Fatalf("the OS leg ran after a manual press: %v", calls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("the scheduled path with the new query still runs the leg", func(t *testing.T) {
|
||||||
|
if calls, _ := run(t, "", "/backup?trigger=night"); len(calls) != 1 {
|
||||||
|
t.Fatalf("the leg ran %d time(s) after a non-manual backup, want 1", len(calls))
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,6 +127,22 @@ func (s *Server) handleRecoverOffsitePassword(w http.ResponseWriter, r *http.Req
|
|||||||
"retained_has_restic_pw": match.HasResticPassword,
|
"retained_has_restic_pw": match.HasResticPassword,
|
||||||
},
|
},
|
||||||
"the recovery code is correct, but it belongs to an EARLIER sealed package (superseded "+match.SupersededAt+"), not the one currently held")
|
"the recovery code is correct, but it belongs to an EARLIER sealed package (superseded "+match.SupersededAt+"), not the one currently held")
|
||||||
|
// ── R-304 (2026-10-08) — NOT EVERY EARLIER PACKAGE WAS CHECKED. ─────────────────────────
|
||||||
|
//
|
||||||
|
// The current package refused the code, no retained package opened it — and at least one earlier
|
||||||
|
// package the hub holds was never tried (or the list could not be read). Saying „the code is
|
||||||
|
// wrong" here would claim a check that did not happen. 424 (Failed Dependency): the verdict
|
||||||
|
// depends on packages we could not try. The controller classifies on the status, never on this
|
||||||
|
// sentence; an older controller maps an unknown status to its neutral „we do not know why".
|
||||||
|
case errors.Is(err, escrow.ErrRetainedUnchecked):
|
||||||
|
n := -1
|
||||||
|
var ue *escrow.RetainedUncheckedError
|
||||||
|
if errors.As(err, &ue) {
|
||||||
|
n = ue.Unchecked
|
||||||
|
}
|
||||||
|
s.logger.Warn("local-api: offsite key recovery: the code did not open the current package and earlier packages were NOT all checked — not reported as a wrong code (R-304)", "vmid", vmid, "unchecked", n)
|
||||||
|
writeStatus(w, http.StatusFailedDependency, false, map[string]any{"older_unchecked": n},
|
||||||
|
"the recovery code did not open the current sealed package, and earlier packages the hub holds were not all checked — the code may belong to one of them; nothing was written")
|
||||||
case errors.Is(err, escrow.ErrNoResticPassword):
|
case errors.Is(err, escrow.ErrNoResticPassword):
|
||||||
s.logger.Warn("local-api: offsite key recovery: the bundle opened but predates the repository-password field", "vmid", vmid)
|
s.logger.Warn("local-api: offsite key recovery: the bundle opened but predates the repository-password field", "vmid", vmid)
|
||||||
writeErr(w, http.StatusConflict, "the recovery code opened the bundle, but it carries NO offsite repository password (sealed before that field existed; it cannot be retro-fitted)")
|
writeErr(w, http.StatusConflict, "the recovery code opened the bundle, but it carries NO offsite repository password (sealed before that field existed; it cannot be retro-fitted)")
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ func TestRecoverOffsitePassword_EachSituationGetsItsOwnStatus(t *testing.T) {
|
|||||||
wantStatus: 404,
|
wantStatus: 404,
|
||||||
mustNotSay: []string{"did not open"},
|
mustNotSay: []string{"did not open"},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// R-304: earlier packages were not all tried — never „did not open the sealed bundle" (the wrong-code words).
|
||||||
|
name: "earlier packages not all checked — not a wrong code",
|
||||||
|
err: &escrow.RetainedUncheckedError{Unchecked: 2},
|
||||||
|
wantStatus: 424,
|
||||||
|
mustNotSay: []string{"did not open the sealed bundle", "could not be fetched"},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "the bundle predates the repository-password field",
|
name: "the bundle predates the repository-password field",
|
||||||
err: escrow.ErrNoResticPassword,
|
err: escrow.ErrNoResticPassword,
|
||||||
|
|||||||
@@ -825,6 +825,10 @@ func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request, vmid int)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
key := backupJobKey{vmid: vmid, target: tier.TargetID}
|
key := backupJobKey{vmid: vmid, target: tier.TargetID}
|
||||||
|
// R-899 (operator ruling 2026-10-08): a household press („Mentés most") is not the night's backup. A controller
|
||||||
|
// that knows sends `trigger=manual`; then the OS leg does not follow (it belongs to the night, after the night's
|
||||||
|
// own copy). An older controller sends nothing and keeps the old behaviour.
|
||||||
|
manual := r.URL.Query().Get("trigger") == "manual"
|
||||||
|
|
||||||
// ONE BACKUP AT A TIME PER GUEST, ACROSS ALL TIERS (operator ruling 2026-07-26: "other backup
|
// ONE BACKUP AT A TIME PER GUEST, ACROSS ALL TIERS (operator ruling 2026-07-26: "other backup
|
||||||
// shouldn't start until finished"). vzdump takes a guest lock, so a concurrent second backup
|
// shouldn't start until finished"). vzdump takes a guest lock, so a concurrent second backup
|
||||||
@@ -926,7 +930,9 @@ func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request, vmid int)
|
|||||||
}
|
}
|
||||||
s.finishJob(key, jobID, b)
|
s.finishJob(key, jobID, b)
|
||||||
// OS leg (agent v0.140.0): after the night's whole-guest copy exists, still holding the heavy-op gate.
|
// OS leg (agent v0.140.0): after the night's whole-guest copy exists, still holding the heavy-op gate.
|
||||||
if b.Success && tier.Primary && s.afterPrimaryBackup != nil {
|
if b.Success && tier.Primary && s.afterPrimaryBackup != nil && manual {
|
||||||
|
s.logger.Info("local-api: no OS leg after a manual backup — it follows the night's own backup (R-899)", "vmid", vmid, "job", jobID)
|
||||||
|
} else if b.Success && tier.Primary && s.afterPrimaryBackup != nil {
|
||||||
s.afterPrimaryBackup(base, vmid)
|
s.afterPrimaryBackup(base, vmid)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const OpKernelStep = "os_kernel_step"
|
|||||||
|
|
||||||
// DefaultKernelJudgeWait is how long a one-shot boot may take to come back healthy before the agent reverts it ONCE.
|
// DefaultKernelJudgeWait is how long a one-shot boot may take to come back healthy before the agent reverts it ONCE.
|
||||||
// Measured 2026-10-07 (`audits/kernel-lane-2026-10-07/B/`): every container healthy 68 s after a reboot on demo-felhom,
|
// Measured 2026-10-07 (`audits/kernel-lane-2026-10-07/B/`): every container healthy 68 s after a reboot on demo-felhom,
|
||||||
// 272 s on demo-hp; 20 minutes stays under the hub's 30-minute host_stale (a box that never comes back alarms after it).
|
// 272 s on demo-hp; 20 minutes stays under the hub's 45-minute host_stale (a box that never comes back alarms after it).
|
||||||
const DefaultKernelJudgeWait = 20 * time.Minute
|
const DefaultKernelJudgeWait = 20 * time.Minute
|
||||||
|
|
||||||
var kverRE = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-pve$`)
|
var kverRE = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-pve$`)
|
||||||
@@ -51,6 +51,24 @@ type KernelView struct {
|
|||||||
VMID int `json:"vmid"` // the customer guest the step was staged for (the health rule's guest)
|
VMID int `json:"vmid"` // the customer guest the step was staged for (the health rule's guest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// kernelReportRing is the ring an after-boot report carries. In the first second after a boot the agent has not
|
||||||
|
// fetched the hub's block yet, and Block() then answers ring 1 — so a ring-0 box's „judging" report said ring 1
|
||||||
|
// (seen on demo-felhom, 2026-10-08 night, `audits/kernel-night-2026-10-07/readback/`). Order: the fetched block; the
|
||||||
|
// block the daemon saved on disk before the reboot (R-866); ring 1 as before. A label only: the hub's approval reads its
|
||||||
|
// own ring list. Pinned by TestKernelReportRing_BeforeFirstFetch.
|
||||||
|
func (l *Leg) kernelReportRing() int {
|
||||||
|
l.mu.Lock()
|
||||||
|
fetched := l.block
|
||||||
|
l.mu.Unlock()
|
||||||
|
if fetched != nil {
|
||||||
|
return fetched.Ring
|
||||||
|
}
|
||||||
|
if b, _, ok := LoadSavedBlock(l.planDir()); ok && b != nil {
|
||||||
|
return b.Ring
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
func parseKernel(raw json.RawMessage) KernelView {
|
func parseKernel(raw json.RawMessage) KernelView {
|
||||||
var v KernelView
|
var v KernelView
|
||||||
_ = json.Unmarshal(raw, &v)
|
_ = json.Unmarshal(raw, &v)
|
||||||
@@ -119,8 +137,12 @@ func (l *Leg) runKernel(ctx context.Context, runID string, vmid int, trigger str
|
|||||||
lg.Info("osupdate: kernel step skipped — ring 1 boots only a kernel a signed os_kernel_step staged", "phase", st.Phase, "staged", st.To, "want", want)
|
lg.Info("osupdate: kernel step skipped — ring 1 boots only a kernel a signed os_kernel_step staged", "phase", st.Phase, "staged", st.To, "want", want)
|
||||||
return Report{}
|
return Report{}
|
||||||
default:
|
default:
|
||||||
|
// R-898: EXACTLY the kernel the household was told about — never "whatever is pending tonight" (the sources can
|
||||||
|
// offer a newer one by night; the step then refused, R23, and the night was lost). A version no longer
|
||||||
|
// installable is refused by the wrapper before any change (R7) and the hub tells the household again.
|
||||||
wr, cerr := l.call(ctx, runID, kernelPlan("apply", vmid, map[string]any{"release_id": "ring0-" + runID,
|
wr, cerr := l.call(ctx, runID, kernelPlan("apply", vmid, map[string]any{"release_id": "ring0-" + runID,
|
||||||
"select": "pending-kernel", "expect_kver": want, "run_id": runID, "trigger": trigger, "ring": blk.Ring}))
|
"select": "listed", "packages": KernelSet(want), "expect_kver": want, "run_id": runID, "trigger": trigger,
|
||||||
|
"ring": blk.Ring}))
|
||||||
rep.unsent = reportFile(l.planDir(), runID, LayerKernel, "apply")
|
rep.unsent = reportFile(l.planDir(), runID, LayerKernel, "apply")
|
||||||
rep.Kernel = rawOrNil(wr.Kernel)
|
rep.Kernel = rawOrNil(wr.Kernel)
|
||||||
switch {
|
switch {
|
||||||
@@ -186,7 +208,7 @@ func (l *Leg) KernelAfterBoot(ctx context.Context, vmid int, j KernelJudge) Repo
|
|||||||
if vmid <= 0 {
|
if vmid <= 0 {
|
||||||
vmid = v.VMID // after a boot the guest may not run yet — the step's own record names it
|
vmid = v.VMID // after a boot the guest may not run yet — the step's own record names it
|
||||||
}
|
}
|
||||||
rep := Report{RunID: runID, Layer: LayerKernel, Trigger: "boot", Ring: l.Block().Ring, VMID: vmid, Mode: "kernel-boot",
|
rep := Report{RunID: runID, Layer: LayerKernel, Trigger: "boot", Ring: l.kernelReportRing(), VMID: vmid, Mode: "kernel-boot",
|
||||||
ReleaseID: v.To, Kernel: rawOrNil(wr.Kernel)}
|
ReleaseID: v.To, Kernel: rawOrNil(wr.Kernel)}
|
||||||
switch wr.KernelEvent {
|
switch wr.KernelEvent {
|
||||||
case "fell_back":
|
case "fell_back":
|
||||||
@@ -236,7 +258,7 @@ func (l *Leg) judgeKernel(ctx context.Context, runID string, vmid int, v KernelV
|
|||||||
for {
|
for {
|
||||||
if !hubReached && l.Hub != nil {
|
if !hubReached && l.Hub != nil {
|
||||||
// the hub's reachability IS this report reaching it (and the operator sees the box is back on the new kernel)
|
// the hub's reachability IS this report reaching it (and the operator sees the box is back on the new kernel)
|
||||||
body, _ := json.Marshal(Report{RunID: runID, Layer: LayerKernel, Trigger: "boot", Ring: l.Block().Ring, VMID: vmid,
|
body, _ := json.Marshal(Report{RunID: runID, Layer: LayerKernel, Trigger: "boot", Ring: l.kernelReportRing(), VMID: vmid,
|
||||||
Mode: "kernel-boot", ReleaseID: v.To, Outcome: "judging", Kernel: mustRaw(v)})
|
Mode: "kernel-boot", ReleaseID: v.To, Outcome: "judging", Kernel: mustRaw(v)})
|
||||||
rctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
rctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
if err := l.Hub.PostOSReport(rctx, body); err == nil {
|
if err := l.Hub.PostOSReport(rctx, body); err == nil {
|
||||||
@@ -276,7 +298,7 @@ func (l *Leg) judgeKernel(ctx context.Context, runID string, vmid int, v KernelV
|
|||||||
return Report{}
|
return Report{}
|
||||||
}
|
}
|
||||||
// not healthy by the deadline: tell the hub (best effort), then ONE self-revert into the old kernel
|
// not healthy by the deadline: tell the hub (best effort), then ONE self-revert into the old kernel
|
||||||
rep := l.finish(ctx, lg, Report{RunID: runID, Layer: LayerKernel, Trigger: "boot", Ring: l.Block().Ring, VMID: vmid,
|
rep := l.finish(ctx, lg, Report{RunID: runID, Layer: LayerKernel, Trigger: "boot", Ring: l.kernelReportRing(), VMID: vmid,
|
||||||
Mode: "kernel-revert", ReleaseID: v.To, Outcome: "health_failed", HealthReason: why + " — reverting to " + v.From,
|
Mode: "kernel-revert", ReleaseID: v.To, Outcome: "health_failed", HealthReason: why + " — reverting to " + v.From,
|
||||||
Kernel: mustRaw(v)})
|
Kernel: mustRaw(v)})
|
||||||
lg.Error("osupdate: kernel step — the one-shot boot is NOT healthy; restarting ONCE into the old kernel", "reason", why,
|
lg.Error("osupdate: kernel step — the one-shot boot is NOT healthy; restarting ONCE into the old kernel", "reason", why,
|
||||||
@@ -285,7 +307,7 @@ func (l *Leg) judgeKernel(ctx context.Context, runID string, vmid int, v KernelV
|
|||||||
if err != nil || wr.refused() || wr.failed() {
|
if err != nil || wr.refused() || wr.failed() {
|
||||||
lg.Error("osupdate: kernel self-revert did not start — the box stays on the new kernel; the operator decides",
|
lg.Error("osupdate: kernel self-revert did not start — the box stays on the new kernel; the operator decides",
|
||||||
"err", err, "refused", string(firstRaw(wr.Refused, wr.Failed)))
|
"err", err, "refused", string(firstRaw(wr.Refused, wr.Failed)))
|
||||||
return l.finish(ctx, lg, Report{RunID: runID, Layer: LayerKernel, Trigger: "boot", Ring: l.Block().Ring, VMID: vmid,
|
return l.finish(ctx, lg, Report{RunID: runID, Layer: LayerKernel, Trigger: "boot", Ring: l.kernelReportRing(), VMID: vmid,
|
||||||
Mode: "kernel-revert", ReleaseID: v.To, Outcome: "revert_failed", Refused: firstRaw(wr.Refused, wr.Failed),
|
Mode: "kernel-revert", ReleaseID: v.To, Outcome: "revert_failed", Refused: firstRaw(wr.Refused, wr.Failed),
|
||||||
HealthReason: "the self-revert did not start"})
|
HealthReason: "the self-revert did not start"})
|
||||||
}
|
}
|
||||||
@@ -294,7 +316,7 @@ func (l *Leg) judgeKernel(ctx context.Context, runID string, vmid int, v KernelV
|
|||||||
|
|
||||||
func (l *Leg) kernelGood(ctx context.Context, runID string, vmid int, v KernelView, start time.Time, lg *slog.Logger) Report {
|
func (l *Leg) kernelGood(ctx context.Context, runID string, vmid int, v KernelView, start time.Time, lg *slog.Logger) Report {
|
||||||
wr, err := l.call(ctx, runID, kernelPlan("kernel-good", vmid, nil))
|
wr, err := l.call(ctx, runID, kernelPlan("kernel-good", vmid, nil))
|
||||||
rep := Report{RunID: runID, Layer: LayerKernel, Trigger: "boot", Ring: l.Block().Ring, VMID: vmid, Mode: "kernel-good",
|
rep := Report{RunID: runID, Layer: LayerKernel, Trigger: "boot", Ring: l.kernelReportRing(), VMID: vmid, Mode: "kernel-good",
|
||||||
ReleaseID: v.To}
|
ReleaseID: v.To}
|
||||||
switch {
|
switch {
|
||||||
case err != nil:
|
case err != nil:
|
||||||
@@ -322,6 +344,21 @@ func truncate(s string, n int) string {
|
|||||||
return s[:n]
|
return s[:n]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KernelSet is the package set that installs exactly kver (R-898; the hub's kernelSet, field-exact): the series
|
||||||
|
// meta-package and the signed image, both at the kernel's own version. Proxmox keeps old kernel versions in its archive.
|
||||||
|
// nil for a string that is not a kernel version.
|
||||||
|
func KernelSet(kver string) []Package {
|
||||||
|
m := kverSeriesRE.FindStringSubmatch(kver)
|
||||||
|
if m == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v := kver[:len(kver)-len("-pve")]
|
||||||
|
return []Package{{Name: "proxmox-kernel-" + m[1], Version: v, Origin: PVEOrigin},
|
||||||
|
{Name: "proxmox-kernel-" + kver + "-signed", Version: v, Origin: PVEOrigin}}
|
||||||
|
}
|
||||||
|
|
||||||
|
var kverSeriesRE = regexp.MustCompile(`^([0-9]+\.[0-9]+)\.[0-9]+-[0-9]+-pve$`)
|
||||||
|
|
||||||
// KernelStepParams are a signed os_kernel_step's params: the exact kernel set (the wrapper compares it with the plan).
|
// KernelStepParams are a signed os_kernel_step's params: the exact kernel set (the wrapper compares it with the plan).
|
||||||
type KernelStepParams struct {
|
type KernelStepParams struct {
|
||||||
ReleaseID string `json:"release_id"`
|
ReleaseID string `json:"release_id"`
|
||||||
|
|||||||
@@ -54,8 +54,12 @@ func TestKernel_Ring0ToldNightStagesThenReboots(t *testing.T) {
|
|||||||
ap = x
|
ap = x
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ap["select"] != "pending-kernel" || ap["expect_kver"] != kNew || ap["lane"] != "slow" {
|
// R-898: EXACTLY the told kernel — the listed set derived from it, never "pending" (red before the fix: select was
|
||||||
t.Fatalf("stage plan = %v", ap)
|
// pending-kernel, so a newer kernel in the sources by night was refused R23 and the night was lost)
|
||||||
|
pk, _ := json.Marshal(ap["packages"])
|
||||||
|
if ap["select"] != "listed" || ap["expect_kver"] != kNew || ap["lane"] != "slow" ||
|
||||||
|
string(pk) != `[{"name":"proxmox-kernel-7.0","origin":"Proxmox Debian Repository","version":"7.0.14-22"},{"name":"proxmox-kernel-7.0.14-22-pve-signed","origin":"Proxmox Debian Repository","version":"7.0.14-22"}]` {
|
||||||
|
t.Fatalf("stage plan = %v (packages %s)", ap, pk)
|
||||||
}
|
}
|
||||||
if p.Kernel.Outcome != "staged" || !p.Kernel.Healthy {
|
if p.Kernel.Outcome != "staged" || !p.Kernel.Healthy {
|
||||||
t.Fatalf("kernel report = %+v", p.Kernel)
|
t.Fatalf("kernel report = %+v", p.Kernel)
|
||||||
@@ -312,3 +316,13 @@ func TestKernel_KeptStageReportIsStaged(t *testing.T) {
|
|||||||
t.Fatalf("kept = %+v", rep)
|
t.Fatalf("kept = %+v", rep)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestKernelSet(t *testing.T) {
|
||||||
|
if got := KernelSet("7.0.14-20-pve"); len(got) != 2 || got[0].Name != "proxmox-kernel-7.0" || got[0].Version != "7.0.14-20" ||
|
||||||
|
got[1].Name != "proxmox-kernel-7.0.14-20-pve-signed" {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
if KernelSet("7.0; reboot") != nil || KernelSet("") != nil {
|
||||||
|
t.Fatal("a non-kernel string must give no set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package osupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||||
|
)
|
||||||
|
|
||||||
|
// After a boot the agent has not fetched the hub's block yet; the after-boot kernel report must still carry the
|
||||||
|
// box's real ring (seen 2026-10-08: a ring-0 box's „judging" report said ring 1). Red-proof: return Block().Ring
|
||||||
|
// from kernelReportRing and the saved-block case fails with 1.
|
||||||
|
func TestKernelReportRing_BeforeFirstFetch(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
l := &Leg{PlanDir: dir}
|
||||||
|
if got := l.kernelReportRing(); got != 1 {
|
||||||
|
t.Fatalf("nothing fetched, nothing saved: ring %d, want 1 (the old default)", got)
|
||||||
|
}
|
||||||
|
l.saveBlock(&hub.WireOSUpdate{Ring: 0, Enabled: true})
|
||||||
|
l2 := &Leg{PlanDir: dir} // a fresh daemon after the reboot: no block fetched yet
|
||||||
|
if got := l2.kernelReportRing(); got != 0 {
|
||||||
|
t.Fatalf("ring-0 block saved before the reboot: ring %d, want 0", got)
|
||||||
|
}
|
||||||
|
l2.SetBlock(&hub.WireOSUpdate{Ring: 1, Enabled: true})
|
||||||
|
if got := l2.kernelReportRing(); got != 1 {
|
||||||
|
t.Fatalf("a fetched block wins over the saved one: ring %d, want 1", got)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, SavedBlockFile)); err != nil {
|
||||||
|
t.Fatalf("saved block missing: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user