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