From 528ebea193562c796229e6426a3d56f49f571238 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Tue, 13 Oct 2026 13:53:15 -0400 Subject: [PATCH] feat: wrap app CLIs to run as the app user (bsd) podmaan exec runs as root and leaves root-owned files behind. Each CLI an image declares gets a wrapper from the base's daemonless-cli, installed at the end of the Containerfile. --- dbuild/config.py | 23 +++++++++++++++++++++++ dbuild/docs.py | 15 ++++++++++++++- dbuild/lint.py | 26 +++++++++++++++++++++++++- tests/test_docs.py | 34 ++++++++++++++++++++++++++++++++++ tests/test_lint.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 138 insertions(+), 2 deletions(-) diff --git a/dbuild/config.py b/dbuild/config.py index 4fc62b3..983dd34 100644 --- a/dbuild/config.py +++ b/dbuild/config.py @@ -9,6 +9,7 @@ import os import platform +import re import shutil import sys from dataclasses import dataclass, field @@ -405,6 +406,15 @@ class Metadata: "doc": "Stack choices (x-daemonless.choices): what the compose offers " "under profiles -- a database to pick, a part to leave out. See dbuild.choices.", }) + cli: list[str] = field(default_factory=list, metadata={ + "desc": ( + "CLIs that run as the app user under `podman exec` (e.g. `[thelounge]`), " + "so they can't leave root-owned files in /config. Each gets a wrapper " + "from the base's `daemonless-cli` in `/usr/local/daemonless/bin`, which base puts " + "first on PATH; the real binary's full path still runs as root." + ), + "display_default": "[]", + }) readme: ReadmeBlocks | None = field(default=None, metadata={ "desc": "Custom Markdown blocks to inject into specific sections of the README.", }) @@ -730,6 +740,18 @@ def _parse_deprecated(meta: dict[str, Any]) -> DeprecationInfo | None: return DeprecationInfo() +def is_cli_name(name: Any) -> bool: + """A command on PATH: no path, no arguments ('php occ' would wrap php).""" + return (isinstance(name, str) and name != "daemonless-cli" + and re.fullmatch(r"[A-Za-z0-9._+-]+", name) is not None) + + +def _parse_cli(meta: dict[str, Any]) -> list[str]: + """Parse ``cli:``. Shape errors are left to lint; bad names are skipped.""" + raw = meta.get("cli") + return [c for c in raw if is_cli_name(c)] if isinstance(raw, list) else [] + + def _parse_metadata(data: dict[str, Any], app_name: str, base: Path | None = None) -> Metadata: """Parse the ``x-daemonless:`` section of the config file.""" meta = data.get("x-daemonless", {}) @@ -768,6 +790,7 @@ def _parse_metadata(data: dict[str, Any], app_name: str, base: Path | None = Non image_class=meta.get("class", "service"), choices=choices_mod.parse(meta, data), deprecated=_parse_deprecated(meta), + cli=_parse_cli(meta), readme=ReadmeBlocks(**meta.get("readme", {})), ) diff --git a/dbuild/docs.py b/dbuild/docs.py index e397ba7..20e293c 100644 --- a/dbuild/docs.py +++ b/dbuild/docs.py @@ -1006,6 +1006,17 @@ def _readme_generation_mode(cfg: Config, base: Path) -> str: return "generate" +def _cli_wrappers(names: list[str]) -> str: + """The line x-daemonless.cli adds to the end of a Containerfile, i.e. to + its final stage. Base puts the wrapper dir first on PATH.""" + if not names: + return "" + return ( + "\n# CLIs run as the app user under podman exec (x-daemonless.cli)\n" + f"RUN daemonless-cli --install {' '.join(names)}\n" + ) + + def render_generated( cfg: Config, args: argparse.Namespace, @@ -1066,7 +1077,9 @@ def render_generated( repo_env = jinja2.Environment(loader=jinja2.FileSystemLoader(str(base))) template = repo_env.get_template(j2_path.name) outputs[out_name] = ( - HEADER_TEMPLATE.format(source=j2_path.name) + template.render(context) + HEADER_TEMPLATE.format(source=j2_path.name) + + template.render(context) + + _cli_wrappers(cfg.metadata.cli) ) except Exception as e: errors.append(f"Failed to render {out_name}: {e}") diff --git a/dbuild/lint.py b/dbuild/lint.py index 51b7dbe..e201aea 100644 --- a/dbuild/lint.py +++ b/dbuild/lint.py @@ -11,7 +11,7 @@ from typing import Any from dbuild import choices as choices_mod -from dbuild.config import VALID_CATEGORIES, VALID_TYPES +from dbuild.config import VALID_CATEGORIES, VALID_TYPES, is_cli_name try: import yaml @@ -145,6 +145,30 @@ def lint_repo(repo_path: Path, verbose: bool = False) -> tuple[list[str], list[s else: errors.append(f"Missing required field: x-daemonless.{field}") + cli = meta.get("cli") + if cli is not None: + if not isinstance(cli, list) or not cli: + errors.append("x-daemonless.cli must be a list of CLI names, e.g. [thelounge]") + else: + for c in cli: + if not is_cli_name(c): + errors.append( + f"x-daemonless.cli: {c!r} is not a CLI name" + " (a command on PATH, no path or arguments)" + ) + # Base puts the wrapper dir first on PATH; an image that + # sets its own (a venv first) would run the real binary. + sources = sorted(repo_path.glob("Containerfile*.j2")) or sorted(repo_path.glob("Containerfile*")) + for cf_path in sources: + for line in cf_path.read_text().splitlines(): + m = re.match(r'\s*(?:ENV\s+)?PATH[=\s]\s*"?([^"\s]*)', line) + if m and not m.group(1).startswith("/usr/local/daemonless/bin:"): + errors.append( + f"{cf_path.name} sets PATH without /usr/local/daemonless/bin first," + " so x-daemonless.cli wrappers would be skipped:" + ' use PATH="/usr/local/daemonless/bin:..."' + ) + description = meta.get("description", "") if "deprecated" in description.lower(): warnings.append( diff --git a/tests/test_docs.py b/tests/test_docs.py index 44ee08c..83f0b11 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -924,3 +924,37 @@ def test_readme_has_no_expose_bundle_keeps_it(self): self.assertIn("ip4_inherit", aj) self.assertNotIn("expose:", aj, "no expose next to ip4_inherit") self.assertIn("expose:", bundle, "the deploy bundle keeps them for fjord") + + +class TestCliWrappers(unittest.TestCase): + """x-daemonless.cli: the CLIs get wrappers in the final stage.""" + + def _render(self, cli_yaml: str): + compose = COMPOSE_YAML.format(name="chat").replace( + ' user: "bsd"\n', ' user: "bsd"\n' + cli_yaml) + with tempfile.TemporaryDirectory() as d: + repo = Path(d) + (repo / ".daemonless").mkdir() + (repo / "compose.yaml").write_text(compose) + (repo / "Containerfile.j2").write_text(CONTAINERFILE_J2) + (repo / "Containerfile.pkg.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, []) + return outputs + + def test_declared_clis_are_wrapped_last(self): + outputs = self._render(" cli: [thelounge, occ]\n") + for name in ("Containerfile", "Containerfile.pkg"): + cf = outputs[name] + self.assertTrue(cf.endswith("RUN daemonless-cli --install thelounge occ\n"), cf) + + def test_no_cli_leaves_the_containerfile_alone(self): + outputs = self._render("") + self.assertNotIn("daemonless-cli", outputs["Containerfile"]) + + def test_a_command_line_is_not_wrapped(self): + outputs = self._render(" cli: [thelounge, 'php occ']\n") + self.assertIn("RUN daemonless-cli --install thelounge\n", outputs["Containerfile"], + "'php occ' would wrap php itself; lint reports it") diff --git a/tests/test_lint.py b/tests/test_lint.py index 43a6aa3..fbb522e 100644 --- a/tests/test_lint.py +++ b/tests/test_lint.py @@ -395,3 +395,45 @@ def test_standalone_cleanup_run_warns(self): any("Standalone cleanup 'RUN'" in w for w in warnings), f"Expected standalone cleanup warning, got {warnings}", ) + + +class TestCliLint(unittest.TestCase): + """x-daemonless.cli names commands on PATH, nothing else.""" + + def _errors(self, cli_yaml: str) -> list[str]: + import tempfile + with tempfile.TemporaryDirectory() as d: + p = Path(d) + (p / "Containerfile").write_text("FROM scratch\n") + (p / "compose.yaml").write_text( + "name: chat\nx-daemonless:\n title: Chat\n icon: ':x:'\n category: Utilities\n" + " description: d\n upstream_url: https://example.com\n user: bsd\n" + + cli_yaml + "services:\n chat:\n image: ghcr.io/daemonless/chat:latest\n") + errors, _ = lint_repo(p) + return [e for e in errors if "x-daemonless.cli" in e] + + def test_plain_names_pass(self): + self.assertEqual(self._errors(" cli: [thelounge, occ]\n"), []) + + def test_paths_and_arguments_fail(self): + errs = self._errors(" cli: [/usr/local/bin/thelounge, 'php occ']\n") + self.assertEqual(len(errs), 2, errs) + + def test_not_a_list_fails(self): + self.assertEqual(len(self._errors(" cli: thelounge\n")), 1) + + def test_own_path_must_keep_the_wrappers_first(self): + import tempfile + cf = 'FROM scratch\nENV NODE_PATH="/x" \\\n PATH="/opt/venv/bin:$PATH"\nENV LD_LIBRARY_PATH=/y\n' + with tempfile.TemporaryDirectory() as d: + p = Path(d) + (p / "Containerfile.j2").write_text(cf) + (p / "compose.yaml").write_text( + "name: chat\nx-daemonless:\n title: Chat\n icon: ':x:'\n category: Utilities\n" + " description: d\n upstream_url: https://example.com\n user: bsd\n cli: [chat]\n" + "services:\n chat:\n image: ghcr.io/daemonless/chat:latest\n") + errors, _ = lint_repo(p) + self.assertEqual(len([e for e in errors if "sets PATH" in e]), 1, errors) + (p / "Containerfile.j2").write_text(cf.replace("/opt/venv/bin:", "/usr/local/daemonless/bin:/opt/venv/bin:")) + errors, _ = lint_repo(p) + self.assertFalse([e for e in errors if "sets PATH" in e], errors)