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" +}