#!/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=
# 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:' (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:]))