R-758: mem_limit is the sum of the compose limits — eight figures corrected, gate mem-limit-sum (--fast, stdlib) with decoys
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:
@@ -25,6 +25,8 @@ Gates, in order (all must pass; **non-zero exit on any failure**):
|
||||
ladder entry whose digests the registry still serves (hook; skipped on CI)
|
||||
10. onboarding static, instant, whole repo — a NEW template directory carries a complete onboarding
|
||||
record (NEW-APP-CHECKLIST.md; the 53 apps published before 2026-10-01 are exempt by name)
|
||||
11. mem-limit-sum static, instant, whole repo — `.felhom.yml` resources.mem_limit equals the sum of every
|
||||
service's deploy.resources.limits.memory, and every service has one (R-758)
|
||||
4. engine-major static, needs GIT HISTORY — no database engine pin crosses a MAJOR version
|
||||
(operator ruling 2026-09-13; expires when Slice 4 / R-448 ships). Runs in the
|
||||
pre-push hook, which has the full clone; on a SHALLOW clone (CI fetches at
|
||||
@@ -117,6 +119,10 @@ GATES = [
|
||||
# 2026-10-02 (`09` §3 decisions 63/64): a family-gated template's exceptions are literal prefixes, it declares
|
||||
# min_controller >= 0.287.0, and the newest baked golden knows the gate. Static, files only.
|
||||
("family-gate", "check-family-gate.py", False, True, False),
|
||||
# R-758 (2026-10-05): `.felhom.yml` resources.mem_limit is the SUM of the compose limits (REUSE.md §2). Eight
|
||||
# templates were under it — the deploy screen and the box's overcommit warning read less than the app may take.
|
||||
# Static, stdlib only (no PyYAML on CI), so --fast: the hook and CI.
|
||||
("mem-limit-sum", "check-mem-limit-sum.py", True, True, False),
|
||||
]
|
||||
|
||||
# 3 (R-605): the gate's HARNESS refused to run — its canary failed or it had nothing to judge — so NO app was
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""check-mem-limit-sum.py — `.felhom.yml` `resources.mem_limit` must equal the SUM of the compose limits (R-758).
|
||||
|
||||
WHAT WENT WRONG. REUSE.md §2 says `mem_limit` = the sum of every service's
|
||||
`deploy.resources.limits.memory` (paperless: 768+256+128=1152M). Nothing checked it, and on 2026-10-01 the new-app
|
||||
checklist's gap page found eight templates UNDER their sum (calcom 768M declared, 1792M enforced). Docker enforces the
|
||||
compose limits, so no app was starved; what was wrong is the figure the deploy screen shows the household and the
|
||||
figure the box's overcommit warning adds up (controller `memoryVerdict`, deploy.go) — both read less than the app may
|
||||
take.
|
||||
|
||||
THE FACT, and the labels that are not it:
|
||||
* The fact is a service's `memory:` key UNDER `deploy: resources: limits:` (Compose enforces that), and the
|
||||
`mem_limit:` key UNDER `.felhom.yml`'s top-level `resources:`.
|
||||
* NOT the fact: a `memory:` under `reservations:` (not a limit — and REUSE forbids reservations anyway), a figure in a
|
||||
COMMENT (the compose header's "RAM: … (mem_limit: 384M)" line, the `.felhom.yml` arithmetic comment), a
|
||||
`mem_limit:` anywhere but under `resources:`, the per-step files under `steps/` (a superseded step's own
|
||||
definition, written by the ladder writer — not what a fresh install deploys).
|
||||
* A service with NO limit is refused too (REUSE.md §2: every service has one): the sum would be a lie of omission.
|
||||
|
||||
stdlib only, a line reader: the CI runner has no PyYAML (memory note "catalog CI has no PyYAML"), and a gate that
|
||||
needs it would have to degrade — this one never needs it.
|
||||
|
||||
USAGE
|
||||
python3 scripts/check-mem-limit-sum.py # every template directory
|
||||
python3 scripts/check-mem-limit-sum.py kimai calcom # only these
|
||||
python3 scripts/check-mem-limit-sum.py --root=<dir> # judge another checkout (the decoy suite)
|
||||
(`--all` is accepted and changes nothing: hidden and abandoned apps are always judged — a deployed one still runs.)
|
||||
Exit: 0 every template's mem_limit is its sum · 1 convicted · 2 inconclusive (a figure nobody can read).
|
||||
Decoys: scripts/test_gate_decoys.py `mem_sum_cases` (COVERS "mem-limit-sum").
|
||||
"""
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
SIZE_RE = re.compile(r"^\s*([0-9]+(?:\.[0-9]+)?)\s*([kKmMgG])(?:i?[bB])?\s*$")
|
||||
|
||||
|
||||
def to_mb(value):
|
||||
"""'384M' / '1G' / '1.5g' / '512Mi' -> MB (int, rounded); None when unreadable."""
|
||||
m = SIZE_RE.match(str(value).strip().strip('"').strip("'"))
|
||||
if not m:
|
||||
return None
|
||||
n, unit = float(m.group(1)), m.group(2).lower()
|
||||
return int(round(n / 1024.0 if unit == "k" else n * 1024 if unit == "g" else n))
|
||||
|
||||
|
||||
def _strip_comment(line):
|
||||
"""Drop a trailing `# comment` that is not inside quotes (good enough for these files' scalars)."""
|
||||
out, q = [], None
|
||||
for i, ch in enumerate(line):
|
||||
if q:
|
||||
if ch == q:
|
||||
q = None
|
||||
elif ch in ("'", '"'):
|
||||
q = ch
|
||||
elif ch == "#" and (i == 0 or line[i - 1] in " \t"):
|
||||
break
|
||||
out.append(ch)
|
||||
return "".join(out).rstrip()
|
||||
|
||||
|
||||
def _walk(text):
|
||||
"""Yield (path_of_keys, key, value) for every `key: value` / `key:` line, by indentation. List items and
|
||||
block scalars are skipped (no limit lives in one)."""
|
||||
stack = [] # [(indent, key)]
|
||||
block_indent = None # inside a `|` / `>` block scalar: skip lines deeper than this
|
||||
for raw in text.split("\n"):
|
||||
line = _strip_comment(raw)
|
||||
if not line.strip():
|
||||
continue
|
||||
indent = len(line) - len(line.lstrip(" "))
|
||||
if block_indent is not None:
|
||||
if indent > block_indent:
|
||||
continue
|
||||
block_indent = None
|
||||
s = line.strip()
|
||||
if s.startswith("- "):
|
||||
continue
|
||||
m = re.match(r"^([A-Za-z0-9_.\-]+):(?:\s+(.*))?$", s)
|
||||
if not m:
|
||||
continue
|
||||
while stack and stack[-1][0] >= indent:
|
||||
stack.pop()
|
||||
key, val = m.group(1), (m.group(2) or "").strip()
|
||||
yield [k for _i, k in stack], key, val
|
||||
if val in ("|", ">", "|-", ">-", "|+", ">+"):
|
||||
block_indent = indent
|
||||
elif val == "":
|
||||
stack.append((indent, key))
|
||||
|
||||
|
||||
def compose_limits(text):
|
||||
"""{service: limit_mb or None (no limit) or 'bad:<raw>' (unreadable)}."""
|
||||
services = {}
|
||||
for path, key, val in _walk(text):
|
||||
if path == ["services"]:
|
||||
services.setdefault(key, None)
|
||||
elif len(path) == 5 and path[0] == "services" and path[2:] == ["deploy", "resources", "limits"] \
|
||||
and key == "memory":
|
||||
mb = to_mb(val)
|
||||
services[path[1]] = mb if mb is not None else "bad:" + val
|
||||
return services
|
||||
|
||||
|
||||
def declared_limit(text):
|
||||
"""The top-level `resources: mem_limit:` -> (mb or None, raw)."""
|
||||
for path, key, val in _walk(text):
|
||||
if path == ["resources"] and key == "mem_limit":
|
||||
return to_mb(val), val
|
||||
return None, None
|
||||
|
||||
|
||||
def check_app(tdir, app):
|
||||
"""-> (verdict 0/1/2, message)."""
|
||||
d = os.path.join(tdir, app)
|
||||
try:
|
||||
compose = io.open(os.path.join(d, "docker-compose.yml"), encoding="utf-8").read()
|
||||
meta = io.open(os.path.join(d, ".felhom.yml"), encoding="utf-8").read()
|
||||
except (IOError, OSError) as e:
|
||||
return 2, "%s: unreadable template (%s)" % (app, e)
|
||||
lims = compose_limits(compose)
|
||||
if not lims:
|
||||
return 2, "%s: no services found in docker-compose.yml" % app
|
||||
bad = sorted(s for s, v in lims.items() if isinstance(v, str))
|
||||
if bad:
|
||||
return 2, "%s: a memory limit nobody can read on %s (%s)" % (app, ", ".join(bad),
|
||||
", ".join(lims[s][4:] for s in bad))
|
||||
missing = sorted(s for s, v in lims.items() if v is None)
|
||||
if missing:
|
||||
return 1, "%s: service(s) with NO deploy.resources.limits.memory: %s (REUSE.md §2: every service has one)" % (
|
||||
app, ", ".join(missing))
|
||||
total = sum(lims.values())
|
||||
ml, raw = declared_limit(meta)
|
||||
if raw is None:
|
||||
return 1, "%s: .felhom.yml declares no resources.mem_limit (the sum is %dM)" % (app, total)
|
||||
if ml is None:
|
||||
return 2, "%s: .felhom.yml mem_limit %r is not a size" % (app, raw)
|
||||
if ml != total:
|
||||
parts = "+".join("%d" % lims[s] for s in lims)
|
||||
return 1, "%s: .felhom.yml mem_limit %s is not the sum of the compose limits %s=%dM" % (app, raw, parts, total)
|
||||
return 0, "%s: %dM = the sum" % (app, total)
|
||||
|
||||
|
||||
def main(argv):
|
||||
root, apps = ROOT, []
|
||||
for a in argv:
|
||||
if a.startswith("--root="):
|
||||
root = a.split("=", 1)[1]
|
||||
elif a == "--all":
|
||||
continue # catalog_gates.py passes it for hidden/abandoned apps; this gate judges every directory anyway
|
||||
elif a.startswith("-"):
|
||||
print("unknown option: %s" % a)
|
||||
return 2
|
||||
else:
|
||||
apps.append(a)
|
||||
tdir = os.path.join(root, "templates")
|
||||
if not os.path.isdir(tdir):
|
||||
print("mem-limit-sum: no templates/ under %s" % root)
|
||||
return 2
|
||||
every = sorted(n for n in os.listdir(tdir) if os.path.isdir(os.path.join(tdir, n)))
|
||||
unknown = [a for a in apps if a not in every]
|
||||
if unknown:
|
||||
print("mem-limit-sum: no such template: %s" % ", ".join(unknown))
|
||||
return 2
|
||||
convicted, undecided = [], []
|
||||
for app in (apps or every):
|
||||
v, msg = check_app(tdir, app)
|
||||
if v == 1:
|
||||
convicted.append(msg)
|
||||
elif v == 2:
|
||||
undecided.append(msg)
|
||||
for m in convicted:
|
||||
print("REFUSED: " + m)
|
||||
for m in undecided:
|
||||
print("INCONCLUSIVE: " + m)
|
||||
n = len(apps or every)
|
||||
if convicted:
|
||||
print("mem-limit-sum: %d of %d template(s) refused — set .felhom.yml resources.mem_limit to the sum of the "
|
||||
"compose limits (and show the arithmetic in a comment, like paperless-ngx)" % (len(convicted), n))
|
||||
return 1
|
||||
if undecided:
|
||||
print("mem-limit-sum: INCONCLUSIVE on %d of %d template(s) — never a pass" % (len(undecided), n))
|
||||
return 2
|
||||
print("mem-limit-sum gate OK: %d template(s), every mem_limit is the sum of its compose limits" % n)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -64,7 +64,9 @@ class CatalogGatesFastTest(unittest.TestCase):
|
||||
# probe-measured joined; the test had been red on main (found by the more-night-apps session).
|
||||
# 11 since 2026-10-01: onboarding (NEW-APP-CHECKLIST.md), fast and history-free, so it runs in CI too.
|
||||
# 12 since family-gate joined — STALE AGAIN until 2026-10-05 (found by the burn-down, round 2).
|
||||
self.assertEqual(len(mod.GATES), 12)
|
||||
# 13 since 2026-10-05: mem-limit-sum (R-758), fast and history-free, so it runs in CI too.
|
||||
self.assertEqual(len(mod.GATES), 13)
|
||||
self.assertIn("mem-limit-sum", [g[0] for g in mod.GATES if g[3] and not g[4]])
|
||||
self.assertIn("onboarding", [g[0] for g in mod.GATES if g[3] and not g[4]])
|
||||
self.assertEqual([g[0] for g in mod.GATES if not g[3]], ["image-resolvable", "volume-persistence"])
|
||||
self.assertIn("test-record", [g[0] for g in mod.GATES if g[3] and not g[4]]) # runs in CI too
|
||||
|
||||
@@ -57,6 +57,7 @@ COVERS = {
|
||||
"probe-measured": "the measurement written in the TAGLINE or another comment block, not directly above setup_done_probe:; a date with no before/after; before/after with no date; 'read upstream' instead of 'measured' - vs a genuine measured comment (R-715)",
|
||||
"family-gate": "family_gate written only in a COMMENT (not gated, no min_controller owed); min_controller only in a comment; a golden DIRECTORY named 0.287.0 with no bake log (the mkdir shape, R-410); a sibling with no golden (stated NOT CHECKED, never a pass of rule 3) - vs the facts: an unanchorable exception (regex, '/', '..'), an exception list with no gate, min_controller below 0.287.0, the newest baked golden below 0.287.0; and a genuine family app passes (decisions 63/64, finding F1)",
|
||||
"onboarding": "a NEW template with no record; a record missing an id, or carrying it only inside an HTML comment; a `done` whose path does not exist, is an EMPTY directory (the mkdir shape, R-410) or names an absent sibling-repo file; an `n/a` with an empty or two-word reason; an `open` row; `opened:` backdated before the checklist; the template a new app copies lacking a new id - vs a complete record, an id added after `opened:`, and an exempt app's record with open rows (NEW-APP-CHECKLIST.md)",
|
||||
"mem-limit-sum": "the right figure only in a COMMENT (the compose header's 'mem_limit: 640M', the .felhom.yml arithmetic) while the field is wrong; a `memory:` under reservations: (not a limit) making the sum come out right; a `mem_limit:` outside resources:; a steps/ file with the old figure (not judged) - vs the facts: a field under the sum, a service with no limit, an unreadable size (R-758)",
|
||||
"copy-i18n": "Hungarian edited in a COMMENT/README/display_name (label, not copy) vs a real frozen string changed; an English block that is not English, is not matched to a Hungarian twin, or rewrites a credential (R-560). A retrieval promise REGISTERED in ALLOWLIST_EN passes only for its own app+path+sentence with a real reason; an entry for another app, a rewritten sentence, a stale entry or a two-word reason convicts (R-594). Also the DEGRADED mode CI actually runs — PyYAML shadowed out, freeze only (R-595)",
|
||||
}
|
||||
|
||||
@@ -686,6 +687,66 @@ def family_gate_cases():
|
||||
shutil.rmtree(ws, ignore_errors=True)
|
||||
|
||||
|
||||
def mem_sum_cases():
|
||||
"""check-mem-limit-sum.py reads FILES: templates/*/docker-compose.yml + .felhom.yml, via --root (R-758)."""
|
||||
global ran
|
||||
import tempfile
|
||||
ws = tempfile.mkdtemp(prefix="catalog-memsum-")
|
||||
try:
|
||||
COMPOSE = (
|
||||
"# demo - header\n# RAM: ~100M (mem_limit: 640M)\n"
|
||||
"services:\n"
|
||||
" demo:\n image: demo/demo:1.0\n container_name: demo\n environment:\n - X=1\n"
|
||||
" deploy:\n resources:\n limits:\n memory: 384M\n"
|
||||
" demo-db:\n image: postgres:16-alpine\n command: |\n memory: 9999M\n"
|
||||
" deploy:\n resources:\n limits:\n memory: 256M # the DB\n"
|
||||
"volumes:\n demo_data:\n")
|
||||
META = ('display_name: "Demo"\n# mem_limit: "999M" is not this line\n'
|
||||
'resources:\n mem_request: "100M"\n mem_limit: "640M" # 384+256\n pi_compatible: true\n')
|
||||
|
||||
def run(name, compose, meta, expect_rc, must=(), extra=None):
|
||||
global ran
|
||||
cat = os.path.join(ws, "cat")
|
||||
shutil.rmtree(cat, ignore_errors=True)
|
||||
d = os.path.join(cat, "templates", "demo")
|
||||
os.makedirs(d)
|
||||
io.open(os.path.join(d, "docker-compose.yml"), "w", encoding="utf-8").write(compose)
|
||||
io.open(os.path.join(d, ".felhom.yml"), "w", encoding="utf-8").write(meta)
|
||||
if extra:
|
||||
extra(d)
|
||||
r = sh([sys.executable, os.path.join(ROOT, "scripts", "check-mem-limit-sum.py"), "--root=" + cat], ROOT)
|
||||
out = r.stdout + r.stderr
|
||||
ran += 1
|
||||
ok = r.returncode == expect_rc and all(m in out for m in must)
|
||||
print(" %s %-70s rc=%d (expected %d)" % ("ok" if ok else "XX", name, r.returncode, expect_rc))
|
||||
if not ok:
|
||||
fails.append("%s: rc=%d expected %d; missing %s\n%s" % (
|
||||
name, r.returncode, expect_rc, [m for m in must if m not in out], out[-400:]))
|
||||
|
||||
def steps(d):
|
||||
os.makedirs(os.path.join(d, "steps"))
|
||||
io.open(os.path.join(d, "steps", "abc.felhom.yml"), "w").write('resources:\n mem_limit: "384M"\n')
|
||||
|
||||
print("\n-- mem-limit-sum: genuine and decoys")
|
||||
run("GENUINE: 384+256 = 640M, the field says 640M", COMPOSE, META, 0, ("mem-limit-sum gate OK",))
|
||||
run("GENUINE: 1G is 1024M (1G + 256M = 1280M)", COMPOSE.replace("memory: 384M", "memory: 1G"),
|
||||
META.replace('"640M"', '"1280M"'), 0, ("gate OK",))
|
||||
run("DECOY: a steps/ file with an old figure is not judged", COMPOSE, META, 0, ("gate OK",), extra=steps)
|
||||
print("-- mem-limit-sum: the facts (each MUST be refused)")
|
||||
run("FACT: the field under the sum; the right figure only in comments", COMPOSE,
|
||||
META.replace('mem_limit: "640M" # 384+256', 'mem_limit: "384M" # 384+256=640M'), 1, ("not the sum", "384+256=640M"))
|
||||
run("FACT: reservations: memory makes nothing a limit (service w/o limit)",
|
||||
COMPOSE.replace(" limits:\n memory: 256M", " reservations:\n memory: 256M"),
|
||||
META.replace('"640M"', '"384M"'), 1, ("NO deploy.resources.limits.memory", "demo-db"))
|
||||
run("FACT: mem_limit only OUTSIDE resources: (top level) is no declaration", COMPOSE,
|
||||
META.replace(' mem_limit: "640M" # 384+256\n', '').replace('display_name: "Demo"\n', 'display_name: "Demo"\nmem_limit: "640M"\n'),
|
||||
1, ("declares no resources.mem_limit",))
|
||||
run("FACT: an unreadable size is INCONCLUSIVE, never a pass", COMPOSE.replace("memory: 384M", "memory: lots"), META, 2,
|
||||
("INCONCLUSIVE",))
|
||||
finally:
|
||||
shutil.rmtree(ws, ignore_errors=True)
|
||||
|
||||
|
||||
def main():
|
||||
gate = os.path.join(ROOT, "scripts", "check-engine-major.py")
|
||||
if not os.path.isfile(gate):
|
||||
@@ -1197,6 +1258,7 @@ i18n:
|
||||
|
||||
onboarding_cases()
|
||||
family_gate_cases()
|
||||
mem_sum_cases()
|
||||
|
||||
if fails:
|
||||
print()
|
||||
|
||||
Reference in New Issue
Block a user