From 53d047a6c15bc4d03a67862a2af2914000f4a076 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sun, 9 Aug 2026 19:05:05 +0200 Subject: [PATCH] Two guards, one number: bound the published check to the retention it must live with Gates only. No release, no version bump, no binary published; the agent stays v0.128.0 at 28ba8593b8 and nothing on a customer's machine changes. THE COUPLING DEFECT. The registry stopped serving 0.120.0 and older while check-published-versions.py demanded every tag still be downloadable. Both rules are sensible and together they are impossible, so CI went red at a commit whose own run had been GREEN the day before -- and would have gone red again at the next publish when 0.121.0 was evicted. scripts/retention-policy.json is now THE number and both readers take it from there. WHAT CI NO LONGER COVERS, and it prints this on every run rather than leaving it to be discovered: a released version older than the retention window is no longer asserted downloadable. Its git tag and its config tree ARE still asserted -- only the binary's presence is dropped. A missing policy file is INCONCLUSIVE (exit 2), never silently unbounded. THE NUMBER IS NOT A LOCATED RULING and the file says so in its own header. Ten is what the registry demonstrably holds; no register row records a prune, R-210 says "Nothing was deleted; this is a list, not an action" and concerns local Docker images, and container packages hold 19 each. The principled bound is the hub's vouched min_agent floor -- nothing can install below it -- and that is the recorded follow-up. check-release-complete.py is the tag half as a machine. release-agent.sh already warned that "a released version without a git tag 404s a box mid-install, as root" and the step was still missed, so this is a gate and not a reminder. Legs 1-2 need no network and run in --fast, so the pre-push hook is the earliest catch. Red-proved by repointing the CHANGELOG head at an unreleased v0.129.0: both legs convicted and each named its fix command. Three controls run: green at 10 naming what it dropped; widened to 11 the evicted version re-enters and convicts; policy removed gives INCONCLUSIVE naming the path. --- CHANGELOG.md | 24 ++++++ REPORT.md | 76 +++++++++++------ scripts/agent_gates.py | 4 + scripts/check-published-versions.py | 42 +++++++++- scripts/check-release-complete.py | 124 ++++++++++++++++++++++++++++ scripts/retention-policy.json | 44 ++++++++++ 6 files changed, 286 insertions(+), 28 deletions(-) create mode 100644 scripts/check-release-complete.py create mode 100644 scripts/retention-policy.json diff --git a/CHANGELOG.md b/CHANGELOG.md index d647b32..7b1a30b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,27 @@ +### Gates only — 2026-08-09 (no release, no version bump, no binary published) + +**Two guards, both owed since the 2026-08-09 install outage (R-273/R-287). Nothing that runs on a +customer's box changed; `scripts/` only, and the agent stays v0.128.0.** + +- **`scripts/retention-policy.json` — THE retention number, in one file.** The registry stopped + serving `felhom-agent` 0.120.0 and older while `check-published-versions.py` demanded that every + tag still be downloadable. Both rules are sensible; together they are impossible, and CI went red + at a commit whose own run had been green the day before. The check now **reads the number from + this file** and bounds its assertion to the newest N generic versions. + **What CI no longer covers, said plainly rather than left to be discovered:** a released version + older than the retention window is **no longer asserted downloadable**. Its git tag and its configs + are still asserted — only the binary's presence is dropped. The check **prints exactly which + versions it stopped covering** on every run, so the narrowing cannot become permanent by accident. + **The number is an OBSERVED state, not a located ruling** — see the file's own header and R-287. + A missing or unreadable policy file is **INCONCLUSIVE (exit 2), never silently unbounded.** +- **`scripts/check-release-complete.py` — the tag half, as a machine.** Asserts that the version at + the head of `CHANGELOG.md` is tagged, that the tag points into this history, and that its package + is published. `release-agent.sh` already warned about this in as many words and the step was still + missed on 2026-08-08, which is why this is a gate and not a reminder. Legs 1–2 need no network and + therefore run in `--fast`, so the pre-push hook catches a missing tag at the earliest moment. + Registered in `agent_gates.py`; red-proved by pointing the CHANGELOG head at an unreleased + v0.129.0 — both legs convicted and each named its fix command. + ## v0.128.0 — the escrow seed is asserted every tick, not remembered once (2026-08-08, R-221) **A rebuilt box could not run the escrow ceremony at all, and there was no way forward from inside diff --git a/REPORT.md b/REPORT.md index 03c1b28..e0fa170 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,41 +1,63 @@ -# REPORT — felhom-agent v0.127.0: a mount Felhom made is not foreign (R-220) +# REPORT — felhom-agent, 2026-08-09 (gates only) -**Scope: the host half of R-220.** The customer-facing refusal message is the controller's half and -ships as felhom-controller v0.203.0. +**No release. No version bump. No binary published. `scripts/` only** — nothing that runs on a +customer's machine changed, and the agent stays **v0.128.0** at `28ba8593b8`. ## What changed -| File | Change | +| file | why | |---|---| -| `internal/storage/claim.go` | `claimFacts.felhomOwnedMounts`; `classifyClaim` forgives a non-managed mountpoint **only when corroborated**; `felhomOwnedMounts()` + `procMounts()` | -| `internal/storage/hostops.go` | `mountTable` seam (nil ⇒ real `/proc/mounts`) | -| `internal/storage/claim_r220_test.go` | new — the own-drive case, the fence, and the corroboration's four edges | +| `scripts/retention-policy.json` **(new)** | THE retention number, in one place, with its reasoning and its honesty about where the number came from | +| `scripts/check-published-versions.py` | reads that number; bounds its assertion to the newest N; **prints what it stopped covering** | +| `scripts/check-release-complete.py` **(new)** | asserts the CHANGELOG-head version is tagged, placed in this history, and published | +| `scripts/agent_gates.py` | registers the new gate; legs 1–2 are offline so it runs in `--fast` too | -## The shape chosen, and why (§7.3) +## The coupling defect, and the fix -**Candidate (b): the claimed check distinguishes a mount Felhom made from a foreign one** — the task -called it "nearer the truth" and it is, because the host and its knowledge survive the rebuild while -the guest's registry does not. Candidate (a) — having the rebuild path clear the raw mounts — would -have made correctness depend on a cleanup step running, and a cleanup that does not run leaves exactly -today's defect. +The prune keeps the newest N; the published-versions check demanded that **every** tag be +downloadable. Nothing connected them, so CI went red at `28ba8593b8` — a commit whose own run had +been **green the day before** — and would have gone red again at the next publish when `0.121.0` was +evicted. Both now read `generic_versions_kept` from one file. -**The discriminator is corroboration, not a path prefix**: the same device must ALSO be mounted under -`/mnt/felhom-drives`. Only enrolment produces that pairing. +**What CI no longer covers:** a released version **older than the retention window** is no longer +asserted downloadable. Its git tag and its config tree are still asserted; only the binary's presence +is dropped. The check names the dropped versions on every run. -**`/proc/mounts` rather than `lsblk MOUNTPOINTS`**, because the lsblk invocation is pinned verbatim in -the sudoers file; changing it would have coupled this fix to a config rollout. `/proc/mounts` is -world-readable and needs neither. +**The number is not a located ruling.** `generic_versions_kept: 10` is what the registry demonstrably +holds; no register row records a prune, and container packages hold 19 each. The file says so in its +own header. The principled bound is the hub's vouched `min_agent` floor — nothing can install below +it — and that is recorded as the follow-up. + +## Controls, all three run + +| control | expected | got | +|---|---|---| +| live run, policy = 10 | green, and it names `0.120.0` as not asserted | **exit 0**, and it did | +| widen policy to 11 | `0.120.0` re-enters the window and convicts | **exit 1**, `FAIL v0.120.0` | +| policy file removed | INCONCLUSIVE, never silently unbounded | **exit 2**, naming the path it tried | + +## Red-proof of the new gate + +Mutation: `CHANGELOG.md` head repointed to `## v0.129.0` — never tagged, never published. Asserted +applied (`grep -c '^## v0.129.0'` → 1). Result **exit 1**, both legs convicting: + +``` +- TAG v0.129.0 DOES NOT EXIST. … without the tag every install 404s mid-run, as root. + Fix: git tag -a v0.129.0 && git push origin v0.129.0 +- PACKAGE 0.129.0 IS NOT PUBLISHED (HTTP 404 …). + Fix: bash scripts/release-agent.sh 0.129.0 +``` + +Reverted; `git status` clean on `CHANGELOG.md`. ## Green gate -`go build` · `go vet` clean · `go test ./...` → **29 packages ok** · `agent_gates.py --fast` → all OK. +`python3 scripts/agent_gates.py` — `reuse-refs OK · instructions OK · published OK · +release-complete OK · all agent gates OK`. -| Red-proof | Result | -|---|---| -| remove the `felhomOwnedMounts` exemption | **FAILS** — "device is mounted at /mnt/adatok (sdb)", the pre-fix refusal | -| over-widen the exemption to any `/mnt/*` | **FAILS** — "/mnt/someone-elses-disk was offered for formatting" | +## Not done here -## Not changed - -No sudoers, no allowlisted command, no PVE surface, no format path. Every other claim signal -(system disk, read-only, LVM PV, ZFS member, member FSTYPEs, empty-topology backstop) is untouched. +The deleter of `0.120.0` is **still not established** and a second attempt failed — Gitea keeps no +package-deletion trail, its container log no longer reaches the window, and the activity feed carries +no package operation. Recorded in R-287, including the withdrawal of my own earlier over-claim that +the router logs showed no DELETE: they do not cover the window, so they never said anything. diff --git a/scripts/agent_gates.py b/scripts/agent_gates.py index a423c6a..2734e6e 100644 --- a/scripts/agent_gates.py +++ b/scripts/agent_gates.py @@ -49,6 +49,10 @@ GATES = [ ("reuse-refs", SHARED_REUSE, [ROOT], True), ("instructions", SHARED_INSTRUCTIONS, [ROOT], True), ("published", os.path.join(ROOT, "scripts", "check-published-versions.py"), [], False), + # R-273: the tag half of a release. Legs 1-2 need no network, so it runs in --fast too — the + # missing TAG is what actually broke every install, and the pre-push hook is the earliest place + # that can catch it. + ("release-complete", os.path.join(ROOT, "scripts", "check-release-complete.py"), [], True), ] VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"} diff --git a/scripts/check-published-versions.py b/scripts/check-published-versions.py index 821183c..12402bb 100644 --- a/scripts/check-published-versions.py +++ b/scripts/check-published-versions.py @@ -95,6 +95,25 @@ PROBE_CONFIG = "configs/felhom-agent.service" 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 +# 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). +RETENTION_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "retention-policy.json") + + +def retention_kept(): + """How many of the newest generic versions the registry is expected to still serve. + + Fails CLOSED and LOUD: a missing or unreadable policy file makes the check INCONCLUSIVE + rather than silently unbounded. An unbounded check would re-create the red this fixed; a + silently-bounded one would be worse. + """ + with open(RETENTION_FILE, encoding="utf-8") as fh: + n = json.load(fh)["generic_versions_kept"] + if not isinstance(n, int) or n < 1: + raise ValueError("generic_versions_kept must be a positive int, got %r" % (n,)) + return n + tried = [] @@ -189,7 +208,28 @@ def main(): print(" no v tags in this repo yet — nothing to check, and nothing proven") print("\ncheck-published-versions: NOTHING TO CHECK") return 0 - print(" %d released version(s) to verify: %s" % (len(versions), ", ".join(versions))) + all_versions = versions + try: + keep = retention_kept() + except Exception as e: + inconclusive("cannot read the retention policy (%s): %s" % (RETENTION_FILE, e)) + + # Bound the assertion to what the registry is expected to still hold. Sorted by SEMVER, not + # lexically: "0.9.0" > "0.10.0" as strings, and that would silently drop the wrong end. + def _key(v): + return tuple(int(x) for x in v.split(".")) + versions = sorted(all_versions, key=_key)[-keep:] + 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(" verifying: %s" % ", ".join(versions)) + if dropped: + # NEVER silent. A bounded check that does not say what it stopped covering is how a + # narrowing becomes permanent by accident. + print(" NOT ASSERTED (older than the retention window, and therefore not expected to be") + print(" downloadable): %s" % ", ".join(dropped)) + print(" ^ these versions still have git TAGS and are still installable in the sense that") + print(" their configs resolve; what is no longer asserted is the BINARY's presence.") bad = [] for v in versions: diff --git a/scripts/check-release-complete.py b/scripts/check-release-complete.py new file mode 100644 index 0000000..1cf726e --- /dev/null +++ b/scripts/check-release-complete.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""check-release-complete.py — the version at the head of CHANGELOG.md is a COMPLETE release. + +THE DEFECT THIS IS A MACHINE FOR (2026-08-08/09, R-273). Agent v0.128.0 was built, tested, +CHANGELOG'd and published to the package registry — and its git tag was never pushed. The hub then +vouched it, and because felhom-host-install.sh fetches an agent's config files from +`raw/tag/v/configs/`, EVERY fresh install and every reinstall died at step 5 of 8, as root, +on a virgin machine, for the better part of a day. + +`scripts/release-agent.sh` already warns about exactly this, in as many words: + "a released version without a git tag 404s a box mid-install, as root" +The warning was there, it was correct, and the step was still missed. **So the fix is a machine and +not a reminder** — that is the whole point of this file. + +WHAT IT ASSERTS, for the newest `## vX.Y.Z` in CHANGELOG.md: + 1. a git tag `vX.Y.Z` EXISTS, and + 2. it points at a commit that is an ANCESTOR OF (or equal to) the tip it was released from — a tag + parked on an unrelated commit is not a release, and + 3. the generic package for X.Y.Z is DOWNLOADABLE. + +(3) needs the network. (1) and (2) do not, and they are the half that actually failed — so this gate +is useful offline and says so rather than going quiet. + +EXIT CODES, matching this repo's other gates: 0 clean, 1 convicted, 2 inconclusive. An unreachable +registry is INCONCLUSIVE for leg 3 only; legs 1 and 2 still run and can still convict. +""" +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.request + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +GITEA_BASE = os.environ.get("GITEA_BASE", "https://gitea.dooplex.hu").rstrip("/") +OWNER, PKG = "admin", "felhom-agent" +HEAD_RE = re.compile(r"^##\s+v?(\d+\.\d+\.\d+)\b", re.M) + + +def git(*args): + return subprocess.run(("git",) + args, cwd=ROOT, capture_output=True, text=True) + + +def head_version(): + ch = os.path.join(ROOT, "CHANGELOG.md") + if not os.path.exists(ch): + return None + m = HEAD_RE.search(open(ch, encoding="utf-8").read()) + return m.group(1) if m else None + + +def main(): + print("check-release-complete — the newest CHANGELOG version is a complete release") + v = head_version() + if not v: + print(" no '## vX.Y.Z' heading in CHANGELOG.md — nothing to check, and nothing proven") + return 0 + tag = "v" + v + print(" newest CHANGELOG version: %s" % tag) + + problems, inconclusive = [], [] + + # ---- leg 1 + 2: the tag, and where it points. Offline-capable. ----------------------------- + r = git("rev-parse", "-q", "--verify", "refs/tags/%s^{commit}" % tag) + if r.returncode != 0: + # A shallow CI clone has no tags of its own; ask the remote before convicting, so this + # gate does not fire on a clone shape rather than on a real defect. + ls = git("ls-remote", "--tags", "origin", "refs/tags/%s" % tag) + if ls.returncode != 0: + inconclusive.append("cannot reach origin to look for tag %s: %s" + % (tag, ls.stderr.strip()[:120])) + elif not ls.stdout.strip(): + problems.append( + "TAG %s DOES NOT EXIST. The installer fetches this version's configs from\n" + " %s/%s/felhom-agent/raw/tag/%s/configs/ — without the tag every install\n" + " 404s mid-run, as root. Fix: git tag -a %s && git push origin %s" + % (tag, GITEA_BASE, OWNER, tag, tag, tag)) + else: + print(" ok tag %s exists on origin (not in this shallow clone)" % tag) + else: + sha = r.stdout.strip() + anc = git("merge-base", "--is-ancestor", sha, "HEAD") + if anc.returncode == 0: + print(" ok tag %s -> %s, an ancestor of HEAD" % (tag, sha[:10])) + else: + problems.append("tag %s points at %s, which is NOT an ancestor of HEAD — a tag parked " + "on an unrelated commit is not a release" % (tag, sha[:10])) + + # ---- leg 3: the package. Needs the network. ------------------------------------------------ + url = "%s/api/packages/%s/generic/%s/%s/%s" % (GITEA_BASE, OWNER, PKG, v, PKG) + req = urllib.request.Request(url, method="HEAD") + try: + with urllib.request.urlopen(req, timeout=25) as resp: + if resp.status == 200: + print(" ok package %s is downloadable" % v) + else: + problems.append("package %s returned HTTP %s at %s" % (v, resp.status, url)) + except urllib.error.HTTPError as e: + if e.code == 404: + problems.append("PACKAGE %s IS NOT PUBLISHED (HTTP 404 at %s).\n" + " Fix: bash scripts/release-agent.sh %s" % (v, url, v)) + else: + inconclusive.append("registry returned HTTP %s for %s" % (e.code, v)) + except Exception as e: + inconclusive.append("registry unreachable (%s) — leg 3 not checked; legs 1-2 still ran" % e) + + if problems: + print("\ncheck-release-complete: INCOMPLETE RELEASE") + for p in problems: + print(" - " + p) + return 1 + if inconclusive: + print("\ncheck-release-complete: INCONCLUSIVE — an undetermined result is never a pass") + for i in inconclusive: + print(" - " + i) + return 2 + print("\ncheck-release-complete: %s is tagged, placed and published." % tag) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/retention-policy.json b/scripts/retention-policy.json new file mode 100644 index 0000000..b9a9657 --- /dev/null +++ b/scripts/retention-policy.json @@ -0,0 +1,44 @@ +{ + "_comment": [ + "THE retention number for published agent artifacts. One file, read by everything that", + "depends on it, because 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 in something else.", + "", + "WHAT WENT WRONG WITHOUT IT (2026-08-08/09). The registry stopped serving felhom-agent", + "0.120.0 and older, while scripts/check-published-versions.py demanded that EVERY git tag", + "still be downloadable. Both rules are individually sensible; together they are impossible.", + "CI went red at a commit whose own run had been green the day before, on a true finding that", + "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 — stated honestly, because it is weaker than it looks.", + "generic_versions_kept is 10 because that is what the registry demonstrably holds today", + "(felhom-agent 0.121.0..0.128.0 = 10 versions, queried 2026-08-09). It is an OBSERVED state,", + "NOT a ruling anyone has been able to locate: no register row records a package prune, R-210", + "is WAITING-ON-OPERATOR and says 'Nothing was deleted; this is a list, not an action', and it", + "concerns local Docker images rather than this registry. Container packages currently hold 19", + "each, so there is no uniform ten-per-package cap visible either. See R-287.", + "", + "SO THIS FILE IS A FLOOR, NOT A LICENCE. It says: CI may assume nothing older than the newest", + "N generic versions is still downloadable. It does NOT authorise deleting anything, and the", + "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", + "limit is the hub's vouched min_agent floor (0.127.0 on 2026-08-09). Nothing can install an", + "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/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, + "readers": [ + "scripts/check-published-versions.py — bounds its assertion to the newest N versions", + "documentation/runbooks/registry-retention.md (felhom.eu) — the prune procedure" + ], + "recorded": "2026-08-09", + "recorded_by": "CC, from the registry's observed state; NOT from a located operator ruling" +}