1b24d139bd
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
194 lines
8.2 KiB
Python
194 lines
8.2 KiB
Python
#!/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:]))
|