Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions dbuild/docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 6 additions & 2 deletions dbuild/templates/README.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
Expand Down Expand Up @@ -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 %}
Expand All @@ -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 %}
Expand Down
6 changes: 3 additions & 3 deletions dbuild/templates/appjail-director.yml.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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") %}
Expand Down Expand Up @@ -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 %}
Expand Down
2 changes: 1 addition & 1 deletion dbuild/templates/appjail.env.j2
Original file line number Diff line number Diff line change
@@ -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" %}
Expand Down
66 changes: 66 additions & 0 deletions tests/test_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading