Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b890b57540 | |||
| aa69114d18 | |||
| abcc1e6833 | |||
| d912307bb7 | |||
| feaaa0e8ff | |||
| 6b6ba0c17f | |||
| 4697f8ac9e |
+65
-9
@@ -1,4 +1,60 @@
|
|||||||
## Unreleased (next: v0.165.0) — D43 = A: BYO hosts keep their own SSH settings (R-1257) (2026-10-24 night) — committed, NOT released (the release + the operator's vouch are Monday's)
|
## v0.166.0 (2026-10-26) — a signed job's window is at most 24 h (R-1280); the policy pass after each bundle (R-1285); the published gate follows D50 (R-1290) — released in the attended release run; **VOUCHED by the operator 2026-10-26 10:04:55 CET** (binary `3f22c65b…`, bundle `093772c1…`)
|
||||||
|
|
||||||
|
Released from a clean clone at `aa69114` (tag `v0.166.0`; binary sha256 `3f22c65b78914632fa476292f6b58e5af3ed9e9900f3a5a7a347a9c0bfea9c5c`,
|
||||||
|
bundle `093772c1bda8b974e86ed45472f8fadc8992f247563f5739876102a5156003a3`); delivered to Tester 1, demo-hp and demo-felhom
|
||||||
|
by signed `agent_update` + `agent_config_update` (each: COMPLETED, bundle INSTALLED, self-check 76/76, update committed).
|
||||||
|
Live: a 25 h signed job REJECTED on demo-hp („op validity window too long"), a 30 min one ran; `felhom-opsign -ttl 25h`
|
||||||
|
refuses. D50 pin `day0_agent` → 0.166.0 (`scripts/retention-policy.json`). Evidence: felhom.eu
|
||||||
|
`documentation/audits/release-monday-2026-10-26/agent-0166/`.
|
||||||
|
|
||||||
|
**Found at the release (felhom.eu R-1285, back to OPEN):** in the normal two-job delivery the bundle job runs in the OLD
|
||||||
|
process (0.165.0) before the restart into 0.166.0, so the policy pass does not run on that bundle — a BYO host coming
|
||||||
|
from 0.164.0 still loses our drop-in one release late. The pass itself is wired: under 0.166.0 a later bundle logged
|
||||||
|
„BUNDLE POLICY DONE install_mode=appliance removed=0".
|
||||||
|
|
||||||
|
### 2026-10-25 (night, unattended) — a BYO host loses our SSH drop-in in ONE update (R-1285) — committed, NOT released
|
||||||
|
|
||||||
|
- **The cause, measured 2026-10-25 on a BYO test host:** the bundle that brings a new `felhom-os-apply` is planned by
|
||||||
|
the OLD one on disk, which did not know D43's appliance-only policy — so a BYO host upgraded from 0.164.0 kept our
|
||||||
|
drop-in until its next bundle. A change inside the wrapper alone cannot fix the run that delivers it.
|
||||||
|
- **The fix — the new AGENT asks the new wrapper:** `internal/osupdate/bundle.go` runs a policy pass right after every
|
||||||
|
bundle install (`mode: "bundle-policy"`, host layer, no envelope); the wrapper on disk is the bundle's own by then.
|
||||||
|
`configs/felhom-os-apply` `Bundle.policy_pass`: on a root-owned BYO record it removes the appliance-only files WE
|
||||||
|
wrote (our header), keeps the previous copy beside the bundle's, `sshd -t` then reloads ssh — a failing `sshd -t` puts
|
||||||
|
the file back. It writes nothing, so it needs no signature; an appliance and the owner's own file are left alone. Never
|
||||||
|
fatal for the agent: an older wrapper refuses the unknown mode and the next bundle does it. Report key `bundle_policy`.
|
||||||
|
- Tests: `TestR1285_AnInstalledBundleIsFollowedByThePolicyPass` (a refused bundle runs no pass);
|
||||||
|
`ByoKeepsItsSSH.test_the_policy_pass_*` (three). Red-proofs: felhom.eu
|
||||||
|
`documentation/audits/night-2026-10-25/redproof-r1285.txt`.
|
||||||
|
|
||||||
|
### 2026-10-25 (night, unattended) — a signed job's window is at most 24 h (R-1280, D44 #22) — committed, NOT released (rides the next agent release; the vouch is the operator's)
|
||||||
|
|
||||||
|
- `internal/authz/verifier.go`: `MaxWindow = 24h`; a blob whose `expires_at - issued_at` is longer is refused
|
||||||
|
(`ErrWindowTooLong`) before the expiry and nonce checks — a job signed with a long window by mistake no longer stays
|
||||||
|
usable that long, and the nonce store no longer keeps its record that long.
|
||||||
|
- `configs/felhom-os-apply`: the root wrapper holds the same 24 h (`MAX_WINDOW_S`, refusal R3 „the signed job's window
|
||||||
|
is longer than 24 h").
|
||||||
|
- `cmd/felhom-opsign`: `-ttl` above 24 h (or ≤ 0) is refused before anything is signed.
|
||||||
|
- Tests: `TestVerify_RejectsPerStage/window_longer_than_24_h` and `…/window_of_exactly_24_h_is_accepted`;
|
||||||
|
`test_felhom_os_apply.DockerLane.test_a_window_longer_than_24h_is_refused` / `…_of_24h_is_accepted`. Both refusals
|
||||||
|
seen failing with their check switched off: felhom.eu `documentation/audits/night-2026-10-25/redproof-r1280.txt`.
|
||||||
|
|
||||||
|
### 2026-10-25 (evening, attended) — the `published` gate follows D50 = A: the newest 5 plus the pinned MinAgent and day-0 agent (R-1290; no binary change)
|
||||||
|
|
||||||
|
- **D50 = A** (operator, 2026-10-25): the registry keeps the newest 5 released agent versions, the hub's MinAgent floor and
|
||||||
|
the day-0 manifest's agent. `scripts/retention-policy.json`: `generic_versions_kept` 10 → 5, new `pinned`
|
||||||
|
(`hub_min_agent` 0.158.1, `day0_agent` 0.165.0 — copies of hub state, because CI holds no hub secret; the session that
|
||||||
|
records a vouch or a new MinAgent updates them in the same commit). `check-published-versions.py` asserts the window
|
||||||
|
plus every pin; a pin with no tag FAILS. CI was red since the operator's hand prune of 0.159.0–0.160.0 (job 2334).
|
||||||
|
- **Decoys** (`test_gate_decoys.py`, via `FELHOM_RETENTION_POLICY`, test only): a pinned version older than the window is
|
||||||
|
asserted; its 404 convicts; a pin with no tag convicts. Seen failing on the old gate (two LIVE HOLES): felhom.eu
|
||||||
|
`documentation/audits/night-2026-10-25/redproof-d50.txt`.
|
||||||
|
|
||||||
|
## v0.165.0 (2026-10-25) — D43 = A: BYO hosts keep their own SSH settings (R-1257); the line test for a box that seeds all day (R-1250) — released in the attended release run; **VOUCHED by the operator 2026-10-25 18:38:49 CET** (binary `e9d04a47…`, bundle `8dea1f34…`)
|
||||||
|
|
||||||
|
**Found at the release (felhom.eu R-1285, P4):** on a BYO host upgraded from 0.164.0 the drop-in is removed only on the bundle run AFTER the one that installs this wrapper — the first run is planned by the old wrapper. Proven on a BYO test host (VM 9371 on demo-hp): the second `agent_config_update` logged `BUNDLE REMOVED … (ours, on a BYO host — D43)`.
|
||||||
|
|
||||||
|
### BYO hosts keep their own SSH settings (R-1257, D43 = A) — 2026-10-24 night
|
||||||
|
|
||||||
- **The root-key-only sshd drop-in (`/etc/ssh/sshd_config.d/10-felhom-root-key-only.conf`, R-982 / D4) goes only to an
|
- **The root-key-only sshd drop-in (`/etc/ssh/sshd_config.d/10-felhom-root-key-only.conf`, R-982 / D4) goes only to an
|
||||||
appliance** — operator ruling D43 = A (2026-10-24): a BYO host belongs to its owner (`11` §1). The table entry's policy
|
appliance** — operator ruling D43 = A (2026-10-24): a BYO host belongs to its owner (`11` §1). The table entry's policy
|
||||||
@@ -17,14 +73,7 @@
|
|||||||
is; a LAN `Match` block is not in force; every address key-only passes; a failing `sshd -t` undoes only the SSH step.
|
is; a LAN `Match` block is not in force; every address key-only passes; a failing `sshd -t` undoes only the SSH step.
|
||||||
The fake box now carries an appliance record by default (every earlier test unchanged).
|
The fake box now carries an appliance record by default (every earlier test unchanged).
|
||||||
|
|
||||||
## 2026-10-24 (review round 2, unattended) — `.claude/rules/unprompted-work.md` aligned with its four siblings
|
### The line test for a box that seeds all day (R-1250) — 2026-10-24
|
||||||
|
|
||||||
- The copy lacked item 6's paragraph „A version number is pushed once …" that the felhom.eu and felhom-controller copies
|
|
||||||
have carried since 2026-10-15; the file's header says „change all five or none". The paragraph states a fact
|
|
||||||
(`felhom-controller/controller/build.sh` refuses an existing tag and an unreachable registry). Now byte-identical to
|
|
||||||
the other four (md5 b1891bd4). No code change. Record: felhom.eu `documentation/audits/review-round-2-2026-10-24/EDITS.md`.
|
|
||||||
|
|
||||||
## Unreleased (next: v0.165.0) — the line test for a box that seeds all day (R-1250) (2026-10-24) — committed, not released
|
|
||||||
|
|
||||||
Attended brief 2026-10-24, Part 4 (commit only; the release rides the next normal train). The weekly test (v0.164.0)
|
Attended brief 2026-10-24, Part 4 (commit only; the release rides the next normal train). The weekly test (v0.164.0)
|
||||||
starts only after 10 quiet minutes; a torrent app that seeds all day never gives them, so that box never got a tested
|
starts only after 10 quiet minutes; a torrent app that seeds all day never gives them, so that box never got a tested
|
||||||
@@ -47,6 +96,13 @@ line and qBittorrent never got its 50 % limit. Now:
|
|||||||
|
|
||||||
**No sudoers or config-bundle change. MinController: none** — an older controller never calls the route.
|
**No sudoers or config-bundle change. MinController: none** — an older controller never calls the route.
|
||||||
|
|
||||||
|
## 2026-10-24 (review round 2, unattended) — `.claude/rules/unprompted-work.md` aligned with its four siblings
|
||||||
|
|
||||||
|
- The copy lacked item 6's paragraph „A version number is pushed once …" that the felhom.eu and felhom-controller copies
|
||||||
|
have carried since 2026-10-15; the file's header says „change all five or none". The paragraph states a fact
|
||||||
|
(`felhom-controller/controller/build.sh` refuses an existing tag and an unreachable registry). Now byte-identical to
|
||||||
|
the other four (md5 b1891bd4). No code change. Record: felhom.eu `documentation/audits/review-round-2-2026-10-24/EDITS.md`.
|
||||||
|
|
||||||
## v0.164.0 — the weekly upload line test (R-1247) (2026-10-24) — RELEASED on the operator's ruling (attended release run); vouched by the operator 09:44:58 CEST
|
## v0.164.0 — the weekly upload line test (R-1247) (2026-10-24) — RELEASED on the operator's ruling (attended release run); vouched by the operator 09:44:58 CEST
|
||||||
|
|
||||||
Binary sha256 `1b803d8b70f8afbe1546f6989f35b6b0e29c9ed63f6acb3843d3c04ef0d24b2b`; config bundle sha256
|
Binary sha256 `1b803d8b70f8afbe1546f6989f35b6b0e29c9ed63f6acb3843d3c04ef0d24b2b`; config bundle sha256
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
# REPORT — D43 = A: BYO hosts keep their own SSH settings (R-1257), 2026-10-24 night (unattended; commit only)
|
# REPORT — D50 retention gate, R-1280, R-1285: committed, not released (2026-10-25 night)
|
||||||
|
|
||||||
Brief: the restore-holes run, Part 4. Starting the run was the operator's ruling **D43 = A**. Previous report (R-1250's
|
**Plain words first.** Three agent changes are on `main`. None is released. The release (v0.166.0) is Monday's and
|
||||||
line hold, attended, same day): `git show f5c3d77:REPORT.md`.
|
waits for the operator's vouch (felhom.eu `STATUS.md`, the Monday list).
|
||||||
|
|
||||||
- **`configs/felhom-os-apply` (commit `c8d3c17`):** the root-key-only sshd drop-in is an `appliance`-policy file —
|
## Each change, before → after
|
||||||
written only when the root-owned install record says `appliance`; on `byo` nothing is written and a copy we wrote
|
|
||||||
(exact path, our header line) is removed (the previous copy kept for the undo, then `sshd -t` and a reload); a box
|
| Row | Commit | Before | After | Proof |
|
||||||
whose record proves nothing is left as it is. **X3:** the in-force check asks `sshd -T` for each of the host's own
|
|---|---|---|---|---|
|
||||||
global IPv4 addresses. **X4:** a failing `sshd -t` puts back only the SSH file (`sshd_step_undone` in the report);
|
| R-1290 (D50 = A) | `feaaa0e` | the published-versions gate knew no keep rule | it asserts the newest 5 released versions plus the pinned MinAgent floor (0.158.1) and the day-0 agent (0.165.0) in `scripts/retention-policy.json`; a pin with no tag fails | decoys seen failing; CI 2339 ✓ |
|
||||||
the rest of the bundle stays.
|
| R-1280 | `d912307` | a signed job's window could be any length | at most 24 h — the verifier (`ErrWindowTooLong`), the root wrapper (`MAX_WINDOW_S`) and `felhom-opsign -ttl`; exactly 24 h is accepted | red-proof; unit tests; not live |
|
||||||
- **Tests:** `configs/test_felhom_config_bundle.py` class `ByoKeepsItsSSH` (7), each rule seen failing with it taken out
|
| R-1285 | `abcc1e6` | a BYO host upgraded from an old wrapper kept our SSH drop-in until its NEXT bundle (the old wrapper planned the new bundle) | after every bundle install the agent runs the new wrapper's `bundle-policy` pass; the file is removed in one update; a failing `sshd -t` puts it back | red-proof; live on a BYO test VM: one signed run gave `BUNDLE POLICY REMOVED`; the test package was deleted after (204, then 404) |
|
||||||
(policy back to `replace`; the probe back to 192.0.2.1; `sshd -t` back to `must`). The fake box now carries an
|
|
||||||
appliance record by default; all 68 bundle tests and 146 os-apply tests pass; `agent_gates.py --fast` OK.
|
## CI (by commit hash, `jobs` endpoint)
|
||||||
- **Not done here (Monday):** the agent release v0.165.0 and the operator's vouch; a signed bundle on a BYO test host.
|
|
||||||
- Docs: felhom.eu `03` (the D4 paragraph) and `11` §1 carry the ruling; register R-1257 → VERIFY.
|
`feaaa0e` 2339 ✓ · `d912307` 2344 ✓ · `abcc1e6` 2345 ✓ — all `run_attempt` 1.
|
||||||
|
|
||||||
|
## Teardown
|
||||||
|
|
||||||
|
The BYO test VM (demo-hp 9372) was uninstalled through the product and destroyed. Its hub host record needs the
|
||||||
|
operator's delete (felhom.eu `STATUS.md`).
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
felhom.eu `documentation/audits/night-2026-10-25/` (`redproof-d50.txt`, `redproof-r1280.txt`, `redproof-r1285.txt`,
|
||||||
|
`rig/r1285-live.txt`).
|
||||||
|
|||||||
@@ -123,6 +123,10 @@ func run() error {
|
|||||||
|
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
issued := now
|
issued := now
|
||||||
|
if *ttl <= 0 || *ttl > authz.MaxWindow {
|
||||||
|
fmt.Fprintf(os.Stderr, "felhom-opsign: -ttl %s refused: a box accepts a window of at most %s (R-1280)\n", *ttl, authz.MaxWindow)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
expires := now.Add(*ttl)
|
expires := now.Add(*ttl)
|
||||||
|
|
||||||
// Canonical OpBlob bytes — the EXACT bytes the agent verifier authenticates over.
|
// Canonical OpBlob bytes — the EXACT bytes the agent verifier authenticates over.
|
||||||
|
|||||||
+48
-1
@@ -187,6 +187,8 @@ BUNDLE_PREV_DIR = "/var/lib/felhom-os-apply/bundle-prev" # the previous copies
|
|||||||
BUNDLE_KEEP = 3
|
BUNDLE_KEEP = 3
|
||||||
BUNDLE_MAX_BYTES = 4 * 1024 * 1024
|
BUNDLE_MAX_BYTES = 4 * 1024 * 1024
|
||||||
BUNDLE_RE = re.compile(r"^bundle-[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?\.json$")
|
BUNDLE_RE = re.compile(r"^bundle-[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?\.json$")
|
||||||
|
# R-1280: the longest validity window a signed job may carry — the agent's authz.MaxWindow, the same 24 h.
|
||||||
|
MAX_WINDOW_S = 24 * 3600
|
||||||
OOB_DIR = "/etc/felhom-sshd"
|
OOB_DIR = "/etc/felhom-sshd"
|
||||||
# R-982: the host's own sshd drop-in (after_install reloads sshd for it; undo reloads after putting it back).
|
# R-982: the host's own sshd drop-in (after_install reloads sshd for it; undo reloads after putting it back).
|
||||||
SSHD_DROPIN = "/etc/ssh/sshd_config.d/10-felhom-root-key-only.conf"
|
SSHD_DROPIN = "/etc/ssh/sshd_config.d/10-felhom-root-key-only.conf"
|
||||||
@@ -489,7 +491,8 @@ class Apply:
|
|||||||
|
|
||||||
def check_plan(self, plan):
|
def check_plan(self, plan):
|
||||||
mode = plan.get("mode", "apply")
|
mode = plan.get("mode", "apply")
|
||||||
if mode not in ("apply", "inventory", "health", "facts", "live-restore-on", "bundle", "agent_update", "oom-check") \
|
if mode not in ("apply", "inventory", "health", "facts", "live-restore-on", "bundle", "bundle-policy", "agent_update",
|
||||||
|
"oom-check") \
|
||||||
+ KERNEL_MODES:
|
+ KERNEL_MODES:
|
||||||
raise Refused("R11", f"unknown mode {mode!r}")
|
raise Refused("R11", f"unknown mode {mode!r}")
|
||||||
if mode == "agent_update":
|
if mode == "agent_update":
|
||||||
@@ -500,6 +503,10 @@ class Apply:
|
|||||||
if plan.get("layer") != "host":
|
if plan.get("layer") != "host":
|
||||||
raise Refused("R11", "bundle is a host-layer mode")
|
raise Refused("R11", "bundle is a host-layer mode")
|
||||||
return mode, "host", 0, "bundle"
|
return mode, "host", 0, "bundle"
|
||||||
|
if mode == "bundle-policy":
|
||||||
|
if plan.get("layer") != "host":
|
||||||
|
raise Refused("R11", "bundle-policy is a host-layer mode")
|
||||||
|
return mode, "host", 0, "bundle-policy"
|
||||||
layer = plan.get("layer")
|
layer = plan.get("layer")
|
||||||
if layer not in ("guest", "host", "docker", "pve", "kernel"):
|
if layer not in ("guest", "host", "docker", "pve", "kernel"):
|
||||||
raise Refused("R12", f"layer {layer!r} is not guest, host, docker, pve or kernel")
|
raise Refused("R12", f"layer {layer!r} is not guest, host, docker, pve or kernel")
|
||||||
@@ -661,6 +668,8 @@ class Apply:
|
|||||||
iss = calendar.timegm(time.strptime(op["issued_at"], "%Y-%m-%dT%H:%M:%SZ"))
|
iss = calendar.timegm(time.strptime(op["issued_at"], "%Y-%m-%dT%H:%M:%SZ"))
|
||||||
except (KeyError, ValueError, TypeError):
|
except (KeyError, ValueError, TypeError):
|
||||||
raise Refused("R3", "the signed job has no readable time window")
|
raise Refused("R3", "the signed job has no readable time window")
|
||||||
|
if exp - iss > MAX_WINDOW_S:
|
||||||
|
raise Refused("R3", "the signed job's window is longer than 24 h (R-1280)")
|
||||||
if now > exp or now < iss - 120:
|
if now > exp or now < iss - 120:
|
||||||
raise Refused("R3", "the signed job is expired or not yet valid")
|
raise Refused("R3", "the signed job is expired or not yet valid")
|
||||||
nonce = op.get("nonce")
|
nonce = op.get("nonce")
|
||||||
@@ -1070,6 +1079,8 @@ class Apply:
|
|||||||
return self.facts()
|
return self.facts()
|
||||||
if self.mode == "bundle":
|
if self.mode == "bundle":
|
||||||
return Bundle(self).from_plan(plan)
|
return Bundle(self).from_plan(plan)
|
||||||
|
if self.mode == "bundle-policy":
|
||||||
|
return Bundle(self).policy_pass()
|
||||||
if self.mode == "agent_update":
|
if self.mode == "agent_update":
|
||||||
return self.agent_update(plan)
|
return self.agent_update(plan)
|
||||||
if self.layer in ("host", "pve", "kernel"):
|
if self.layer in ("host", "pve", "kernel"):
|
||||||
@@ -2060,6 +2071,42 @@ class Bundle:
|
|||||||
ver, files = self.parse(data, sha)
|
ver, files = self.parse(data, sha)
|
||||||
return self.install(ver, sha, files, "installer", False)
|
return self.install(ver, sha, files, "installer", False)
|
||||||
|
|
||||||
|
def policy_pass(self):
|
||||||
|
"""R-1285 (D43 = A): the appliance-only files' policy, applied by THIS wrapper. A bundle that brings a new wrapper
|
||||||
|
is planned by the OLD one, which did not know the policy — so on a BYO host upgraded from 0.164.0 our SSH drop-in
|
||||||
|
stayed until the next bundle. The agent runs this pass right after every bundle install (the wrapper on disk is
|
||||||
|
then the new one). It only REMOVES a file WE wrote (our header) from a box whose root-owned install record says
|
||||||
|
byo, keeps the previous copy beside the bundle's, and reloads sshd; it writes nothing, so it needs no signature.
|
||||||
|
A failing `sshd -t` puts the file back (R-1257 X4's rule). Report key `bundle_policy`."""
|
||||||
|
log = self.r.log
|
||||||
|
install_mode = self.install_mode()
|
||||||
|
removed = []
|
||||||
|
if install_mode == "byo":
|
||||||
|
stamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime(self.r.now()))
|
||||||
|
prev = os.path.join(BUNDLE_PREV_DIR, f"{stamp}-policy")
|
||||||
|
for dest, _src, _mode, _check, policy in BUNDLE_FILES:
|
||||||
|
if policy != "appliance":
|
||||||
|
continue
|
||||||
|
old, old_mode, _ = self.live(dest)
|
||||||
|
if old is None or not old.startswith(SSHD_DROPIN_HEADER):
|
||||||
|
continue
|
||||||
|
self.r.put_file(prev + dest, old, 0o600)
|
||||||
|
self.r.remove(dest)
|
||||||
|
removed.append((dest, old, old_mode))
|
||||||
|
log(f"os-apply: BUNDLE POLICY REMOVED {dest} (ours, on a BYO host — D43, R-1285)")
|
||||||
|
if any(d == SSHD_DROPIN for d, _, _ in removed):
|
||||||
|
rc, out, err = self.r.host(["/usr/sbin/sshd", "-t"], 120)
|
||||||
|
if rc != 0:
|
||||||
|
for d, old, old_mode in removed:
|
||||||
|
self.r.put_file(d, old, old_mode)
|
||||||
|
log(f"os-apply: BUNDLE POLICY WARNING sshd -t failed without our drop-in (rc={rc}) — put back")
|
||||||
|
removed = []
|
||||||
|
else:
|
||||||
|
self.must(["systemctl", "try-reload-or-restart", "ssh"], "systemctl try-reload-or-restart ssh")
|
||||||
|
self.report["bundle_policy"] = {"install_mode": install_mode, "removed": [d for d, _, _ in removed]}
|
||||||
|
log(f"os-apply: BUNDLE POLICY DONE install_mode={install_mode} removed={len(removed)}")
|
||||||
|
return 0
|
||||||
|
|
||||||
# ---------- install, self-check, undo ----------
|
# ---------- install, self-check, undo ----------
|
||||||
def install(self, ver, sha, files, authority, bootstrap):
|
def install(self, ver, sha, files, authority, bootstrap):
|
||||||
log = self.r.log
|
log = self.r.log
|
||||||
|
|||||||
@@ -588,6 +588,47 @@ class ByoKeepsItsSSH(unittest.TestCase):
|
|||||||
return orig(argv, timeout, stdin)
|
return orig(argv, timeout, stdin)
|
||||||
box.host = host
|
box.host = host
|
||||||
|
|
||||||
|
# R-1285: the policy pass the agent runs right after every bundle install, with the wrapper NOW on disk. No
|
||||||
|
# signature (it only removes a file we wrote). RED-PROOF: make policy_pass return before the loop → our copy stays.
|
||||||
|
POLICY_PLAN = {"release_id": "bundle-policy-0.166.0", "layer": "host", "mode": "bundle-policy"}
|
||||||
|
|
||||||
|
def test_the_policy_pass_removes_our_copy_from_a_byo_host(self):
|
||||||
|
data, sha = real_bundle()
|
||||||
|
box = Box(data, None, install_mode="byo")
|
||||||
|
box.put(PLAN, json.dumps(self.POLICY_PLAN).encode(), 0o600, uid=999)
|
||||||
|
box.put(self.DEST, self.OURS, 0o644) # the old wrapper's bundle run left it
|
||||||
|
rc, rep = run(box)
|
||||||
|
self.assertEqual(rc, 0, rep)
|
||||||
|
self.assertNotIn(self.DEST, box.files)
|
||||||
|
self.assertEqual(rep["bundle_policy"], {"install_mode": "byo", "removed": [self.DEST]}, rep)
|
||||||
|
self.assertTrue(any(k.endswith(self.DEST) and k.startswith(osapply.BUNDLE_PREV_DIR) for k in box.files))
|
||||||
|
self.assertLess(box.calls.index(["/usr/sbin/sshd", "-t"]), box.calls.index(["systemctl", "try-reload-or-restart", "ssh"]))
|
||||||
|
|
||||||
|
def test_the_policy_pass_leaves_an_appliance_and_the_owners_file(self):
|
||||||
|
for mode, content in (("appliance", None), ("byo", b"# my own\nPermitRootLogin yes\n")):
|
||||||
|
data, sha = real_bundle()
|
||||||
|
box = Box(data, None, install_mode=mode)
|
||||||
|
box.put(PLAN, json.dumps(self.POLICY_PLAN).encode(), 0o600, uid=999)
|
||||||
|
body = content if content is not None else self.OURS
|
||||||
|
box.put(self.DEST, body, 0o644)
|
||||||
|
rc, rep = run(box)
|
||||||
|
self.assertEqual(rc, 0, rep)
|
||||||
|
self.assertEqual(box.files.get(self.DEST), body, mode)
|
||||||
|
self.assertEqual(rep["bundle_policy"]["removed"], [], mode)
|
||||||
|
self.assertNotIn(["systemctl", "try-reload-or-restart", "ssh"], box.calls, mode)
|
||||||
|
|
||||||
|
def test_the_policy_pass_puts_our_copy_back_when_sshd_t_fails(self):
|
||||||
|
data, sha = real_bundle()
|
||||||
|
box = Box(data, None, install_mode="byo")
|
||||||
|
box.put(PLAN, json.dumps(self.POLICY_PLAN).encode(), 0o600, uid=999)
|
||||||
|
box.put(self.DEST, self.OURS, 0o644)
|
||||||
|
self._fake(box, sshd_t_rc=1)
|
||||||
|
rc, rep = run(box)
|
||||||
|
self.assertEqual(rc, 0, rep)
|
||||||
|
self.assertEqual(box.files.get(self.DEST), self.OURS)
|
||||||
|
self.assertEqual(rep["bundle_policy"]["removed"], [])
|
||||||
|
self.assertNotIn(["systemctl", "try-reload-or-restart", "ssh"], box.calls)
|
||||||
|
|
||||||
def test_a_match_block_for_the_lan_is_not_in_force(self):
|
def test_a_match_block_for_the_lan_is_not_in_force(self):
|
||||||
data, sha = real_bundle()
|
data, sha = real_bundle()
|
||||||
box = Box(data, signed_job(sha))
|
box = Box(data, signed_job(sha))
|
||||||
|
|||||||
@@ -833,6 +833,14 @@ class DockerLane(unittest.TestCase):
|
|||||||
def test_expired_signed_job_is_refused(self):
|
def test_expired_signed_job_is_refused(self):
|
||||||
self.refused(docker_fake(signed=signed_job(expires="2026-10-04T11:55:00Z")), "R3")
|
self.refused(docker_fake(signed=signed_job(expires="2026-10-04T11:55:00Z")), "R3")
|
||||||
|
|
||||||
|
def test_a_window_longer_than_24h_is_refused(self):
|
||||||
|
# R-1280. RED-PROOF: drop the MAX_WINDOW_S check → the job (current, signed, 24 h + 1 s) runs.
|
||||||
|
self.refused(docker_fake(signed=signed_job(issued="2026-10-03T11:59:59Z", expires="2026-10-04T12:00:00Z")), "R3")
|
||||||
|
|
||||||
|
def test_a_window_of_24h_is_accepted(self):
|
||||||
|
rc, rep = run(docker_fake(signed=signed_job(issued="2026-10-03T12:30:00Z", expires="2026-10-04T12:30:00Z")))
|
||||||
|
self.assertNotEqual((rep.get("refused") or {}).get("code"), "R3", rep)
|
||||||
|
|
||||||
def test_replayed_signed_job_is_refused(self):
|
def test_replayed_signed_job_is_refused(self):
|
||||||
f = docker_fake(signed=signed_job())
|
f = docker_fake(signed=signed_job())
|
||||||
f.nonces = {"n1": f.clock + 600}
|
f.nonces = {"n1": f.clock + 600}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ var (
|
|||||||
ErrExpired = errors.New("authz: op expired")
|
ErrExpired = errors.New("authz: op expired")
|
||||||
// ErrNotYetValid: now < issued_at (minus clock-skew tolerance).
|
// ErrNotYetValid: now < issued_at (minus clock-skew tolerance).
|
||||||
ErrNotYetValid = errors.New("authz: op not yet valid")
|
ErrNotYetValid = errors.New("authz: op not yet valid")
|
||||||
|
// ErrWindowTooLong: expires_at - issued_at > MaxWindow (R-1280).
|
||||||
|
ErrWindowTooLong = errors.New("authz: op validity window too long")
|
||||||
// ErrReplay: the nonce was already recorded in the window.
|
// ErrReplay: the nonce was already recorded in the window.
|
||||||
ErrReplay = errors.New("authz: replay (nonce already seen)")
|
ErrReplay = errors.New("authz: replay (nonce already seen)")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ const Namespace = "felhom-op-v1"
|
|||||||
// the validity window stays an honest upper bound.
|
// the validity window stays an honest upper bound.
|
||||||
const DefaultClockSkew = 2 * time.Minute
|
const DefaultClockSkew = 2 * time.Minute
|
||||||
|
|
||||||
|
// MaxWindow is the longest validity window a signed op may carry (R-1280, D44 #22, operator 2026-10-25): a job signed
|
||||||
|
// with a long window by mistake would stay usable that long, and the nonce store would keep its record as long. The
|
||||||
|
// root wrapper (configs/felhom-os-apply, MAX_WINDOW_S) and felhom-opsign (-ttl) hold the same 24 h.
|
||||||
|
const MaxWindow = 24 * time.Hour
|
||||||
|
|
||||||
// KeyRole tags a pinned operator key (doc 04 §3 two-key model).
|
// KeyRole tags a pinned operator key (doc 04 §3 two-key model).
|
||||||
type KeyRole string
|
type KeyRole string
|
||||||
|
|
||||||
@@ -150,6 +155,9 @@ func (v *Verifier) Verify(blob, sigArmored []byte) (*VerifiedOp, error) {
|
|||||||
if now.Before(op.IssuedAt.Add(-v.ClockSkew)) {
|
if now.Before(op.IssuedAt.Add(-v.ClockSkew)) {
|
||||||
return nil, fmt.Errorf("%w: issued_at=%s now=%s", ErrNotYetValid, op.IssuedAt, now)
|
return nil, fmt.Errorf("%w: issued_at=%s now=%s", ErrNotYetValid, op.IssuedAt, now)
|
||||||
}
|
}
|
||||||
|
if w := op.ExpiresAt.Sub(op.IssuedAt); w > MaxWindow {
|
||||||
|
return nil, fmt.Errorf("%w: issued_at=%s expires_at=%s (%s, the most is %s)", ErrWindowTooLong, op.IssuedAt, op.ExpiresAt, w, MaxWindow)
|
||||||
|
}
|
||||||
if now.After(op.ExpiresAt) {
|
if now.After(op.ExpiresAt) {
|
||||||
return nil, fmt.Errorf("%w: expires_at=%s now=%s", ErrExpired, op.ExpiresAt, now)
|
return nil, fmt.Errorf("%w: expires_at=%s now=%s", ErrExpired, op.ExpiresAt, now)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,26 @@ func TestVerify_RejectsPerStage(t *testing.T) {
|
|||||||
rejects(t, err, ErrTarget)
|
rejects(t, err, ErrTarget)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// R-1280: a window longer than 24 h is refused even while it is current; exactly 24 h is accepted.
|
||||||
|
// RED-PROOF: drop the MaxWindow check → the 24 h + 1 s case verifies.
|
||||||
|
t.Run("window longer than 24 h", func(t *testing.T) {
|
||||||
|
blob := canonicalBlob("guest_destroy", "demo-felhom", "9001", "op", "n-win-1", "{}",
|
||||||
|
refNow.Add(-time.Minute), refNow.Add(-time.Minute).Add(MaxWindow+time.Second))
|
||||||
|
sig := mintArmor(t, pub.Marshal(), Namespace, "sha512", blob, sign)
|
||||||
|
v := atRefNow(New(signerSet(pub, "op"), NewMemoryNonceStore(), "demo-felhom"))
|
||||||
|
_, err := v.Verify(blob, sig)
|
||||||
|
rejects(t, err, ErrWindowTooLong)
|
||||||
|
})
|
||||||
|
t.Run("window of exactly 24 h is accepted", func(t *testing.T) {
|
||||||
|
blob := canonicalBlob("guest_destroy", "demo-felhom", "9001", "op", "n-win-2", "{}",
|
||||||
|
refNow.Add(-time.Minute), refNow.Add(-time.Minute).Add(MaxWindow))
|
||||||
|
sig := mintArmor(t, pub.Marshal(), Namespace, "sha512", blob, sign)
|
||||||
|
v := atRefNow(New(signerSet(pub, "op"), NewMemoryNonceStore(), "demo-felhom"))
|
||||||
|
if _, err := v.Verify(blob, sig); err != nil {
|
||||||
|
t.Fatalf("a 24 h window must verify: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("expired", func(t *testing.T) {
|
t.Run("expired", func(t *testing.T) {
|
||||||
blob := canonicalBlob("guest_destroy", "demo-felhom", "9001", "op", "n-exp-1", "{}",
|
blob := canonicalBlob("guest_destroy", "demo-felhom", "9001", "op", "n-exp-1", "{}",
|
||||||
refNow.Add(-2*time.Hour), refNow.Add(-time.Hour))
|
refNow.Add(-2*time.Hour), refNow.Add(-time.Hour))
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -114,12 +115,30 @@ func (e ConfigUpdateExecutor) Execute(ctx context.Context, op string, params jso
|
|||||||
return fmt.Errorf("agent_config_update: failed (previous files restored): %s", wr.Failed)
|
return fmt.Errorf("agent_config_update: failed (previous files restored): %s", wr.Failed)
|
||||||
}
|
}
|
||||||
lg.Warn("osupdate: config bundle INSTALLED", "bundle", string(wr.Bundle), "pass_seconds", wr.PassSeconds)
|
lg.Warn("osupdate: config bundle INSTALLED", "bundle", string(wr.Bundle), "pass_seconds", wr.PassSeconds)
|
||||||
|
e.policyPass(ctx, lg, p.AgentVersion)
|
||||||
if e.AfterInstall != nil {
|
if e.AfterInstall != nil {
|
||||||
e.AfterInstall(ctx)
|
e.AfterInstall(ctx)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// policyPass (R-1285) asks the wrapper NOW on disk — after this install, the bundle's own — to apply the
|
||||||
|
// appliance-only files' policy (mode "bundle-policy"): on a BYO host it removes the SSH drop-in we wrote. The bundle run
|
||||||
|
// itself was planned by the wrapper that was on disk BEFORE it, which may predate the policy (0.164.0). Never fatal:
|
||||||
|
// the bundle is installed either way; an older wrapper refuses the unknown mode and the next bundle does it.
|
||||||
|
func (e ConfigUpdateExecutor) policyPass(ctx context.Context, lg *slog.Logger, ver string) {
|
||||||
|
runID := "bundle-policy-" + e.Leg.now().UTC().Format("20060102T150405Z")
|
||||||
|
wr, err := e.Leg.call(ctx, runID, map[string]any{"release_id": "bundle-policy-" + ver, "layer": LayerHost, "mode": "bundle-policy"})
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
lg.Warn("osupdate: the bundle policy pass did not run", "err", err)
|
||||||
|
case wr.refused():
|
||||||
|
lg.Info("osupdate: the bundle policy pass was refused (a wrapper before 0.166.0?) — the next bundle applies it", "refused", string(wr.Refused))
|
||||||
|
default:
|
||||||
|
lg.Info("osupdate: bundle policy pass done", "bundle_policy", string(wr.BundlePolicy))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (e ConfigUpdateExecutor) download(ctx context.Context, url, dest string) (string, error) {
|
func (e ConfigUpdateExecutor) download(ctx context.Context, url, dest string) (string, error) {
|
||||||
hc := e.HTTPClient
|
hc := e.HTTPClient
|
||||||
if hc == nil {
|
if hc == nil {
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ func (b *bundleWrapper) Run(_ context.Context, name string, args ...string) ([]b
|
|||||||
var plan map[string]any
|
var plan map[string]any
|
||||||
_ = json.Unmarshal(raw, &plan)
|
_ = json.Unmarshal(raw, &plan)
|
||||||
b.plans = append(b.plans, plan)
|
b.plans = append(b.plans, plan)
|
||||||
|
if plan["mode"] == "bundle-policy" { // R-1285: the pass after an install carries no bundle
|
||||||
|
return []byte(`OSAPPLY-REPORT {"mode":"bundle-policy","bundle_policy":{"install_mode":"byo","removed":[]}}` + "\n"), nil, nil
|
||||||
|
}
|
||||||
body, _ := os.ReadFile(plan["bundle"].(string))
|
body, _ := os.ReadFile(plan["bundle"].(string))
|
||||||
b.bodies = append(b.bodies, body)
|
b.bodies = append(b.bodies, body)
|
||||||
return []byte("OSAPPLY-REPORT " + b.rep + "\n"), nil, nil
|
return []byte("OSAPPLY-REPORT " + b.rep + "\n"), nil, nil
|
||||||
@@ -153,3 +156,27 @@ func TestBundleURL(t *testing.T) {
|
|||||||
func (b *bundleWrapper) RunStdin(ctx context.Context, _ io.Reader, name string, args ...string) ([]byte, []byte, error) {
|
func (b *bundleWrapper) RunStdin(ctx context.Context, _ io.Reader, name string, args ...string) ([]byte, []byte, error) {
|
||||||
return b.Run(ctx, name, args...)
|
return b.Run(ctx, name, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// R-1285: right after an install the agent asks the wrapper now on disk for the appliance-only policy pass — the bundle
|
||||||
|
// run itself was planned by the OLD wrapper (on a BYO host upgraded from 0.164.0 our SSH drop-in stayed one bundle
|
||||||
|
// longer). A refused or failed bundle runs no pass.
|
||||||
|
// RED-PROOF: drop the policyPass call → one plan only.
|
||||||
|
func TestR1285_AnInstalledBundleIsFollowedByThePolicyPass(t *testing.T) {
|
||||||
|
body := []byte(`{"format":1,"agent_version":"0.143.0","files":[]}`)
|
||||||
|
srv, sha := serveBundle(t, body)
|
||||||
|
w := &bundleWrapper{t: t, rep: `{"mode":"bundle","bundle":{"agent_version":"0.143.0","written":["/usr/local/sbin/felhom-os-apply"]}}`}
|
||||||
|
e, _ := bundleExec(t, w, srv.URL)
|
||||||
|
if err := e.Execute(signedCtx(), OpConfigUpdate, params("0.143.0", sha)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(w.plans) != 2 || w.plans[1]["mode"] != "bundle-policy" || w.plans[1]["layer"] != "host" || w.plans[1]["signed"] != nil {
|
||||||
|
t.Fatalf("after the install the agent must run the policy pass (host layer, no envelope): %v", w.plans)
|
||||||
|
}
|
||||||
|
|
||||||
|
w2 := &bundleWrapper{t: t, rep: `{"mode":"bundle","refused":{"code":"R3","reason":"x"}}`}
|
||||||
|
e2, _ := bundleExec(t, w2, srv.URL)
|
||||||
|
_ = e2.Execute(signedCtx(), OpConfigUpdate, params("0.143.0", sha))
|
||||||
|
if len(w2.plans) != 1 {
|
||||||
|
t.Fatalf("a refused bundle must run no policy pass: %v", w2.plans)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -123,7 +123,8 @@ type WrapperReport struct {
|
|||||||
Undo bool `json:"undo"`
|
Undo bool `json:"undo"`
|
||||||
LiveRestore json.RawMessage `json:"live_restore"`
|
LiveRestore json.RawMessage `json:"live_restore"`
|
||||||
Facts json.RawMessage `json:"facts"`
|
Facts json.RawMessage `json:"facts"`
|
||||||
Bundle json.RawMessage `json:"bundle"` // the config bundle's result (R-840, mode "bundle")
|
Bundle json.RawMessage `json:"bundle"` // the config bundle's result (R-840, mode "bundle")
|
||||||
|
BundlePolicy json.RawMessage `json:"bundle_policy"` // the appliance-only policy pass (R-1285, mode "bundle-policy")
|
||||||
// OOMCheck (R-528, `09` decision 157): the docker layer's memory-kill check, {result, oom_killed, oom_event,
|
// OOMCheck (R-528, `09` decision 157): the docker layer's memory-kill check, {result, oom_killed, oom_event,
|
||||||
// exit_code, image, detail}. Carried to the hub UNCHANGED; the agent never reads it.
|
// exit_code, image, detail}. Carried to the hub UNCHANGED; the agent never reads it.
|
||||||
OOMCheck json.RawMessage `json:"oom_check"`
|
OOMCheck json.RawMessage `json:"oom_check"`
|
||||||
|
|||||||
@@ -98,7 +98,8 @@ TAG_RE = re.compile(r"^v(\d+\.\d+\.\d+)$")
|
|||||||
# THE retention number, read from the one file that owns it. A check and the policy it enforces
|
# THE retention number, read from the one file that owns it. A check and the policy it enforces
|
||||||
# must read the same number from the same place, or they drift and the drift looks like a defect
|
# must read the same number from the same place, or they drift and the drift looks like a defect
|
||||||
# in something else — which is exactly what happened on 2026-08-08/09 (R-287).
|
# in something else — which is exactly what happened on 2026-08-08/09 (R-287).
|
||||||
RETENTION_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "retention-policy.json")
|
RETENTION_FILE = os.environ.get("FELHOM_RETENTION_POLICY") or \
|
||||||
|
os.path.join(os.path.dirname(os.path.abspath(__file__)), "retention-policy.json") # the env: decoy tests only
|
||||||
|
|
||||||
|
|
||||||
def retention_kept():
|
def retention_kept():
|
||||||
@@ -114,6 +115,21 @@ def retention_kept():
|
|||||||
raise ValueError("generic_versions_kept must be a positive int, got %r" % (n,))
|
raise ValueError("generic_versions_kept must be a positive int, got %r" % (n,))
|
||||||
return n
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def retention_pinned():
|
||||||
|
"""D50 = A: the versions kept besides the newest N — the hub's MinAgent floor and the day-0 agent. Copies of hub
|
||||||
|
state the vouching session writes into the policy file (CI holds no hub secret). Missing key = no pins."""
|
||||||
|
with open(RETENTION_FILE, encoding="utf-8") as fh:
|
||||||
|
pins = json.load(fh).get("pinned") or {}
|
||||||
|
if not isinstance(pins, dict):
|
||||||
|
raise ValueError("pinned must be an object, got %r" % (pins,))
|
||||||
|
out = {}
|
||||||
|
for name, v in pins.items():
|
||||||
|
if not isinstance(v, str) or not re.match(r"^\d+\.\d+\.\d+$", v):
|
||||||
|
raise ValueError("pinned %s must be X.Y.Z, got %r" % (name, v))
|
||||||
|
out[name] = v
|
||||||
|
return out
|
||||||
|
|
||||||
tried = []
|
tried = []
|
||||||
|
|
||||||
|
|
||||||
@@ -211,6 +227,7 @@ def main():
|
|||||||
all_versions = versions
|
all_versions = versions
|
||||||
try:
|
try:
|
||||||
keep = retention_kept()
|
keep = retention_kept()
|
||||||
|
pins = retention_pinned()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
inconclusive("cannot read the retention policy (%s): %s" % (RETENTION_FILE, e))
|
inconclusive("cannot read the retention policy (%s): %s" % (RETENTION_FILE, e))
|
||||||
|
|
||||||
@@ -218,10 +235,15 @@ def main():
|
|||||||
# lexically: "0.9.0" > "0.10.0" as strings, and that would silently drop the wrong end.
|
# lexically: "0.9.0" > "0.10.0" as strings, and that would silently drop the wrong end.
|
||||||
def _key(v):
|
def _key(v):
|
||||||
return tuple(int(x) for x in v.split("."))
|
return tuple(int(x) for x in v.split("."))
|
||||||
versions = sorted(all_versions, key=_key)[-keep:]
|
window = sorted(all_versions, key=_key)[-keep:]
|
||||||
|
# D50 = A: the pinned versions are asserted wherever they sit. A pin with no tag cannot be installed at all —
|
||||||
|
# a failure, never a skip.
|
||||||
|
unpinned_tags = sorted(("%s=%s" % (k, v)) for k, v in pins.items() if v not in all_versions)
|
||||||
|
versions = sorted(set(window) | set(v for v in pins.values() if v in all_versions), key=_key)
|
||||||
dropped = [v for v in all_versions if v not in versions]
|
dropped = [v for v in all_versions if v not in versions]
|
||||||
|
|
||||||
print(" %d released version(s); retention policy keeps the newest %d" % (len(all_versions), keep))
|
print(" %d released version(s); retention policy keeps the newest %d plus the pinned %s" % (
|
||||||
|
len(all_versions), keep, ", ".join("%s=%s" % kv for kv in sorted(pins.items())) or "(none)"))
|
||||||
print(" verifying: %s" % ", ".join(versions))
|
print(" verifying: %s" % ", ".join(versions))
|
||||||
if dropped:
|
if dropped:
|
||||||
# NEVER silent. A bounded check that does not say what it stopped covering is how a
|
# NEVER silent. A bounded check that does not say what it stopped covering is how a
|
||||||
@@ -232,6 +254,9 @@ def main():
|
|||||||
print(" their configs resolve; what is no longer asserted is the BINARY's presence.")
|
print(" their configs resolve; what is no longer asserted is the BINARY's presence.")
|
||||||
|
|
||||||
bad = []
|
bad = []
|
||||||
|
for pin in unpinned_tags:
|
||||||
|
bad.append((pin, ["the pinned version has NO git tag — nothing can install it (D50 = A)"]))
|
||||||
|
print(" FAIL pinned %s: no git tag v%s" % (pin, pin.split("=")[1]))
|
||||||
for v in versions:
|
for v in versions:
|
||||||
pkg_url = "%s/api/packages/%s/generic/%s/%s/%s" % (GITEA_BASE, OWNER, PKG, v, PKG)
|
pkg_url = "%s/api/packages/%s/generic/%s/%s/%s" % (GITEA_BASE, OWNER, PKG, v, PKG)
|
||||||
raw_url = "%s/%s/%s/raw/tag/v%s/%s" % (GITEA_BASE, OWNER, REPO, v, PROBE_CONFIG)
|
raw_url = "%s/%s/%s/raw/tag/v%s/%s" % (GITEA_BASE, OWNER, REPO, v, PROBE_CONFIG)
|
||||||
|
|||||||
@@ -1,41 +1,33 @@
|
|||||||
{
|
{
|
||||||
"_comment": [
|
"_comment": [
|
||||||
"THE retention number for published agent artifacts. One file, read by everything that",
|
"THE retention rule for published agent artifacts. One file, read by everything that depends on it, because a check",
|
||||||
"depends on it, because a check and the policy it enforces must read the same number from the",
|
"and the policy it enforces must read the same rule from the same place or they drift — and the drift looks like a",
|
||||||
"same place or they drift — and the drift looks like a defect in something else.",
|
"defect in something else (R-287, 2026-08-08/09; again R-1290, 2026-10-25).",
|
||||||
"",
|
"",
|
||||||
"WHAT WENT WRONG WITHOUT IT (2026-08-08/09). The registry stopped serving felhom-agent",
|
"D50 = A (operator ruling 2026-10-25, felhom.eu `09` §3 decision 262): the registry keeps the NEWEST 5 released agent",
|
||||||
"0.120.0 and older, while scripts/check-published-versions.py demanded that EVERY git tag",
|
"versions, PLUS the hub's MinAgent floor, PLUS the version the hub's day-0 artifact manifest names. The operator prunes",
|
||||||
"still be downloadable. Both rules are individually sensible; together they are impossible.",
|
"by hand to that rule (on 2026-10-25 he removed 0.159.0–0.160.0 and kept 0.158.1, the floor). CI asserts exactly these",
|
||||||
"CI went red at a commit whose own run had been green the day before, on a true finding that",
|
"and never more. Before D50 the number was 10 (the R-267 newest-10 prune, R-287/R-291).",
|
||||||
"no one could act on. The red will return at the next publish unless the two read one number.",
|
|
||||||
"",
|
"",
|
||||||
"HOW THE NUMBER WAS ARRIVED AT. generic_versions_kept is 10 because that is what the registry",
|
"THE PINS ARE COPIES OF HUB STATE, because CI cannot read the hub without a secret it must not hold. The session that",
|
||||||
"keeps: the deleter was ESTABLISHED on 2026-08-10 (R-287) — the newest-10 prune of generic packages",
|
"records an agent vouch (day0_agent) or declares a new MinAgent with a floor (hub_min_agent) updates `pinned` in the",
|
||||||
"run under R-267 (felhom-agent and felhom-golden generic held exactly 10 afterwards). Until then this",
|
"same commit (felhom-build-deploy skill, agent release). A pin that names a version with no tag is a FAILURE, never a",
|
||||||
"file called the 10 an observed state with no located ruling; that is superseded (corrected",
|
"skip — a pin nobody can install is the defect the pin exists to catch.",
|
||||||
"2026-10-05, R-291). Container packages are not pruned by that rule.",
|
|
||||||
"",
|
"",
|
||||||
"SO THIS FILE IS A FLOOR, NOT A LICENCE. It says: CI may assume nothing older than the newest",
|
"SO THIS FILE IS A FLOOR, NOT A LICENCE: it says what CI may assume is still downloadable. It does not authorise",
|
||||||
"N generic versions is still downloadable. It does NOT authorise deleting anything, and the",
|
"deleting anything else.",
|
||||||
"operator should confirm or replace the number — at which point this file changes and both",
|
|
||||||
"readers follow it in the same commit.",
|
|
||||||
"",
|
"",
|
||||||
"THE DEEPER BOUND, recorded so a future session does not have to re-derive it: the principled",
|
"NEVER retire a git TAG to satisfy this. felhom-host-install.sh fetches an agent's config files from",
|
||||||
"limit is the hub's vouched min_agent floor (0.127.0 on 2026-08-09). Nothing can install an",
|
"raw/tag/v<version>/configs/, so deleting a tag retires the ability to install that version at all."
|
||||||
"agent below it — the hub refuses to vouch one and boxes update to the floor — so a released",
|
|
||||||
"version below the floor being un-downloadable costs nothing real. Bounding on the floor would",
|
|
||||||
"be better than bounding on a count, and it needs the gate to read the hub, which is network",
|
|
||||||
"the gate does not have today. Filed as the follow-up in R-287.",
|
|
||||||
"",
|
|
||||||
"NEVER retire a git TAG to satisfy this. felhom-host-install.sh fetches an agent's config",
|
|
||||||
"files from raw/tag/v<version>/configs/, so deleting a tag retires the ability to install that",
|
|
||||||
"version at all — a strictly worse act than an un-downloadable binary."
|
|
||||||
],
|
],
|
||||||
"generic_versions_kept": 10,
|
"generic_versions_kept": 5,
|
||||||
"readers": [
|
"readers": [
|
||||||
"scripts/check-published-versions.py — bounds its assertion to the newest N versions"
|
"scripts/check-published-versions.py — asserts the newest N versions plus every pinned version"
|
||||||
],
|
],
|
||||||
"recorded": "2026-08-09",
|
"recorded": "2026-10-26",
|
||||||
"recorded_by": "CC 2026-08-09; the number's source (the R-267 newest-10 prune, established 2026-08-10 by R-287) recorded 2026-10-05 (R-291)"
|
"recorded_by": "CC 2026-10-26: day0_agent 0.166.0 (operator vouch 2026-10-26 10:04:55 CET); D50 = A (operator 2026-10-25); earlier history: R-287 (2026-08-09), R-291 (2026-10-05)",
|
||||||
|
"pinned": {
|
||||||
|
"hub_min_agent": "0.158.1",
|
||||||
|
"day0_agent": "0.166.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,13 +179,24 @@ def published_cases(base):
|
|||||||
TAGS = ["v0.150.%d" % i for i in range(3)] # inside any retention window >= 3
|
TAGS = ["v0.150.%d" % i for i in range(3)] # inside any retention window >= 3
|
||||||
VERS = [t[1:] for t in TAGS]
|
VERS = [t[1:] for t in TAGS]
|
||||||
|
|
||||||
|
# D50 = A: the gate also asserts the PINNED versions. The decoys run against their own policy file (the real pins
|
||||||
|
# name versions the fake Gitea does not hold); PINS is what each case writes there.
|
||||||
|
pol_dir = tempfile.mkdtemp(prefix="retention-decoy-")
|
||||||
|
pol = os.path.join(pol_dir, "retention-policy.json")
|
||||||
|
PINS = {}
|
||||||
|
|
||||||
def case(name, setup, expect_rc, must=()):
|
def case(name, setup, expect_rc, must=()):
|
||||||
FAKE.reset()
|
FAKE.reset()
|
||||||
FAKE.tags = list(TAGS)
|
FAKE.tags = list(TAGS)
|
||||||
FAKE.packages = set(VERS)
|
FAKE.packages = set(VERS)
|
||||||
FAKE.raw = set(VERS)
|
FAKE.raw = set(VERS)
|
||||||
|
PINS.clear()
|
||||||
setup()
|
setup()
|
||||||
rc, out = run([sys.executable, gate], ROOT, child_env(base))
|
with io.open(pol, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump({"generic_versions_kept": keep, "pinned": dict(PINS)}, fh)
|
||||||
|
env = child_env(base)
|
||||||
|
env["FELHOM_RETENTION_POLICY"] = pol
|
||||||
|
rc, out = run([sys.executable, gate], ROOT, env)
|
||||||
if not FAKE.hits:
|
if not FAKE.hits:
|
||||||
fails.append("published/%s: the gate never asked the fake Gitea - the seam is not wired" % name)
|
fails.append("published/%s: the gate never asked the fake Gitea - the seam is not wired" % name)
|
||||||
report("published: " + name, rc, out, expect_rc, must)
|
report("published: " + name, rc, out, expect_rc, must)
|
||||||
@@ -220,6 +231,25 @@ def published_cases(base):
|
|||||||
case("BY DESIGN: a version older than the retention window is not asserted", retired, 0,
|
case("BY DESIGN: a version older than the retention window is not asserted", retired, 0,
|
||||||
("NOT ASSERTED", "0.9.0"))
|
("NOT ASSERTED", "0.9.0"))
|
||||||
|
|
||||||
|
def pinned_old_ok():
|
||||||
|
FAKE.tags = ["v0.8.0"] + ["v0.10.%d" % i for i in range(keep)]
|
||||||
|
FAKE.packages = set(t[1:] for t in FAKE.tags)
|
||||||
|
FAKE.raw = set(t[1:] for t in FAKE.tags)
|
||||||
|
PINS["hub_min_agent"] = "0.8.0"
|
||||||
|
case("GENUINE (D50): a pinned version older than the window is asserted and downloads", pinned_old_ok, 0,
|
||||||
|
("verifying: 0.8.0", "ALL RELEASED VERSIONS INSTALLABLE"))
|
||||||
|
|
||||||
|
def pinned_old_gone():
|
||||||
|
pinned_old_ok()
|
||||||
|
FAKE.packages.discard("0.8.0")
|
||||||
|
case("FACT (D50): a pinned version older than the window whose package 404s", pinned_old_gone, 1,
|
||||||
|
("FAIL v0.8.0", "binary NOT downloadable"))
|
||||||
|
|
||||||
|
def pinned_no_tag():
|
||||||
|
PINS["day0_agent"] = "0.150.9"
|
||||||
|
case("FACT (D50): a pin that names a version with no tag", pinned_no_tag, 1,
|
||||||
|
("FAIL pinned day0_agent=0.150.9", "no git tag"))
|
||||||
|
|
||||||
def five_hundred():
|
def five_hundred():
|
||||||
FAKE.tags_status = 500
|
FAKE.tags_status = 500
|
||||||
case("INCONCLUSIVE: the tags api answers 500", five_hundred, 2, ("INCONCLUSIVE",))
|
case("INCONCLUSIVE: the tags api answers 500", five_hundred, 2, ("INCONCLUSIVE",))
|
||||||
|
|||||||
Reference in New Issue
Block a user