From 6fba1ce0fc05e5e7325ebb2194bc19441d873cb0 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Tue, 13 Oct 2026 10:15:10 -0400 Subject: [PATCH] appjail: jail env and name follow the compose --- dbuild/docs.py | 24 ++++++++- dbuild/templates/README.j2 | 8 ++- dbuild/templates/appjail-director.yml.j2 | 6 +-- dbuild/templates/appjail.env.j2 | 2 +- tests/test_docs.py | 66 ++++++++++++++++++++++++ 5 files changed, 98 insertions(+), 8 deletions(-) diff --git a/dbuild/docs.py b/dbuild/docs.py index 33023bb..e397ba7 100644 --- a/dbuild/docs.py +++ b/dbuild/docs.py @@ -604,8 +604,23 @@ def _enrich_metadata(cfg: Config, community_override: str | None = None) -> dict if match: base_version = _normalize_base_version(match.group(1)) + # The jail is named as the compose names the service, not the repo: + # fjord matches the two, and refused opencloud-radicale (service + # "radicale") with "this stack has no service named radicale". + app_service = cfg.image + compose_path = Path.cwd() / "compose.yaml" + if compose_path.exists(): + import yaml # lazy, as everywhere in this module + try: + svcs = list(((yaml.safe_load(compose_path.read_text()) or {}).get("services") or {}).keys()) + except yaml.YAMLError: + svcs = [] + if len(svcs) == 1: + app_service = str(svcs[0]) + context = { "name": cfg.image, + "app_service": app_service, "base_version": base_version, "title": meta.title, "description": meta.description, @@ -681,9 +696,9 @@ def _enrich_metadata(cfg: Config, community_override: str | None = None) -> dict for k, v in env_docs.items(): if k not in existing_env_names: if isinstance(v, dict): - cfg.env.append({"name": k, "default": str(v.get("default", ""))}) + cfg.env.append({"name": k, "default": str(v.get("default", "")), "docs_only": True}) else: - cfg.env.append({"name": k, "default": ""}) + cfg.env.append({"name": k, "default": "", "docs_only": True}) # An app shipped with its database reads its values from .env: show what # example.env gives, or the compose's own ${VAR:-fallback}, instead of the @@ -714,6 +729,11 @@ def _enrich_metadata(cfg: Config, community_override: str | None = None) -> dict desc = desc.get("desc", "") item = {"name": name, "default": display_val, "desc": desc} + # Documented but not set by the compose (commented out there) and + # with no value: the jail must not get it set empty -- stump refuses + # STUMP_TRUST_PROXY_HEADERS="" and crash-loops on AppJail only. + if e.get("docs_only") and not val: + item["unset"] = True if name in ["PUID", "PGID", "TZ"]: item["placeholder"] = f"@{name}@" context["env"].append(item) diff --git a/dbuild/templates/README.j2 b/dbuild/templates/README.j2 index c9606b6..e311715 100644 --- a/dbuild/templates/README.j2 +++ b/dbuild/templates/README.j2 @@ -39,7 +39,11 @@ services: {%- if env %} environment: {%- for e in env %} +{%- if e.unset %} + # - {{ e.name }}={{ " # " ~ e.desc if e.desc else "" }} +{%- else %} - {{ e.name }}={{ e.placeholder if (render_mode == "mkdocs" and e.placeholder) else e.default }}{{ " # " ~ e.desc if e.desc else "" }} +{%- endif %} {%- endfor %} {%- endif %} {%- if volumes %} @@ -208,7 +212,7 @@ podman run -d --name {{ name }} \ {%- if healthcheck %} --health-cmd {{ healthcheck }} \ {%- endif %} -{%- for e in env %} +{%- for e in env if not e.unset %} -e {{ e.name }}={{ e.placeholder if (render_mode == "mkdocs" and e.placeholder) else e.default }} \ {%- endfor %} {%- for v in volumes %} @@ -228,7 +232,7 @@ services: - mode: host {%- if env %} environment: -{%- for e in env %} +{%- for e in env if not e.unset %} - {{ e.name }}={{ e.placeholder if (render_mode == "mkdocs" and e.placeholder) else e.default }} {%- endfor %} {%- endif %} diff --git a/dbuild/templates/appjail-director.yml.j2 b/dbuild/templates/appjail-director.yml.j2 index ee1e112..71c01fc 100644 --- a/dbuild/templates/appjail-director.yml.j2 +++ b/dbuild/templates/appjail-director.yml.j2 @@ -31,8 +31,8 @@ options: - nat: {%- endif %} services: - {{ name }}: - name: {{ name | replace("-", "_") }} + {{ app_service or name }}: + name: {{ (app_service or name) | replace("-", "_") }} options: - container: 'args:--pull' {%- for p in ports if ns.vnet or (render_mode is defined and render_mode == "deploy") %} @@ -63,7 +63,7 @@ services: oci: user: root environment: -{%- for e in env if not e.env_only %} +{%- for e in env if not e.env_only and not e.unset %} - {{ e.name }}: !ENV '${{ '{' }}{{ e.name }}{{ '}' }}' {%- endfor %} {%- endif %} diff --git a/dbuild/templates/appjail.env.j2 b/dbuild/templates/appjail.env.j2 index d46cd8d..98ee749 100644 --- a/dbuild/templates/appjail.env.j2 +++ b/dbuild/templates/appjail.env.j2 @@ -1,7 +1,7 @@ # .env DIRECTOR_PROJECT={{ name }} -{%- for e in env %} +{%- for e in env if not e.unset %} {{ e.name }}={{ e.placeholder if (render_mode is defined and render_mode == "mkdocs" and e.placeholder) else e.default }} {%- endfor %} {%- if render_mode is defined and render_mode == "deploy" %} diff --git a/tests/test_docs.py b/tests/test_docs.py index 2f18ecb..44ee08c 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -320,6 +320,72 @@ def test_director_without_the_dropped_jail(self): self.assertNotIn(" mariadb:\n", own, "your own MariaDB: no jail for the bundled one") self.assertIn("container: 'args:--pull'", own, "the app's own jail is still there") + +class TestDirectorMatchesCompose(unittest.TestCase): + """The jail gets what the compose gives the container. A variable the + docs describe but the compose leaves commented out is not set in the + jail: stump got STUMP_TRUST_PROXY_HEADERS="" on AppJail, refused it and + crash-looped (2026-10-12). Podman never set it.""" + + COMPOSE = ( + "name: app\n" + "x-daemonless:\n" + ' title: "App"\n' + ' description: "An app."\n' + ' category: "Productivity"\n' + ' user: "bsd"\n' + " docs:\n" + " env:\n" + ' TZ: "Timezone"\n' + ' APP_PROXY: "Uncomment behind a proxy"\n' + " APP_MODE:\n" + ' desc: "Mode"\n' + ' default: "fast"\n' + "services:\n" + " app:\n" + " image: ghcr.io/daemonless/app:latest\n" + " environment:\n" + " - TZ=UTC\n" + " # - APP_PROXY=true\n" + " ports:\n" + " - 8080:8080\n" + ) + + def test_commented_out_env_stays_out_of_the_jail(self): + with tempfile.TemporaryDirectory() as d: + repo = Path(d) + (repo / ".daemonless").mkdir() + (repo / "compose.yaml").write_text(self.COMPOSE) + (repo / "Containerfile.j2").write_text(CONTAINERFILE_J2) + with _chdir(repo): + cfg = dbuild_config.load(repo) + outputs, errors = docs.render_generated(cfg, argparse.Namespace(community=None), repo) + self.assertEqual(errors, []) + readme = outputs["README.md"] + self.assertNotIn("APP_PROXY=\n", readme, "set empty somewhere") + self.assertNotIn("APP_PROXY= #", readme.replace("# - APP_PROXY= #", ""), "set empty in the compose") + self.assertNotIn("-e APP_PROXY=", readme, "set empty in podman run") + self.assertNotIn("APP_PROXY: !ENV", readme, "set empty in the jail") + self.assertIn("# - APP_PROXY= # Uncomment behind a proxy", readme, "the compose still documents it") + self.assertIn("| `APP_PROXY` |", readme, "the table still lists it") + self.assertIn("APP_MODE: !ENV", readme, "a documented default still reaches the jail") + self.assertIn("TZ: !ENV", readme) + + + def test_jail_is_named_as_the_compose_service(self): + # opencloud-radicale's service is "radicale"; fjord matches the two. + compose = self.COMPOSE.replace("services:\n app:\n", "services:\n radicale:\n") + with tempfile.TemporaryDirectory() as d: + repo = Path(d) / "opencloud-radicale" + (repo / ".daemonless").mkdir(parents=True) + (repo / "compose.yaml").write_text(compose) + (repo / "Containerfile.j2").write_text(CONTAINERFILE_J2) + with _chdir(repo): + cfg = dbuild_config.load(repo) + docs.generate_appjail_files(cfg, Path(d) / "out") + director = (Path(d) / "out" / "appjail-director.yml").read_text() + self.assertIn("\n radicale:\n name: radicale\n", director) + @contextlib.contextmanager def _set_env(key: str, value: str): prev = os.environ.get(key)