R-896: the volume-persistence gate refuses a host that is not a scratch host (no containers on DooPlex)
gates / gates (push) Successful in 7s
gates / gates (push) Successful in 7s
check() refuses (HARNESS REFUSED, exit 3) before any docker call unless /opt/upg exists (the bench) or FELHOM_SCRATCH_HOST=1. TestVenue red before the guard; new end-to-end decoy. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -1,3 +1,16 @@
|
||||
## 2026-10-10 (afternoon) — the volume-persistence gate refuses to run on a host that is not a scratch host (R-896)
|
||||
|
||||
**What runs on a box changed:** nothing — a gate script only.
|
||||
|
||||
- **`scripts/check-volume-persistence.py`** builds a canary image and runs every template on the LOCAL Docker. A full
|
||||
`catalog_gates.py` run on DooPlex (the production host) did exactly that on 2026-10-06. Now `check()` refuses —
|
||||
`HARNESS REFUSED`, exit 3, before any docker call — unless the host says it is a scratch host: the bench's harness
|
||||
directory `/opt/upg` exists, or `FELHOM_SCRATCH_HOST=1` is set. Fail closed: no marker, no containers.
|
||||
- Tests: `TestVenue` (3 cases; the no-marker case was RED before the guard — „the real prober was reached"), and a new
|
||||
end-to-end decoy in `test_gate_decoys.py` („not a scratch host", rc 3); the three existing end-to-end decoys now set
|
||||
the marker so they still reach the canary.
|
||||
- `CLAUDE.md`: the volume-persistence paragraph names the venue rule.
|
||||
|
||||
## 2026-10-10 (later) — Grocy added: household stock, shopping list and chores
|
||||
|
||||
**What runs on a box changed:** one new app is offered, `kamra.<domain>`. Nothing already installed
|
||||
|
||||
@@ -95,7 +95,9 @@ deployed `app.yaml` (customer secrets) is never overwritten. Full deploy details
|
||||
and minutes per app, so it is periodic like the resolvability gate — run it whenever a template's
|
||||
`volumes:` block or image tag changes, and at the start of every catalog campaign, **on a scratch
|
||||
host, never a customer box**. It refuses to report at all unless it has just re-proven itself in
|
||||
both directions against two canary templates. Fixture tests (no Docker):
|
||||
both directions against two canary templates — **and, since R-896 (2026-10-10), it refuses (exit 3) before any docker
|
||||
call unless the host says it is a scratch host: the bench's `/opt/upg` exists, or `FELHOM_SCRATCH_HOST=1` is set.**
|
||||
So a full `catalog_gates.py` run on DooPlex reports volume-persistence HARNESS REFUSED instead of acting there. Fixture tests (no Docker):
|
||||
`python3 scripts/test_check_volume_persistence.py`.
|
||||
**UNDETERMINED is exit 2 and is never a pass** — an app that wrote nothing has not been shown to
|
||||
be correct. Why it exists: papra mounted `papra_data:/app/data` while the app wrote its database
|
||||
|
||||
@@ -1,87 +1,11 @@
|
||||
# REPORT — 2026-10-10: Grocy and LubeLogger published; Monica stopped at the fit check
|
||||
# REPORT — 2026-10-10 (afternoon): the volume-persistence gate refuses a non-scratch host (R-896)
|
||||
|
||||
**What runs on a box changed:** two new apps are offered — `kamra.<domain>` (Grocy) and
|
||||
`garazs.<domain>` (LubeLogger). Nothing already installed is touched. **60 template directories, 58
|
||||
offered.** Records: `onboarding/grocy.md`, `onboarding/lubelogger.md`, every one of the 61 checks
|
||||
answered `done` or `n/a`, none `open`. Evidence:
|
||||
`felhom.eu/documentation/audits/new-apps-2026-10-10/`.
|
||||
Part of the register-shrink session (`felhom.eu/REPORT-register-shrink-2026-10-10.md`).
|
||||
|
||||
## The three apps
|
||||
|
||||
| app | result | what decided it |
|
||||
|---|---|---|
|
||||
| **Grocy** 4.7.1 | **published** | MIT, alive (3 releases in 2026), SQLite in one volume, ~30 MiB idle, Hungarian UI 91.6 % |
|
||||
| **LubeLogger** v1.7.3 | **published** | MIT, very alive (17 releases in 2026), LiteDB in one volume, ~60 MiB idle |
|
||||
| **Monica** | **stopped — checklist 0.2** | no release of any kind in 17 months, no stable release in 29, the `4.x` branch untouched since 2024-05-04, and an upstream notice that the hosted instance and its data go at the end of December 2026 before a rewrite. **The operator decides** (R-927) |
|
||||
|
||||
## The two faults the walk found, both fixed before publishing
|
||||
|
||||
**1. An `after_install` command may not contain `$`.** The controller runs every element of the command
|
||||
through Go's `os.Expand` and refuses one that names anything outside `env:`. Grocy's replacement has to
|
||||
use grocy's own `password_hash(..., PASSWORD_ARGON2ID)`, and PHP is the only interpreter in its image
|
||||
(measured: no python, no perl, no ruby, no node) — so the code can never be an argument. On 9202 the
|
||||
first attempt came back
|
||||
|
||||
after_install: [pw argv dsn db i t e s n q h] not declared in env or has no value — not run
|
||||
|
||||
and the app sat behind its install hold with `admin`/`admin` still in place. The code now lives in a
|
||||
file the compose entrypoint writes and the command passes only `${ADMIN_PASSWORD}`. **Written into
|
||||
`REUSE.md`'s `after_install` row as a trap** — mealie and dawarich avoid it only by language.
|
||||
|
||||
**2. Grocy could not be updated at all.** The image copies `config-dist.php` to
|
||||
`/config/data/config.php` *only if that file is absent*, so the file is written once and never follows
|
||||
the image. A volume written by 4.6.0 and started under 4.7.1 answered **HTTP 500 on every page** —
|
||||
`Configured AUTH_CLASS "Grocy\Middleware\DefaultAuthMiddleware" does not exist` — while its own log said
|
||||
the migrations had run and the init was done. The entrypoint now deletes that file at every start, so
|
||||
the image re-copies its current defaults; every setting a household needs is a `GROCY_*` environment
|
||||
variable and each person's own choices live in the database. **The edge failed before the fix and is
|
||||
`proven` after it, on both venues** — and the failure bought something: the product's own guarded Update
|
||||
was watched undoing a REAL failure (backing-up → pulling → copying → verifying → **undone** in 346 s,
|
||||
the app back on 4.6.0, the data read back), which is checklist 6.3 without manufacturing a fault.
|
||||
|
||||
## What LubeLogger ships as, and what we ship
|
||||
|
||||
`Middleware/Authen.cs` reads `EnableAuth` with a default of `false`, and `appsettings.json` ships it
|
||||
false. With it false the middleware **mints a ticket with the `IsRootUser` role for every request**.
|
||||
Measured on a default start: an anonymous stranger got 200 on `/`, on `/api/vehicles` and on
|
||||
`/Home/Settings`, and `POST /Vehicle/SaveVehicle` answered `{"success":true}` — the vehicle came back
|
||||
from the API. Our template's entrypoint exports `EnableAuth=true` and the SHA-256 of a generated name
|
||||
and password, so the app's own login is on **before its first byte**: measured from the container's
|
||||
creation, at t+1 s nothing was listening and at t+2 s `/api/vehicles` was already 401. No install
|
||||
window to close. Sign-up is closed by the app itself (`Invalid Token`).
|
||||
|
||||
## Catalogue conventions added
|
||||
|
||||
- **`REUSE.md` §2, a fifth healthcheck family** — bash `/dev/tcp`, for an image with no HTTP client at
|
||||
all. `ghcr.io/hargata/lubelogger` has no curl, wget, nc, python or node; it has bash. Measured in both
|
||||
directions (rc 0 against the app with an HTTP 200 line, rc 1 against a dead port) before it was
|
||||
written down, with its traps: it needs **bash**, not `sh`, and the connect must sit outside the
|
||||
pipeline or its failure is invisible.
|
||||
- **`REUSE.md` `after_install` row** — the `$` trap above, with the way out.
|
||||
|
||||
## The ladders
|
||||
|
||||
| app | step | bench | box 9202 |
|
||||
|---|---|---|---|
|
||||
| grocy | 4.6.0 → 4.7.1 | **proven** — soak 605 s, 10343 requests, peak anon 6.7 % of 384 M, 0 kills | **proven** — the product's guarded Update, done in 41 s |
|
||||
| lubelogger | v1.7.2 → v1.7.3 | **proven** — soak 605 s, 11988 requests, peak anon 12.3 % of 512 M, 0 kills | **proven** — done in 25.6 s |
|
||||
|
||||
Both written by `upgrade-test.py --write-ladder` from the two verdicts, never by hand.
|
||||
|
||||
## Tool fixes made on the way (no rows — fixed here, per the size rule)
|
||||
|
||||
- `scripts/check-onboarding.py` crashed with `UnicodeEncodeError` on a Windows console **while printing
|
||||
which ids were still open**, so an informative exit 1 arrived as a traceback. Guarded.
|
||||
- `scripts/upgrade-test.py` used `Path.read_text()`/`write_text()`, which take the platform encoding; on
|
||||
this workstation the ladder writer put a cp1250 em dash into a template full of Hungarian and the file
|
||||
stopped being valid UTF-8. All 26 calls now say `encoding="utf-8"`.
|
||||
- The same console trap in **nineteen** of `felhom.eu`'s gate scripts and in `repo_gates.py` itself,
|
||||
where it **aborted the whole runner at the first gate**. Guarded there too; `reuse-refs` went from a
|
||||
traceback to green. Red-proof for the claim that this was not my doing: `test_due_checks_gate.py`
|
||||
fails the same 5 of 42 with my change reverted.
|
||||
|
||||
## Gates
|
||||
|
||||
`python3 scripts/catalog_gates.py grocy lubelogger` — all twelve green, the two runtime ones included
|
||||
(`image-resolvable` and `volume-persistence`, run on bench 9401 where there is Docker):
|
||||
`felhom.eu/documentation/audits/new-apps-2026-10-10/box/*/gates.txt`.
|
||||
- **Change:** `check-volume-persistence.py` refuses (exit 3, `HARNESS REFUSED`) before any docker call unless the host
|
||||
is a scratch host (`/opt/upg` present, or `FELHOM_SCRATCH_HOST=1`). DooPlex has neither, so the gate can no longer act
|
||||
on the production host.
|
||||
- **Red-proof:** `TestVenue.test_no_marker_refuses_before_any_docker_call` failed before the guard („the real prober was
|
||||
reached"), passes after. Full suite 61/61; decoys incl. the new „not a scratch host" case all behave;
|
||||
`catalog_gates.py --fast` rc 0.
|
||||
- **What runs on a box changed:** nothing.
|
||||
|
||||
@@ -944,9 +944,33 @@ def collect_apps(root: Path, only=None, include_unavailable=False):
|
||||
# apart, so a summary can say whether the gate ran at all. Pinned by test_check_volume_persistence.py.
|
||||
HARNESS_REFUSED = 3
|
||||
|
||||
# R-896 (2026-10-10): the real prober builds a canary image and runs every template on the LOCAL Docker. Run on DooPlex
|
||||
# (the production host) it acted there — and its self-test failed for every app. So it runs only where the host says it
|
||||
# is a scratch host: the bench's layout directory, or an explicit FELHOM_SCRATCH_HOST=1 from the person who knows the box.
|
||||
# Fail closed: no marker → HARNESS REFUSED before any docker call. Pinned by TestVenue (test_check_volume_persistence.py).
|
||||
SCRATCH_ENV = "FELHOM_SCRATCH_HOST"
|
||||
SCRATCH_MARKERS = (Path("/opt/upg"),) # the bench guest's harness layout (upgrade-test.py ROOT)
|
||||
|
||||
|
||||
def venue_refusal() -> str:
|
||||
"""Empty when this host may run containers for the gate; otherwise the reason it may not."""
|
||||
if os.environ.get(SCRATCH_ENV) == "1":
|
||||
return ""
|
||||
if any(m.is_dir() for m in SCRATCH_MARKERS):
|
||||
return ""
|
||||
return ("this host is not a scratch host (no %s, %s is not 1) — the gate runs real containers on the local "
|
||||
"Docker and must not act on a production host (R-896). Run it on the bench, or set %s=1 on a scratch "
|
||||
"host." % (" / ".join(str(m) for m in SCRATCH_MARKERS), SCRATCH_ENV, SCRATCH_ENV))
|
||||
|
||||
|
||||
def check(root: Path, only=None, prober=docker_prober, include_unavailable=False,
|
||||
evidence: Path | None = None, skip_self_test=False) -> int:
|
||||
if prober is docker_prober:
|
||||
why = venue_refusal()
|
||||
if why:
|
||||
print(f"ERROR: {why}", file=sys.stderr)
|
||||
print("HARNESS REFUSED — not a scratch host; no container was started")
|
||||
return HARNESS_REFUSED
|
||||
apps, skipped = collect_apps(root, only, include_unavailable)
|
||||
if skipped:
|
||||
print(f"skipping {len(skipped)} app(s) not offered for new installs: {', '.join(skipped)}")
|
||||
|
||||
@@ -9,6 +9,7 @@ Run: python3 scripts/test_check_volume_persistence.py
|
||||
"""
|
||||
import importlib.util
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -597,5 +598,64 @@ class TestEmptyBind(unittest.TestCase):
|
||||
self.assertFalse(any("R-805" in w for w in why), why)
|
||||
|
||||
|
||||
class TestVenue(unittest.TestCase):
|
||||
"""R-896: the REAL prober runs containers on the local Docker. On a host that is not a scratch host (DooPlex is
|
||||
the production host) the gate refuses BEFORE any docker call — the consequence, not the helper: the prober is
|
||||
never reached."""
|
||||
|
||||
def setUp(self):
|
||||
self._env = os.environ.pop(cvp.SCRATCH_ENV, None)
|
||||
self._markers = cvp.SCRATCH_MARKERS
|
||||
self._real = cvp.docker_prober
|
||||
self.calls = []
|
||||
|
||||
def tearDown(self):
|
||||
if self._env is not None:
|
||||
os.environ[cvp.SCRATCH_ENV] = self._env
|
||||
else:
|
||||
os.environ.pop(cvp.SCRATCH_ENV, None)
|
||||
cvp.SCRATCH_MARKERS = self._markers
|
||||
|
||||
def _run(self):
|
||||
def spy(*a, **kw):
|
||||
self.calls.append(a)
|
||||
raise AssertionError("the real prober was reached")
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
d = Path(td) / "templates" / "demo"
|
||||
d.mkdir(parents=True)
|
||||
(d / "docker-compose.yml").write_text("services:\n s:\n image: alpine:3.22\n")
|
||||
(d / ".felhom.yml").write_text("slug: demo\n")
|
||||
orig_self_test = cvp.self_test
|
||||
cvp.self_test = lambda prober: spy()
|
||||
try:
|
||||
buf = io.StringIO()
|
||||
with redirect_stdout(buf):
|
||||
rc = cvp.check(Path(td)) # the default prober = the real one
|
||||
finally:
|
||||
cvp.self_test = orig_self_test
|
||||
return rc, buf.getvalue()
|
||||
|
||||
def test_no_marker_refuses_before_any_docker_call(self):
|
||||
with tempfile.TemporaryDirectory() as nowhere:
|
||||
cvp.SCRATCH_MARKERS = (Path(nowhere) / "absent",)
|
||||
rc, out = self._run()
|
||||
self.assertEqual(rc, cvp.HARNESS_REFUSED, out)
|
||||
self.assertIn("not a scratch host", out)
|
||||
self.assertEqual(self.calls, [])
|
||||
|
||||
def test_marker_directory_lets_it_run(self):
|
||||
with tempfile.TemporaryDirectory() as here:
|
||||
cvp.SCRATCH_MARKERS = (Path(here),)
|
||||
with self.assertRaises(AssertionError): # it went on to the self-test (the spy)
|
||||
self._run()
|
||||
|
||||
def test_explicit_env_lets_it_run(self):
|
||||
with tempfile.TemporaryDirectory() as nowhere:
|
||||
cvp.SCRATCH_MARKERS = (Path(nowhere) / "absent",)
|
||||
os.environ[cvp.SCRATCH_ENV] = "1"
|
||||
with self.assertRaises(AssertionError):
|
||||
self._run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
|
||||
@@ -876,7 +876,7 @@ def volume_persistence_cases():
|
||||
empty = os.path.join(ws, "nobin")
|
||||
os.makedirs(empty)
|
||||
|
||||
def run(name, expect_rc, must=(), path=stub, templates=True):
|
||||
def run(name, expect_rc, must=(), path=stub, templates=True, scratch=True):
|
||||
global ran
|
||||
cat = os.path.join(ws, "cat")
|
||||
shutil.rmtree(cat, ignore_errors=True)
|
||||
@@ -889,7 +889,7 @@ def volume_persistence_cases():
|
||||
"services:\n demo:\n image: example.org/demo:1.0\n volumes:\n - demo_data:/data\n"
|
||||
"volumes:\n demo_data:\n")
|
||||
r = subprocess.run([sys.executable, os.path.join(cat, "scripts", "check-volume-persistence.py")],
|
||||
cwd=cat, env={"PATH": path}, capture_output=True, text=True, input="", timeout=300)
|
||||
cwd=cat, env=dict({"PATH": path}, **({"FELHOM_SCRATCH_HOST": "1"} if scratch else {})), capture_output=True, text=True, input="", timeout=300)
|
||||
out = r.stdout + r.stderr
|
||||
ran += 1
|
||||
miss = [m for m in must if m not in out]
|
||||
@@ -902,6 +902,8 @@ def volume_persistence_cases():
|
||||
run("LABEL: a runtime that answers nothing", 3, ("HARNESS REFUSED", "failed its canary"))
|
||||
run("LABEL: no docker at all", 3, ("HARNESS REFUSED",), path=empty)
|
||||
run("LABEL: nothing to judge", 3, ("HARNESS REFUSED",), templates=False)
|
||||
# R-896: a host that does not say it is a scratch host (DooPlex) — refused before the canary is even built.
|
||||
run("LABEL: not a scratch host", 3, ("HARNESS REFUSED", "not a scratch host"), scratch=False)
|
||||
finally:
|
||||
shutil.rmtree(ws, ignore_errors=True)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user