steps/ per ladder step: gate rule 4, writer, backfill (8); R-653, R-656; decoy suite reads live pins (R-663)
gates / gates (push) Successful in 2s

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:
2026-09-24 07:57:51 +02:00
parent 585a7cba22
commit 5ed599cd5f
16 changed files with 1150 additions and 22 deletions
+23
View File
@@ -1,3 +1,26 @@
## Every step keeps its own definition — the box can climb one step at a time (2026-09-24, `09` §6.4 part 5, R-653, R-656, R-663)
**No `image:` line moved.** The box half ships in controller v0.268.0.
- **`steps/<step_key(to)>.yml`** — every `update_ladder:` entry but the newest now carries its own complete
compose file. `ladder.step_key` is sha256 of `to` as canonical JSON, first 16 hex; the controller computes
the same string (`stacks.StepKey`, pinned by one shared value).
- **Gate:** `check-test-record.py` rule 4 — an intermediate step with no file, or a file whose own `image:`
lines are not that step's `to`, is refused. Decoys: no file; the right NAME naming the head's image; the
definition under another name (red-proof: rule removed → all three pass wrongly).
- **Writer:** `upgrade-test.py --write-ladder` keeps the superseded head's compose — fixes included — as its
step file when it appends the next entry (red-proof in `test_ladder_writer.py`).
- **Backfill** (`scripts/steps_backfill.py`, one-off): 8 step files for the 7 apps with more than one entry
(emby, ghost, immich, n8n, navidrome, nextcloud, romm ×2), each from the NEWEST commit whose compose names
that step's images — romm's first step comes from `f4eb94f` (the working 768M / two-worker template), not
from `15f9ebf` (the move that OOM-looped).
- **R-653:** the memory watch's load is `reached` only when at least half its requests got an HTTP answer;
otherwise the edge is `inconclusive`, never `proven`.
- **R-656:** the bench clears the app's own scratch drive folders before FROM and says so (never a bare root,
never outside the scratch roots).
- **R-663:** `test_gate_decoys.py` and `test_ladder_writer.py` had been red since the night of 2026-09-23
(kimai-db and navidrome moved under literals); they now READ the live pins. Suite: 84 cases OK.
## wishlist fits its first boot; uptime-kuma no longer parks on its database wizard (2026-09-23 night, R-612, R-613)
**No `image:` line moved.** Two template fixes, each red-proofed on scratch guest 9202 through the product.
+15
View File
@@ -19,6 +19,9 @@ WHAT IT CHECKS, per template that carries `update_ladder:` (format and field rul
3. the NEWEST entry's `to` is EXACTLY the compose's current image per service. This is the fact
that makes the rule hold without history: a compose moved without a new entry no longer
matches its ladder's head, whoever pushed it and however.
4. every entry but the newest carries its OWN definition at `steps/<step_key(to)>.yml`, whose
images per service are EXACTLY that entry's `to` (`09` §6.4 part 5: the box pins that file, one
step per press). The name alone is not the fact: the file's own `image:` lines are read.
A template WITHOUT a ladder passes here — it has never been moved since the gate existed, and its
first move is refused by the twin unless that move brings the first entry.
@@ -50,6 +53,18 @@ def check_app(app_dir):
for i in range(1, len(entries)):
if entries[i].get("from") != entries[i - 1].get("to"):
problems.append("entry %d's `from` is not entry %d's `to` — the ladder has a gap" % (i + 1, i))
for i, e in enumerate(entries[:-1]):
to = e.get("to")
if not isinstance(to, dict) or not to:
continue # already convicted by check_entry
sp = os.path.join(app_dir, ladder.step_file(to))
if not os.path.isfile(sp):
problems.append("entry %d of %d has no definition at %s — the box climbs one step at a time "
"and needs this step's own compose file" % (i + 1, len(entries), ladder.step_file(to)))
continue
got = ladder.images_in(open(sp, encoding="utf-8").read())
if got != to:
problems.append("%s names %s, but entry %d's `to` is %s" % (ladder.step_file(to), got, i + 1, to))
if entries:
current = ladder.images_in(open(comp, encoding="utf-8").read())
head = entries[-1].get("to")
+19
View File
@@ -30,8 +30,16 @@ AN ENTRY (all keys required unless marked):
backfilled (optional) "YYYY-MM-DD" — written by the backfill from an EXISTING record,
never by a new test; a new move may not carry it
STEP DEFINITIONS (`09` §6.4 part 5, controller v0.268.0): every entry but the NEWEST carries its own
complete compose file at `templates/<app>/steps/<step_key(to)>.yml` — the box climbs one step at a time
and pins exactly that file; the newest step's definition is the template's `docker-compose.yml`. The box's
catalog clone is `--depth 1`, so git history is not a place a box can read a step from, and the commit
that moved an image is not always the definition that works (romm 15f9ebf). `step_key` is computed the
SAME way by the controller (`stacks.StepKey`, pinned by TestLadder_StepKeyMatchesTheCatalog).
Every path that reads or writes the format is here, so the gate and the writer cannot disagree.
"""
import hashlib
import json
import re
@@ -191,3 +199,14 @@ def append_entry(felhom_text, e):
last = j
lines.insert(last + 1, line)
return "\n".join(lines) + "\n"
def step_key(to):
"""The 16-hex name of a step's definition: sha256 of `to` as canonical JSON (keys sorted, no spaces).
The controller computes the same string (stacks.StepKey)."""
return hashlib.sha256(json.dumps(to, sort_keys=True, separators=(",", ":")).encode()).hexdigest()[:16]
def step_file(to):
"""The step definition's path RELATIVE to the template directory."""
return "steps/%s.yml" % step_key(to)
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""steps_backfill.py — ONE-OFF (2026-09-24, `09` §6.4 part 5): write `steps/<step_key(to)>.yml` for every
ladder entry that is not the newest, from git history.
WHICH COMMIT, and why not "the commit of the step": the definition a box must pin for a step is the one
the catalog SERVED while that step was the head — the NEWEST commit whose compose images equal the
step's `to`, i.e. the step's images WITH every fix that flowed after they moved. The commit that MOVED
the image can be the broken one: romm's 15f9ebf (5.0.0 -> 5.3.0, 512M, four workers) OOM-looped on
demo-hp; the working definition is the same images under f4eb94f's template (768M, two workers).
Prints one line per step: app, entry, step key, the commit it came from. Never overwrites a step file.
"""
import os
import subprocess
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import ladder # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def git(*a):
return subprocess.run(["git", "-C", ROOT] + list(a), capture_output=True, text=True, check=True).stdout
def main():
wrote = 0
for app in sorted(os.listdir(os.path.join(ROOT, "templates"))):
d = os.path.join(ROOT, "templates", app)
fy = os.path.join(d, ".felhom.yml")
if not os.path.isfile(fy):
continue
entries, _, errs = ladder.parse(open(fy, encoding="utf-8").read())
if errs or len(entries) < 2:
continue
rel = "templates/%s/docker-compose.yml" % app
commits = git("log", "--format=%H", "--", rel).split() # newest first
for i, e in enumerate(entries[:-1]):
dst = os.path.join(d, ladder.step_file(e["to"]))
if os.path.exists(dst):
print("SKIP %-10s entry %d: %s exists" % (app, i + 1, ladder.step_file(e["to"])))
continue
found = None
for c in commits:
body = git("show", "%s:%s" % (c, rel))
if ladder.images_in(body) == e["to"]:
found = (c, body)
break # newest first: the last definition served for this step
if not found:
print("MISS %-10s entry %d: no commit's compose names %s" % (app, i + 1, e["to"]))
continue
os.makedirs(os.path.dirname(dst), exist_ok=True)
open(dst, "w", encoding="utf-8").write(found[1])
print("WROTE %-10s entry %d/%d -> %s from %s (%s)" % (app, i + 1, len(entries), ladder.step_file(e["to"]),
found[0][:12], git("log", "-1", "--format=%s", found[0]).strip()[:70]))
wrote += 1
print("steps_backfill: %d step definition(s) written" % wrote)
return 0
if __name__ == "__main__":
sys.exit(main())
+70 -16
View File
@@ -94,6 +94,7 @@ def commit(clone, msg):
def reset(clone):
sh(["git", "reset", "-q", "--hard", "HEAD"], cwd=clone)
sh(["git", "clean", "-fdq"], cwd=clone) # a case may ADD a file (a steps/ definition)
def case(name, clone, edits, expect_rc, must_contain=(), gate="check-engine-major.py"):
@@ -283,6 +284,25 @@ def swap_image(service, frm, to):
def cur_image(clone, relpath, service):
"""The service's current image in the clone — read, never typed (R-663)."""
sys.path.insert(0, os.path.join(ROOT, "scripts"))
import ladder as _l
return _l.images_in(io.open(os.path.join(clone, relpath), encoding="utf-8").read())[service]
def strip_ladder(t):
"""The template WITHOUT its update_ladder block (and the header comment the writer puts above it).
The writer appends the block at the END of the file (ladder.append_entry), so everything from its
first line on goes — a case then builds exactly the ladder it describes, whatever the live catalog
has recorded since (R-663)."""
lines = t.splitlines()
for i, l in enumerate(lines):
if l.startswith("# update_ladder") or l.startswith("update_ladder:"):
return "\n".join(lines[:i]).rstrip("\n") + "\n"
return t
# ── test record (09 §3 decision 13) ────────────────────────────────────────────────────────────
TR_D1 = "sha256:" + "a" * 64
TR_D2 = "sha256:" + "b" * 64
@@ -299,8 +319,12 @@ def tr_entry(frm, to, digest, verdict="proven", peak=41.0, tight=False, **extra)
def tr_append(line, header=True):
"""Append one entry line. A template that already HAS a ladder (the writer puts it at the end of
the file) gets the line appended to it; one without gets the block (R-663: navidrome gained a
ladder on 2026-09-23 night, and a second `update_ladder:` key is its own conviction)."""
def _fn(t):
block = ("\nupdate_ladder:\n" if header else "") + line + "\n"
has = any(l.startswith("update_ladder:") for l in t.splitlines())
block = ("\nupdate_ladder:\n" if header and not has else "") + line + "\n"
return t.rstrip("\n") + "\n" + block
return _fn
@@ -349,7 +373,10 @@ def case_tr_static(name, clone, edits, expect_rc, must_contain=(), apps=("navidr
def test_record_cases(clone):
NC, NF = "templates/navidrome/docker-compose.yml", "templates/navidrome/.felhom.yml"
old, new = "deluan/navidrome:0.64.0", "deluan/navidrome:0.64.1"
# READ, never typed (R-663): the live pin moves with every proven step.
old = cur_image(clone, NC, "navidrome")
new = "deluan/navidrome:0.99.1"
prev = "deluan/navidrome:0.1.0" # an invented older step, for the static cases
frm, to = {"navidrome": old}, {"navidrome": new}
move = (NC, swap_image("navidrome", old, new))
good = tr_entry(frm, to, {"navidrome": TR_D1})
@@ -367,7 +394,7 @@ def test_record_cases(clone):
case_tr_move("INCONCLUSIVE: the registry cannot be asked", clone,
[move, (NF, tr_append(good))], 2, ("could not be asked",), {})
case_tr_move("DECOY: the entry only in a COMMENT under update_ladder", clone,
[move, (NF, lambda t: t.rstrip("\n") + "\nupdate_ladder:\n # " + good.strip() + "\n")], 1,
[move, (NF, lambda t: strip_ladder(t).rstrip("\n") + "\nupdate_ladder:\n # " + good.strip() + "\n")], 1,
("holds no entry",), table)
case_tr_move("DECOY: the entry only in README.md", clone,
[move, ("README.md", lambda t: t + "\n" + good + "\n")], 1,
@@ -385,21 +412,42 @@ def test_record_cases(clone):
case_tr_move("DECOY: a ref moves in a compose COMMENT only", clone,
[(NC, lambda t: t + "\n# was: deluan/navidrome:0.63.2\n")], 0, (), table)
print("-- test-record (static): the newest step IS the compose")
case_tr_static("GENUINE: a ladder whose head is the compose", clone,
[(NF, tr_append(tr_entry({"navidrome": "deluan/navidrome:0.63.2"}, frm, {"navidrome": TR_D1})))], 0)
def ladder_of(*lines):
"""Replace the template's ladder with exactly these entry lines."""
return lambda t: tr_append("\n".join(lines))(strip_ladder(t))
head = tr_entry({"navidrome": prev}, frm, {"navidrome": TR_D1})
case_tr_static("GENUINE: a ladder whose head is the compose", clone, [(NF, ladder_of(head))], 0)
case_tr_static("FACT: the compose moved past the ladder's head", clone,
[(NF, tr_append(tr_entry({"navidrome": "deluan/navidrome:0.63.2"}, frm, {"navidrome": TR_D1}))),
move], 1, ("not the ladder's newest step",))
[(NF, ladder_of(head)), move], 1, ("not the ladder's newest step",))
case_tr_static("FACT: a line that is not one JSON entry", clone,
[(NF, lambda t: t + "\nupdate_ladder:\n - from: x\n")], 1, ("not a one-line JSON entry",))
[(NF, lambda t: strip_ladder(t) + "\nupdate_ladder:\n - from: x\n")], 1, ("not a one-line JSON entry",))
case_tr_static("FACT: a gap between steps", clone,
[(NF, tr_append(tr_entry({"navidrome": "deluan/navidrome:0.62.0"}, {"navidrome": "deluan/navidrome:0.63.0"}, {"navidrome": TR_D1})
+ "\n" + tr_entry({"navidrome": "deluan/navidrome:0.63.2"}, frm, {"navidrome": TR_D1})))],
[(NF, ladder_of(tr_entry({"navidrome": "deluan/navidrome:0.0.1"}, {"navidrome": "deluan/navidrome:0.0.2"}, {"navidrome": TR_D1}), head))],
1, ("the ladder has a gap",))
case_tr_static("FACT: a failed verdict sits in the ladder", clone,
[(NF, tr_append(tr_entry({"navidrome": "deluan/navidrome:0.63.2"}, frm, {"navidrome": TR_D1}, verdict="failed")))],
[(NF, ladder_of(tr_entry({"navidrome": prev}, frm, {"navidrome": TR_D1}, verdict="failed")))],
1, ("not allowed in a ladder",))
# ── `09` §6.4 part 5 (v0.268.0 on the box): every step but the newest carries its OWN definition
# at steps/<StepKey(to)>.yml — the box climbs one step at a time and pins that file. ──────────
print("-- test-record (static): every intermediate step carries its own definition")
sys.path.insert(0, os.path.join(ROOT, "scripts"))
import ladder as _l
mid = {"navidrome": "deluan/navidrome:0.2.0"}
two = ladder_of(tr_entry({"navidrome": prev}, mid, {"navidrome": TR_D1}),
tr_entry(mid, frm, {"navidrome": TR_D1}))
step_rel = "templates/navidrome/" + _l.step_file(mid)
step_body = lambda t: swap_image("navidrome", old, mid["navidrome"])(io.open(os.path.join(clone, NC), encoding="utf-8").read())
case_tr_static("FACT: a two-step ladder with NO steps/ file for the first step", clone,
[(NF, two)], 1, ("has no definition",))
case_tr_static("GENUINE: a two-step ladder whose first step carries its definition", clone,
[(NF, two), (step_rel, step_body)], 0)
case_tr_static("DECOY: the steps/ file has the right NAME and names the head's image", clone,
[(NF, two), (step_rel, lambda t: io.open(os.path.join(clone, NC), encoding="utf-8").read())],
1, ("names",))
case_tr_static("DECOY: the step's definition sits beside the template under another name", clone,
[(NF, two), ("templates/navidrome/steps/0.2.0.yml", step_body)], 1, ("has no definition",))
def main():
gate = os.path.join(ROOT, "scripts", "check-engine-major.py")
if not os.path.isfile(gate):
@@ -409,6 +457,12 @@ def main():
try:
KIMAI = "templates/kimai/docker-compose.yml"
DOCMOST = "templates/docmost/docker-compose.yml"
# The engine ref is READ from the clone, not typed: the night of 2026-09-23 moved kimai-db
# 11.6 -> 11.8 through its own test record, and a literal here broke every case below
# ("fixture drifted") without a single gate changing. R-663.
KDB = cur_image(clone, KIMAI, "kimai-db")
if not KDB.startswith("mariadb:11."):
raise SystemExit("kimai-db is %s — the cases below assume a MariaDB 11 line; fixture drifted" % KDB)
# ── THE FACTS: these must be refused ─────────────────────────────────────────────────
out = case("FACT: docmost-postgres 16-alpine -> 17-alpine bundled with the app bump", clone,
@@ -424,20 +478,20 @@ def main():
# bookstack 0b73e5e shape — two migrations behind one edge — and stays refused.
case("FACT: kimai-db 11.6 -> 12.3 BUNDLED with the kimai app bump", clone,
[(KIMAI, lambda t: swap_image("kimai", "kimai/kimai2:apache-2.57.0", "kimai/kimai2:apache-2.58.0")(
swap_image("kimai-db", "mariadb:11.6", "mariadb:12.3")(t)))],
swap_image("kimai-db", KDB, "mariadb:12.3")(t)))],
expect_rc=1, must_contain=("IN THE SAME COMMIT as kimai", "OWN EDGE", "R-450"))
case("FACT: kimai-db mariadb:11.6 -> mariadb:lts (major unreadable)", clone,
[(KIMAI, swap_image("kimai-db", "mariadb:11.6", "mariadb:lts"))],
[(KIMAI, swap_image("kimai-db", KDB, "mariadb:lts"))],
expect_rc=2, must_contain=("INCONCLUSIVE",))
# ── THE GENUINE ARTICLES: these must pass ────────────────────────────────────────────
case("GENUINE: kimai-db mariadb:11.6 -> 11.8 (within major)", clone,
[(KIMAI, swap_image("kimai-db", "mariadb:11.6", "mariadb:11.8"))],
case("GENUINE: kimai-db mariadb:11.x -> 11.99 (within major)", clone,
[(KIMAI, swap_image("kimai-db", KDB, "mariadb:11.99"))],
expect_rc=0, must_contain=("engine-major gate OK",))
# R-469: the LIFT itself. A MariaDB major ALONE in its template is now permitted, and the
# gate says so by name rather than passing in silence.
case("GENUINE: kimai-db mariadb:11.6 -> 12.3 ALONE (the R-469 lift)", clone,
[(KIMAI, swap_image("kimai-db", "mariadb:11.6", "mariadb:12.3"))],
[(KIMAI, swap_image("kimai-db", KDB, "mariadb:12.3"))],
expect_rc=0, must_contain=("ALLOWED", "kimai-db", "mariadb 11 -> 12", "R-469"))
# ── THE DECOYS: the label moves, the fact does not — these must pass ─────────────────
+34 -5
View File
@@ -28,7 +28,14 @@ spec.loader.exec_module(ut)
D = "sha256:" + "c" * 64
def bench(verdict="proven", peak=0.41, marks=(), frm="0.64.0", to="0.64.1"):
# READ, never typed (R-663): navidrome moved 0.64.0 -> 0.64.1 on 2026-09-23 night and a literal here
# broke two of these tests without the writer changing.
LIVE = ladder.images_in(open(os.path.join(ROOT, "templates", "navidrome", "docker-compose.yml")).read())["navidrome"].split(":")[1]
NEXT = LIVE + "-next"
AFTER = LIVE + "-after"
def bench(verdict="proven", peak=0.41, marks=(), frm=LIVE, to=NEXT):
return {"harness_version": 3, "app": "navidrome", "verdict": verdict,
"from": {"navidrome": "deluan/navidrome:" + frm}, "to": {"navidrome": "deluan/navidrome:" + to},
"measured_at": "2026-09-23T22:00:00Z", "marks": list(marks),
@@ -41,11 +48,12 @@ class WriterTest(unittest.TestCase):
shutil.copytree(os.path.join(ROOT, "templates", "navidrome"),
os.path.join(self.tmp, "templates", "navidrome"))
self.fy = os.path.join(self.tmp, "templates", "navidrome", ".felhom.yml")
# start from a template WITHOUT a ladder, at 0.64.0 (the live pin tonight)
# start from a template WITHOUT a ladder, at the live pin
text = open(self.fy).read()
if "update_ladder:" in text:
text = text[:text.index("\n# update_ladder")] + "\n"
open(self.fy, "w").write(text)
shutil.rmtree(os.path.join(self.tmp, "templates", "navidrome", "steps"), ignore_errors=True)
self._orig = image_digest.resolve
image_digest.resolve = lambda ref: (D, None)
@@ -67,11 +75,11 @@ class WriterTest(unittest.TestCase):
self.assertEqual(rc, 0, out)
entries, _, errs = ladder.parse(open(self.fy).read())
self.assertEqual(errs, [])
self.assertEqual(entries[-1]["to"], {"navidrome": "deluan/navidrome:0.64.1"})
self.assertEqual(entries[-1]["to"], {"navidrome": "deluan/navidrome:" + NEXT})
self.assertEqual(entries[-1]["memory_peak_pct"], 41.0) # a PERCENT, from the watch's fraction
self.assertEqual(entries[-1]["digest"], {"navidrome": D})
comp = open(os.path.join(self.tmp, "templates", "navidrome", "docker-compose.yml")).read()
self.assertEqual(ladder.images_in(comp), {"navidrome": "deluan/navidrome:0.64.1"})
self.assertEqual(ladder.images_in(comp), {"navidrome": "deluan/navidrome:" + NEXT})
import subprocess
r = subprocess.run([sys.executable, os.path.join(HERE, "check-test-record.py"), "--root", self.tmp,
"navidrome"], capture_output=True, text=True)
@@ -88,7 +96,7 @@ class WriterTest(unittest.TestCase):
self.assertNotIn("update_ladder:", open(self.fy).read())
def test_refuses_when_the_template_is_not_at_from(self):
rc, out = self.run_writer(bench(frm="0.63.2"))
rc, out = self.run_writer(bench(frm="0.0.1"))
self.assertEqual(rc, 1)
self.assertIn("the template is at", out)
@@ -98,6 +106,27 @@ class WriterTest(unittest.TestCase):
e = ladder.parse(open(self.fy).read())[0][-1]
self.assertEqual(e["marks"], {"files_may_change": True, "needs_person": None, "memory_tight": True})
def test_a_second_step_keeps_the_first_steps_definition(self):
"""`09` §6.4 part 5: writing step 2 turns step 1 into an intermediate step — its OWN definition
(the compose as it stood, fixes included) is kept at steps/<key>.yml, and the gate accepts the
two-step ladder. RED-PROOF (REPORT.md): drop the STEP block in write_ladder — the gate refuses
at "has no definition"."""
rc, out = self.run_writer(bench())
self.assertEqual(rc, 0, out)
comp_p = os.path.join(self.tmp, "templates", "navidrome", "docker-compose.yml")
# a fix flows into the step-1 definition after it was published (the romm f4eb94f shape)
open(comp_p, "a").write("# a fix that flowed after step 1\n")
at_step1 = open(comp_p).read()
rc, out = self.run_writer(bench(frm=NEXT, to=AFTER))
self.assertEqual(rc, 0, out)
sp = os.path.join(self.tmp, "templates", "navidrome", ladder.step_file({"navidrome": "deluan/navidrome:" + NEXT}))
self.assertTrue(os.path.isfile(sp), out)
self.assertEqual(open(sp).read(), at_step1, "the step file must be the definition AS SERVED, fixes included")
import subprocess
r = subprocess.run([sys.executable, os.path.join(HERE, "check-test-record.py"), "--root", self.tmp,
"navidrome"], capture_output=True, text=True)
self.assertEqual(r.returncode, 0, r.stdout)
if __name__ == "__main__":
unittest.main(verbosity=2)
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""test_upgrade_bench.py — the test bench's two night-of-2026-09-23 faults (R-653, R-656). No Docker.
python3 scripts/test_upgrade_bench.py
"""
import importlib.util
import os
import shutil
import tempfile
import unittest
HERE = os.path.dirname(os.path.abspath(__file__))
spec = importlib.util.spec_from_file_location("upgrade_test_mod", os.path.join(HERE, "upgrade-test.py"))
ut = importlib.util.module_from_spec(spec)
spec.loader.exec_module(ut)
class LoadVerdict(unittest.TestCase):
"""R-653. RED-PROOF (REPORT.md): make load_verdict always return "reached" — the ghost case fails."""
def test_every_request_errored_is_inconclusive(self):
# ghost's first watch, 2026-09-23 night: 11 797 requests, all `err`
self.assertEqual(ut.load_verdict(11797, {"err": 11797}), "inconclusive")
def test_answers_of_any_code_are_the_app_answering(self):
self.assertEqual(ut.load_verdict(11429, {"200": 5712, "401": 5717}), "reached")
self.assertEqual(ut.load_verdict(10, {"302": 5, "err": 5}), "reached")
def test_under_half_is_inconclusive_and_none_is_inconclusive(self):
self.assertEqual(ut.load_verdict(10, {"200": 4, "err": 6}), "inconclusive")
self.assertEqual(ut.load_verdict(0, {}), "inconclusive")
class ClearScratch(unittest.TestCase):
"""R-656. RED-PROOF (REPORT.md): make clear_scratch_folders return [] without removing — the first
test fails with the last run's config still there."""
def setUp(self):
self.root = tempfile.mkdtemp(prefix="bench-scratch-")
self.hdd = os.path.join(self.root, "hdd")
self.env = {"HDD_PATH": self.hdd, "USERDATA_PATH": self.hdd + "/userdata"}
self.said = []
def tearDown(self):
shutil.rmtree(self.root, ignore_errors=True)
def plant(self, rel):
p = os.path.join(self.hdd, rel, "config", "config.php")
os.makedirs(os.path.dirname(p), exist_ok=True)
open(p, "w").write("last run")
return p
def test_the_apps_own_folders_are_cleared_and_said(self):
mine = self.plant("appdata/nextcloud")
other = self.plant("appdata/immich")
comp = "services:\n nextcloud:\n volumes:\n - ${HDD_PATH}/appdata/nextcloud:/var/www/html\n"
got = ut.clear_scratch_folders(comp, self.env, self.said.append)
self.assertFalse(os.path.exists(mine), "the last run's files are still there")
self.assertTrue(os.path.exists(other), "another app's folder was touched")
self.assertEqual(len(got), 1)
self.assertTrue(any("cleared before FROM" in s for s in self.said))
def test_never_a_bare_root_never_outside(self):
keep = self.plant("appdata/x")
comp = (" volumes:\n - ${HDD_PATH}:/data\n - ${HDD_PATH}/:/d2\n"
" - ${HDD_PATH}/../escape:/e\n")
got = ut.clear_scratch_folders(comp, self.env, self.said.append)
self.assertEqual(got, [])
self.assertTrue(os.path.exists(keep))
if __name__ == "__main__":
unittest.main(verbosity=2)
+55 -1
View File
@@ -465,12 +465,50 @@ def memory_watch(app: str, project: str, workdir: Path, seconds: int, say, ev: P
tight = [n for n, p in per.items() if _tight_pct(p) is not None and _tight_pct(p) > MEMORY_TIGHT]
rec = {"soak_s": round(time.time() - t0, 1), "requested_s": seconds, "requests": hits["n"],
"codes": hits["codes"], "first_kill": first_bad, "containers": per,
"unmeasured": [n for n, p in per.items() if not p["measured"]]}
"unmeasured": [n for n, p in per.items() if not p["measured"]],
"load": load_verdict(hits["n"], hits["codes"])}
marks = ["memory_tight"] if tight and not killed else []
say(f"memory watch: killed={killed} tight={tight} requests={hits['n']} codes={hits['codes']}")
return rec, killed, marks
def load_verdict(requests: int, codes: dict) -> str:
"""R-653: did the watch's load REACH the app? `reached` when at least half its requests got any HTTP
answer (a 401 or a 30x is the app answering); `inconclusive` otherwise. Measured 2026-09-23 night:
ghost's and nextcloud's first ten-minute watches sent 11 797 and 9 427 requests and EVERY one was
`err` — memory measured, app idle — and the harness wrote `proven` over it."""
if requests <= 0:
return "inconclusive"
answered = sum(n for c, n in codes.items() if c != "err")
return "reached" if answered * 2 >= requests else "inconclusive"
def clear_scratch_folders(compose_text: str, env: dict, say) -> list:
"""R-656: before FROM, remove the app's OWN scratch drive folders — every `${HDD_PATH}/…`,
`${USERDATA_PATH}/…` and `${IMPORT_PATH}/…` bind the compose names — so a re-run of the same app does
not start on the last run's files (nextcloud's second run never installed, 2026-09-23 night: `occ
status: installed: false` over the first run's config/). `compose down -v` removes named volumes, not
bind-mounted host folders. NEVER a bare root, never a path outside the scratch roots. Returns what was
removed, and says so either way."""
roots = {k: env.get(k) for k in ("HDD_PATH", "USERDATA_PATH", "IMPORT_PATH") if env.get(k)}
removed = []
for var, rel in re.findall(r"\$\{(HDD_PATH|USERDATA_PATH|IMPORT_PATH)\}(/[^:\s\"']*)", compose_text):
root = roots.get(var)
if not root:
continue
rel = rel.strip("/")
target = os.path.realpath(os.path.join(root, rel))
safe_root = os.path.realpath(root)
if not rel or target == safe_root or not target.startswith(safe_root + os.sep):
say(f"scratch folder NOT cleared (outside or equal to {var}={root}): {rel!r}")
continue
if os.path.exists(target):
shutil.rmtree(target, ignore_errors=False)
removed.append(target)
say(f"scratch drive folders cleared before FROM (R-656): {removed or 'none existed'}")
return removed
MIGRATION_RE = re.compile(
r"migrat|upgrad|schema|alter table|CREATE TABLE|InnoDB: Upgrad|mysql_upgrade|"
r"mariadb-upgrade|Running .* migration|Applying|db:migrate",
@@ -558,6 +596,7 @@ def run_edge(edge_id: str) -> dict:
try:
# --- 1. FROM ---
rec["scratch_cleared"] = clear_scratch_folders(compose_text, env, say)
say(f"{edge_id}: deploying {app} at FROM {e['frm']}")
render(app, e["frm"], workdir, env, e.get("template"))
up = compose(workdir, project, "up", "-d")
@@ -652,6 +691,11 @@ def run_edge(edge_id: str) -> dict:
if killed:
rec["verdict"] = "failed"
say("VERDICT -> failed: the new version was OOM-killed or restarted under light load")
elif mem.get("load") != "reached":
rec["verdict"] = "inconclusive"
rec["abort_detail"] = (f"memory watch: fewer than half of its {mem.get('requests')} requests reached "
f"the app ({mem.get('codes')}) — memory measured on an idle app (R-653)")
say("VERDICT -> inconclusive: " + rec["abort_detail"])
# --- 6. the ABORT ---
say(f"{edge_id}: ABORT — putting the FROM images back")
@@ -778,6 +822,16 @@ def write_ladder(argv) -> int:
if probs:
print(f"REFUSED {app}: the entry would not be well-formed: {probs}")
return 1
# `09` §6.4 part 5: the step being SUPERSEDED keeps its own definition. When the ladder's head is the
# compose as it stands, that compose — the head's images with every fix that flowed since — becomes
# steps/<step_key(head.to)>.yml, the file a box one step behind will pin (check-test-record.py rule 4).
prior, _, _ = ladder.parse(fy_p.read_text())
if prior and prior[-1].get("to") == cur:
sp = tdir / ladder.step_file(cur)
if not sp.exists():
sp.parent.mkdir(parents=True, exist_ok=True)
sp.write_text(comp)
print(f"STEP {app}: the superseded step {cur} keeps its definition at {ladder.step_file(cur)}")
# move the compose, per service, on that service's own image: line
out, svc = [], None
for line in comp.splitlines():
+53
View File
@@ -0,0 +1,53 @@
# Emby - Személyes média szerver élő TV és DVR támogatással
# Domain: ${SUBDOMAIN}.${DOMAIN}
# Database: None (file-based)
# RAM: ~512M (mem_limit: 2048M) | Pi-compatible: No
#
# Environment variables:
# DOMAIN - Your domain (e.g., demo-felhom.eu)
# USERDATA_PATH - Ügyfél-tartalom gyökér (<namespace>/userdata)
#
# Storage layout (felhom userdata convention):
# Médiatár → ${USERDATA_PATH}/media (csak olvasható)
services:
emby:
image: emby/embyserver:4.11.0.1
container_name: emby
restart: unless-stopped
environment:
- TZ=Europe/Budapest
- UID=1000
- GID=1000
volumes:
- emby_config:/config
- ${USERDATA_PATH}/media:/media:ro
networks:
- traefik-public
deploy:
resources:
limits:
memory: 2048M
healthcheck:
# Az Emby képfájlban NINCS curl és nincs önálló wget — csak BusyBox van.
# A korábbi curl-próba ezért soha nem futott le, a konténer véglegesen
# unhealthy maradt, és a Traefik nem irányított rá forgalmat (404).
test: ["CMD", "/bin/busybox", "wget", "--spider", "-q", "http://127.0.0.1:8096/emby/system/ping"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
labels:
- "traefik.enable=true"
- "traefik.http.routers.emby.rule=Host(`${SUBDOMAIN}.${DOMAIN}`)"
- "traefik.http.routers.emby.entrypoints=websecure"
- "traefik.http.routers.emby.tls=true"
- "traefik.http.routers.emby.tls.certresolver=letsencrypt"
- "traefik.http.services.emby.loadbalancer.server.port=8096"
volumes:
emby_config:
networks:
traefik-public:
external: true
@@ -0,0 +1,47 @@
# Ghost - Professzionális blog és hírlevél platform
# Domain: ${SUBDOMAIN}.${DOMAIN}
# Database: None (file-based)
# RAM: ~150M (mem_limit: 512M) | Pi-compatible: No
#
# Environment variables:
# DOMAIN - Your domain (e.g., demo-felhom.eu)
services:
ghost:
image: ghost:6.64.0-alpine
container_name: ghost
restart: unless-stopped
environment:
- TZ=Europe/Budapest
- NODE_ENV=production
- url=https://${SUBDOMAIN}.${DOMAIN}
- database__client=sqlite3
- database__connection__filename=content/data/ghost.db
volumes:
- ghost_content:/var/lib/ghost/content
networks:
- traefik-public
deploy:
resources:
limits:
memory: 512M
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:2368/',r=>{process.exit(r.statusCode<400?0:1)}).on('error',()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
labels:
- "traefik.enable=true"
- "traefik.http.routers.ghost.rule=Host(`${SUBDOMAIN}.${DOMAIN}`)"
- "traefik.http.routers.ghost.entrypoints=websecure"
- "traefik.http.routers.ghost.tls=true"
- "traefik.http.routers.ghost.tls.certresolver=letsencrypt"
- "traefik.http.services.ghost.loadbalancer.server.port=2368"
volumes:
ghost_content:
networks:
traefik-public:
external: true
+146
View File
@@ -0,0 +1,146 @@
# Immich - Self-hosted Photo & Video Management
# Domain: ${SUBDOMAIN}.${DOMAIN}
# Database: PostgreSQL (with VectorChord) + Redis
# RAM: ~4GB minimum (mem_limit: 4096M total — server 2048M + ML 1536M + postgres 256M + redis 128M) | Pi-compatible: No (ML too heavy)
#
# Environment variables:
# DOMAIN - Your domain (e.g., demo-felhom.eu)
# HDD_PATH - Drive namespace root (managed upload lives under appdata)
# USERDATA_PATH - Ügyfél-tartalom gyökér (<namespace>/userdata)
# DB_PASSWORD - PostgreSQL password (auto-generated)
#
# Storage layout (felhom userdata convention):
# Managed upload (Immich-owned) → ${HDD_PATH}/appdata/immich (HDD, host path — app-managed)
# External photo library (RO) → ${USERDATA_PATH}/media/photos → /external/photos
# PostgreSQL data → immich_postgres_data (named volume, NVMe)
# ML model cache → immich_ml_cache (named volume, NVMe)
# Redis data → immich_redis_data (named volume, NVMe)
#
# ⚠ EXTERNAL LIBRARY IS NOT WIRED BY COMPOSE: mounting /external/photos only makes the files
# visible to the container. To actually surface them in Immich you MUST register an External
# Library pointing at /external/photos in the Immich admin UI (Administration → External
# Libraries) or via the API — a POST-DEPLOY step. Until then photos sharing is "mount ready,
# registration pending". See .felhom.yml first_steps + REPORT.
#
# First-time setup:
# Create admin account on first visit, then register the External Library (see above).
services:
immich-server:
image: ghcr.io/immich-app/immich-server:v3.2.2
container_name: immich-server
restart: unless-stopped
depends_on:
immich-postgres:
condition: service_healthy
immich-redis:
condition: service_healthy
environment:
- DB_PASSWORD=${DB_PASSWORD}
- DB_HOSTNAME=immich-postgres
- DB_USERNAME=immich
- DB_DATABASE_NAME=immich
- REDIS_HOSTNAME=immich-redis
- IMMICH_MACHINE_LEARNING_URL=http://immich-machine-learning:3003
- TZ=Europe/Budapest
volumes:
- ${HDD_PATH}/appdata/immich:/usr/src/app/upload
- ${USERDATA_PATH}/media/photos:/external/photos:ro
networks:
- traefik-public
- immich-internal
deploy:
resources:
limits:
memory: 2048M
healthcheck:
test: ["CMD", "curl", "-sf", "http://127.0.0.1:2283/api/server/ping"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
labels:
- "traefik.enable=true"
- "traefik.http.routers.immich.rule=Host(`${SUBDOMAIN}.${DOMAIN}`)"
- "traefik.http.routers.immich.entrypoints=websecure"
- "traefik.http.routers.immich.tls=true"
- "traefik.http.routers.immich.tls.certresolver=letsencrypt"
- "traefik.http.services.immich.loadbalancer.server.port=2283"
immich-machine-learning:
image: ghcr.io/immich-app/immich-machine-learning:v3.0.3
container_name: immich-machine-learning
restart: unless-stopped
environment:
- TZ=Europe/Budapest
- TRANSFORMERS_CACHE=/cache
volumes:
- immich_ml_cache:/cache
networks:
- immich-internal
deploy:
resources:
limits:
memory: 1536M
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:3003/ping')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 120s
immich-postgres:
image: ghcr.io/immich-app/postgres:16-vectorchord0.4.3-pgvectors0.2.0
container_name: immich-postgres
restart: unless-stopped
environment:
- POSTGRES_USER=immich
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=immich
- POSTGRES_INITDB_ARGS=--data-checksums
- TZ=Europe/Budapest
volumes:
- immich_postgres_data:/var/lib/postgresql/data
networks:
- immich-internal
deploy:
resources:
limits:
memory: 512M
healthcheck:
test: ["CMD-SHELL", "pg_isready -U immich -d immich"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
immich-redis:
image: redis:7-alpine
container_name: immich-redis
restart: unless-stopped
command: redis-server --appendonly yes
environment:
- TZ=Europe/Budapest
volumes:
- immich_redis_data:/data
networks:
- immich-internal
deploy:
resources:
limits:
memory: 128M
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
volumes:
immich_ml_cache:
immich_postgres_data:
immich_redis_data:
networks:
traefik-public:
external: true
immich-internal:
+48
View File
@@ -0,0 +1,48 @@
# n8n - Workflow automatizálás vizuális szerkesztővel
# Domain: ${SUBDOMAIN}.${DOMAIN}
# Database: None (file-based)
# RAM: ~150M (mem_limit: 512M) | Pi-compatible: No
#
# Environment variables:
# DOMAIN - Your domain (e.g., demo-felhom.eu)
# N8N_ENCRYPTION_KEY- Titkosítási kulcs (auto-generated)
services:
n8n:
image: n8nio/n8n:2.40.5
container_name: n8n
restart: unless-stopped
environment:
- TZ=Europe/Budapest
- N8N_HOST=${SUBDOMAIN}.${DOMAIN}
- N8N_PROTOCOL=https
- WEBHOOK_URL=https://${SUBDOMAIN}.${DOMAIN}/
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
volumes:
- n8n_data:/home/node/.n8n
networks:
- traefik-public
deploy:
resources:
limits:
memory: 1536M
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:5678/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
labels:
- "traefik.enable=true"
- "traefik.http.routers.n8n.rule=Host(`${SUBDOMAIN}.${DOMAIN}`)"
- "traefik.http.routers.n8n.entrypoints=websecure"
- "traefik.http.routers.n8n.tls=true"
- "traefik.http.routers.n8n.tls.certresolver=letsencrypt"
- "traefik.http.services.n8n.loadbalancer.server.port=5678"
volumes:
n8n_data:
networks:
traefik-public:
external: true
@@ -0,0 +1,51 @@
# Navidrome - Könnyű zene szerver Subsonic API támogatással
# Domain: ${SUBDOMAIN}.${DOMAIN}
# Database: None (file-based)
# RAM: ~50M (mem_limit: 256M) | Pi-compatible: Yes
#
# Environment variables:
# DOMAIN - Your domain (e.g., demo-felhom.eu)
# USERDATA_PATH - Ügyfél-tartalom gyökér (<namespace>/userdata)
#
# Storage layout (felhom userdata convention):
# Zenegyűjtemény → ${USERDATA_PATH}/media/music (csak olvasható)
services:
navidrome:
image: deluan/navidrome:0.64.0
container_name: navidrome
restart: unless-stopped
environment:
- TZ=Europe/Budapest
- ND_SCANSCHEDULE=1h
- ND_LOGLEVEL=info
- ND_BASEURL=
volumes:
- navidrome_data:/data
- ${USERDATA_PATH}/media/music:/music:ro
networks:
- traefik-public
deploy:
resources:
limits:
memory: 256M
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:4533/ping"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
labels:
- "traefik.enable=true"
- "traefik.http.routers.navidrome.rule=Host(`${SUBDOMAIN}.${DOMAIN}`)"
- "traefik.http.routers.navidrome.entrypoints=websecure"
- "traefik.http.routers.navidrome.tls=true"
- "traefik.http.routers.navidrome.tls.certresolver=letsencrypt"
- "traefik.http.services.navidrome.loadbalancer.server.port=4533"
volumes:
navidrome_data:
networks:
traefik-public:
external: true
@@ -0,0 +1,132 @@
# Nextcloud - Saját felhő tárhely - Google Drive/Dropbox alternatíva
# Domain: ${SUBDOMAIN}.${DOMAIN}
# Database: mariadb
# RAM: ~256M (mem_limit: 1024M) | Pi-compatible: No
#
# Environment variables:
# DOMAIN - Your domain (e.g., demo-felhom.eu)
# DB_PASSWORD - Adatbázis jelszó (auto-generated)
# MYSQL_ROOT_PASSWORD- MariaDB root jelszó (auto-generated)
# NEXTCLOUD_ADMIN_USER- Admin felhasználónév
# NEXTCLOUD_ADMIN_PASSWORD- Admin jelszó (auto-generated)
# HDD_PATH - Adattárolási útvonal
services:
nextcloud:
image: nextcloud:34.0.1-apache
container_name: nextcloud
restart: unless-stopped
depends_on:
nextcloud-db:
condition: service_healthy
nextcloud-redis:
condition: service_healthy
environment:
- TZ=Europe/Budapest
- MYSQL_DATABASE=nextcloud
- MYSQL_USER=nextcloud
- MYSQL_PASSWORD=${DB_PASSWORD}
- MYSQL_HOST=nextcloud-db
- NEXTCLOUD_ADMIN_USER=${NEXTCLOUD_ADMIN_USER:-admin}
- NEXTCLOUD_ADMIN_PASSWORD=${NEXTCLOUD_ADMIN_PASSWORD}
- NEXTCLOUD_TRUSTED_DOMAINS=${SUBDOMAIN}.${DOMAIN} nextcloud
- OVERWRITEPROTOCOL=https
- OVERWRITEHOST=${SUBDOMAIN}.${DOMAIN}
- REDIS_HOST=nextcloud-redis
# App-email (managed relay). Injected by the controller only when app-email is on (global + per-app);
# empty SMTP_HOST keeps Nextcloud mail disabled. Nextcloud uses the plaintext :2526 listener
# (tls_mode=plaintext, SMTP_SECURE empty = no TLS) — it can't skip the self-signed STARTTLS cert.
# From is split: MAIL_FROM_ADDRESS=nextcloud + MAIL_DOMAIN=felhom.eu. See .felhom.yml smtp_mapping.
- SMTP_HOST=${SMTP_HOST:-}
- SMTP_PORT=${SMTP_PORT:-25}
- SMTP_SECURE=${SMTP_SECURE:-}
- MAIL_FROM_ADDRESS=${MAIL_FROM_ADDRESS:-}
- MAIL_DOMAIN=${MAIL_DOMAIN:-}
volumes:
- nextcloud_html:/var/www/html
- ${HDD_PATH}/appdata/nextcloud:/var/www/html/data
networks:
- traefik-public
- nextcloud-internal
deploy:
resources:
limits:
memory: 1024M
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:80/status.php"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
labels:
- "traefik.enable=true"
- "traefik.http.routers.nextcloud.rule=Host(`${SUBDOMAIN}.${DOMAIN}`)"
- "traefik.http.routers.nextcloud.entrypoints=websecure"
- "traefik.http.routers.nextcloud.tls=true"
- "traefik.http.routers.nextcloud.tls.certresolver=letsencrypt"
- "traefik.http.services.nextcloud.loadbalancer.server.port=80"
- "traefik.http.middlewares.nextcloud-redirect.redirectregex.regex=/.well-known/(card|cal)dav"
- "traefik.http.middlewares.nextcloud-redirect.redirectregex.replacement=/remote.php/dav/"
- "traefik.http.routers.nextcloud.middlewares=nextcloud-redirect"
nextcloud-db:
image: mariadb:12.3
container_name: nextcloud-db
restart: unless-stopped
environment:
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
- MYSQL_DATABASE=nextcloud
- MYSQL_USER=nextcloud
- MYSQL_PASSWORD=${DB_PASSWORD}
- TZ=Europe/Budapest
# MARIADB_AUTO_UPGRADE: on a MAJOR engine move the engine converts its own datadir (~7 s on a
# small DB, backs its system tables up first). Operator ruling 2026-09-13 on
# felhom.eu/documentation/audits/SPIKE-r459-mariadb-upgrade-2026-09-06.md. Inert until a
# major moves — and none may, until Slice 4 (R-448) ships: see CLAUDE.md, engine-major rule.
- MARIADB_AUTO_UPGRADE=1
volumes:
- nextcloud_db_data:/var/lib/mysql
networks:
- nextcloud-internal
deploy:
resources:
limits:
memory: 512M
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
nextcloud-redis:
image: redis:7-alpine
container_name: nextcloud-redis
restart: unless-stopped
command: redis-server --appendonly yes
environment:
- TZ=Europe/Budapest
volumes:
- nextcloud_redis_data:/data
networks:
- nextcloud-internal
deploy:
resources:
limits:
memory: 128M
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
volumes:
nextcloud_db_data:
nextcloud_html:
nextcloud_redis_data:
networks:
traefik-public:
external: true
nextcloud-internal:
+160
View File
@@ -0,0 +1,160 @@
# ROMM - ROM Manager for Game Libraries
# Domain: ${SUBDOMAIN}.${DOMAIN}
# Database: MariaDB + Redis
# RAM: ~300MB (mem_limit: 1280M total — romm 768M + mariadb 384M + redis 128M) | Pi-compatible: No (MariaDB + heavy)
#
# Environment variables:
# DOMAIN - Your domain (e.g., demo-felhom.eu)
# HDD_PATH - Drive namespace root (appdata lives here)
# USERDATA_PATH - Ügyfél-tartalom gyökér (<namespace>/userdata)
# DB_PASSWORD - MariaDB user password (auto-generated)
# MYSQL_ROOT_PASSWORD - MariaDB root password (auto-generated)
# ROMM_AUTH_SECRET_KEY - Auth secret (auto-generated)
#
# Storage layout (felhom userdata convention):
# ROM library → ${USERDATA_PATH}/roms (browsable — drop ROMs here via FileBrowser)
# Cover art etc → ${HDD_PATH}/appdata/romm/resources (app-internal, NOT browsable)
# App config → romm_config (named volume, NVMe)
# MariaDB data → romm_db_data (named volume, NVMe)
# Redis data → romm_redis_data (named volume, NVMe)
#
# First-time setup:
# Default login: admin / admin — change immediately!
services:
romm:
image: rommapp/romm:5.3.0
container_name: romm
restart: unless-stopped
depends_on:
romm-db:
condition: service_healthy
romm-redis:
condition: service_healthy
entrypoint: ["/bin/sh", "-c"]
command:
- |
if [ ! -f /romm/config/config.yml ]; then
echo "Creating default config.yml..."
cat > /romm/config/config.yml << 'CONF'
exclude:
platforms: []
roms: []
system:
log_level: INFO
CONF
fi
exec /docker-entrypoint.sh /init
environment:
- ROMM_AUTH_SECRET_KEY=${ROMM_AUTH_SECRET_KEY}
- DB_PASSWD=${DB_PASSWORD}
- DB_HOST=romm-db
- DB_PORT=3306
- DB_NAME=romm
- DB_USER=romm
- REDIS_HOST=romm-redis
- REDIS_PORT=6379
- ROMM_PORT=8080
# 2, not the image's default of 4. MEASURED on demo-hp 2026-09-22 (R-635): each warm
# uvicorn worker holds ~216 MiB, so four of them plus the master reach ~882 MiB and the
# container was OOM-killed at both 512M and 768M — 37 worker SIGKILLs in five minutes,
# ~500% CPU, host load 5.2 while otherwise idle. Four workers is a SERVER default; this
# is one household on one small box. Two workers measure ~450 MiB and fit 768M with
# real headroom. `/init:143` reads this variable: --workers "${WEB_SERVER_CONCURRENCY:-4}".
- WEB_SERVER_CONCURRENCY=2
- IGDB_CLIENT_ID=${IGDB_CLIENT_ID:-}
- IGDB_CLIENT_SECRET=${IGDB_CLIENT_SECRET:-}
- STEAMGRIDDB_API_KEY=${STEAMGRIDDB_API_KEY:-}
- SCREENSCRAPER_USER=${SCREENSCRAPER_USER:-}
- SCREENSCRAPER_PASSWORD=${SCREENSCRAPER_PASSWORD:-}
- MOBYGAMES_API_KEY=${MOBYGAMES_API_KEY:-}
- TZ=Europe/Budapest
volumes:
- ${USERDATA_PATH}/roms:/romm/library
- ${HDD_PATH}/appdata/romm/resources:/romm/resources
- romm_config:/romm/config
networks:
- traefik-public
- romm-internal
deploy:
resources:
limits:
# 768M, not 512M: romm 5.3.0 does not fit in 512M. Measured on demo-hp 2026-09-22 —
# OOMKilled true, 4,530 gunicorn worker SIGKILLs in six hours, ~500% CPU in a permanent
# restart storm, while the web front end still answered 200 so the update read `done`
# (R-635). 5.0.0 did fit; the version moved and the limit did not.
memory: 768M
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8080/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
labels:
- "traefik.enable=true"
- "traefik.http.routers.romm.rule=Host(`${SUBDOMAIN}.${DOMAIN}`)"
- "traefik.http.routers.romm.entrypoints=websecure"
- "traefik.http.routers.romm.tls=true"
- "traefik.http.routers.romm.tls.certresolver=letsencrypt"
- "traefik.http.services.romm.loadbalancer.server.port=8080"
romm-db:
image: mariadb:11.4
container_name: romm-db
restart: unless-stopped
environment:
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
- MYSQL_DATABASE=romm
- MYSQL_USER=romm
- MYSQL_PASSWORD=${DB_PASSWORD}
- TZ=Europe/Budapest
# MARIADB_AUTO_UPGRADE: on a MAJOR engine move the engine converts its own datadir (~7 s on a
# small DB, backs its system tables up first). Operator ruling 2026-09-13 on
# felhom.eu/documentation/audits/SPIKE-r459-mariadb-upgrade-2026-09-06.md. Inert until a
# major moves — and none may, until Slice 4 (R-448) ships: see CLAUDE.md, engine-major rule.
- MARIADB_AUTO_UPGRADE=1
volumes:
- romm_db_data:/var/lib/mysql
networks:
- romm-internal
deploy:
resources:
limits:
memory: 384M
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
romm-redis:
image: redis:7-alpine
container_name: romm-redis
restart: unless-stopped
command: redis-server --appendonly yes
environment:
- TZ=Europe/Budapest
volumes:
- romm_redis_data:/data
networks:
- romm-internal
deploy:
resources:
limits:
memory: 128M
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
volumes:
romm_config:
romm_db_data:
romm_redis_data:
networks:
traefik-public:
external: true
romm-internal:
+160
View File
@@ -0,0 +1,160 @@
# ROMM - ROM Manager for Game Libraries
# Domain: ${SUBDOMAIN}.${DOMAIN}
# Database: MariaDB + Redis
# RAM: ~300MB (mem_limit: 1280M total — romm 768M + mariadb 384M + redis 128M) | Pi-compatible: No (MariaDB + heavy)
#
# Environment variables:
# DOMAIN - Your domain (e.g., demo-felhom.eu)
# HDD_PATH - Drive namespace root (appdata lives here)
# USERDATA_PATH - Ügyfél-tartalom gyökér (<namespace>/userdata)
# DB_PASSWORD - MariaDB user password (auto-generated)
# MYSQL_ROOT_PASSWORD - MariaDB root password (auto-generated)
# ROMM_AUTH_SECRET_KEY - Auth secret (auto-generated)
#
# Storage layout (felhom userdata convention):
# ROM library → ${USERDATA_PATH}/roms (browsable — drop ROMs here via FileBrowser)
# Cover art etc → ${HDD_PATH}/appdata/romm/resources (app-internal, NOT browsable)
# App config → romm_config (named volume, NVMe)
# MariaDB data → romm_db_data (named volume, NVMe)
# Redis data → romm_redis_data (named volume, NVMe)
#
# First-time setup:
# Default login: admin / admin — change immediately!
services:
romm:
image: rommapp/romm:5.3.1
container_name: romm
restart: unless-stopped
depends_on:
romm-db:
condition: service_healthy
romm-redis:
condition: service_healthy
entrypoint: ["/bin/sh", "-c"]
command:
- |
if [ ! -f /romm/config/config.yml ]; then
echo "Creating default config.yml..."
cat > /romm/config/config.yml << 'CONF'
exclude:
platforms: []
roms: []
system:
log_level: INFO
CONF
fi
exec /docker-entrypoint.sh /init
environment:
- ROMM_AUTH_SECRET_KEY=${ROMM_AUTH_SECRET_KEY}
- DB_PASSWD=${DB_PASSWORD}
- DB_HOST=romm-db
- DB_PORT=3306
- DB_NAME=romm
- DB_USER=romm
- REDIS_HOST=romm-redis
- REDIS_PORT=6379
- ROMM_PORT=8080
# 2, not the image's default of 4. MEASURED on demo-hp 2026-09-22 (R-635): each warm
# uvicorn worker holds ~216 MiB, so four of them plus the master reach ~882 MiB and the
# container was OOM-killed at both 512M and 768M — 37 worker SIGKILLs in five minutes,
# ~500% CPU, host load 5.2 while otherwise idle. Four workers is a SERVER default; this
# is one household on one small box. Two workers measure ~450 MiB and fit 768M with
# real headroom. `/init:143` reads this variable: --workers "${WEB_SERVER_CONCURRENCY:-4}".
- WEB_SERVER_CONCURRENCY=2
- IGDB_CLIENT_ID=${IGDB_CLIENT_ID:-}
- IGDB_CLIENT_SECRET=${IGDB_CLIENT_SECRET:-}
- STEAMGRIDDB_API_KEY=${STEAMGRIDDB_API_KEY:-}
- SCREENSCRAPER_USER=${SCREENSCRAPER_USER:-}
- SCREENSCRAPER_PASSWORD=${SCREENSCRAPER_PASSWORD:-}
- MOBYGAMES_API_KEY=${MOBYGAMES_API_KEY:-}
- TZ=Europe/Budapest
volumes:
- ${USERDATA_PATH}/roms:/romm/library
- ${HDD_PATH}/appdata/romm/resources:/romm/resources
- romm_config:/romm/config
networks:
- traefik-public
- romm-internal
deploy:
resources:
limits:
# 768M, not 512M: romm 5.3.0 does not fit in 512M. Measured on demo-hp 2026-09-22 —
# OOMKilled true, 4,530 gunicorn worker SIGKILLs in six hours, ~500% CPU in a permanent
# restart storm, while the web front end still answered 200 so the update read `done`
# (R-635). 5.0.0 did fit; the version moved and the limit did not.
memory: 768M
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8080/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
labels:
- "traefik.enable=true"
- "traefik.http.routers.romm.rule=Host(`${SUBDOMAIN}.${DOMAIN}`)"
- "traefik.http.routers.romm.entrypoints=websecure"
- "traefik.http.routers.romm.tls=true"
- "traefik.http.routers.romm.tls.certresolver=letsencrypt"
- "traefik.http.services.romm.loadbalancer.server.port=8080"
romm-db:
image: mariadb:11.4
container_name: romm-db
restart: unless-stopped
environment:
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
- MYSQL_DATABASE=romm
- MYSQL_USER=romm
- MYSQL_PASSWORD=${DB_PASSWORD}
- TZ=Europe/Budapest
# MARIADB_AUTO_UPGRADE: on a MAJOR engine move the engine converts its own datadir (~7 s on a
# small DB, backs its system tables up first). Operator ruling 2026-09-13 on
# felhom.eu/documentation/audits/SPIKE-r459-mariadb-upgrade-2026-09-06.md. Inert until a
# major moves — and none may, until Slice 4 (R-448) ships: see CLAUDE.md, engine-major rule.
- MARIADB_AUTO_UPGRADE=1
volumes:
- romm_db_data:/var/lib/mysql
networks:
- romm-internal
deploy:
resources:
limits:
memory: 384M
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
romm-redis:
image: redis:7-alpine
container_name: romm-redis
restart: unless-stopped
command: redis-server --appendonly yes
environment:
- TZ=Europe/Budapest
volumes:
- romm_redis_data:/data
networks:
- romm-internal
deploy:
resources:
limits:
memory: 128M
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
volumes:
romm_config:
romm_db_data:
romm_redis_data:
networks:
traefik-public:
external: true
romm-internal: