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
23 changes: 23 additions & 0 deletions dbuild/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import os
import platform
import re
import shutil
import sys
from dataclasses import dataclass, field
Expand Down Expand Up @@ -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.",
})
Expand Down Expand Up @@ -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", {})
Expand Down Expand Up @@ -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", {})),
)

Expand Down
15 changes: 14 additions & 1 deletion dbuild/docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}")
Expand Down
26 changes: 25 additions & 1 deletion dbuild/lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
34 changes: 34 additions & 0 deletions tests/test_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
42 changes: 42 additions & 0 deletions tests/test_lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading