diff --git a/dbuild/appjail_choices.py b/dbuild/appjail_choices.py new file mode 100644 index 0000000..76ed651 --- /dev/null +++ b/dbuild/appjail_choices.py @@ -0,0 +1,116 @@ +"""Each choice option's AppJail form, for fjord. + +The catalog's AppJail bundle is the default answer to every choice. An option +that adds services (immich's public proxy, a MariaDB for Vikunja) changes the +compose, which podman runs, but AppJail runs appjail-director.yml, which never +saw the option -- so fjord refused every non-default pick on AppJail. + +Here an option's AppJail form is what its director has beyond the default +one: the services and volumes it adds, rendered by the same code that renders +the bundle (the authored director sliced by option, or the director template +with the option's database jail), plus any file the new jails reference. +fjord adds them to the bundle's director and drops what the option drops. +""" + +from __future__ import annotations + +import copy +from typing import Any + +from dbuild import docs + + +def _director_dict(text: str) -> dict[str, Any]: + """Parse director YAML, keeping each !ENV value as the text that wrote it + so the fragment re-renders exactly as the bundle does.""" + import yaml + + class Loader(yaml.SafeLoader): + pass + + # A plain string, the way an author writes it in x-daemonless: + # docs._render_director_override turns it back into the tag. + def env(loader: yaml.SafeLoader, node: yaml.Node) -> str: + return f"!ENV '{loader.construct_scalar(node)}'" + + Loader.add_constructor("!ENV", env) + return yaml.load(text, Loader=Loader) or {} + + +def _added(full: dict[str, Any], base: dict[str, Any]) -> dict[str, Any]: + """The services and volumes full has that base does not.""" + out: dict[str, Any] = {} + for key in ("services", "volumes"): + have = base.get(key) or {} + more = {k: v for k, v in (full.get(key) or {}).items() if k not in have} + if more: + out[key] = more + return out + + +def _render_template_director(cfg: Any, appjail: dict[str, Any]) -> str: + """The generated (non-authored) director for cfg with appjail metadata + replaced: the bundle's own template, in its deploy render.""" + env = docs._get_jinja_env(docs.Path.cwd()) + if env is None: + raise RuntimeError("Could not find dbuild templates") + context = docs._enrich_metadata(cfg) + context["render_mode"] = "deploy" + context["appjail"] = appjail + return env.get_template("appjail-director.yml.j2").render(context) + + +def option_forms(cfg: Any) -> dict[str, dict[str, dict[str, Any]]]: + """choice id -> option id -> {"director": text, "files": {name: text}, + "hostnames": {director service: variable}} + for every option that adds jails. An option that only drops services or + sets values has none: fjord handles those from drop and env.""" + aj = cfg.metadata.appjail if isinstance(cfg.metadata.appjail, dict) else {} + authored = aj.get("director") if isinstance(aj.get("director"), dict) and aj["director"].get("services") else None + out: dict[str, dict[str, dict[str, Any]]] = {} + + if authored is None: + base_text = _render_template_director(cfg, aj) + base = _director_dict(base_text) + + for c in cfg.metadata.choices: + for o in c.options: + files: dict[str, str] = {} + hostnames: dict[str, str] = {} + if authored is not None: + if not o.profile: + continue + added = _added(docs._director_for_option(authored, cfg, [o]), + docs._director_for_option(authored, cfg, None)) + else: + if not o.director_dep: + continue + with_dep = copy.deepcopy(aj) + with_dep["depends_on"] = [*(with_dep.get("depends_on") or []), o.director_dep] + added = _added(_director_dict(_render_template_director(cfg, with_dep)), base) + if o.jail_template_file and o.jail_template: + files[o.jail_template_file] = o.jail_template + # The jail's own service name in the director: what fjord + # names the jail by, and so what the app's host variable + # must say there (the compose calls it "mariadb", the + # director "vikunja-mariadb"). + for var in o.hostnames.values(): + hostnames[o.director_dep["name"]] = var + if not added: + continue + text = docs._render_director_override(added) + text = text.replace("# appjail-director.yml\n\n", "", 1) + out.setdefault(c.id, {})[o.id] = {"director": text, "files": files, "hostnames": hostnames} + return out + + +def merge_into(data: dict[str, Any], forms: dict[str, dict[str, dict[str, Any]]]) -> dict[str, Any]: + """Put each option's AppJail form on its option in choices.to_fjord's + output, as `appjail`.""" + for c in data.get("choices") or []: + for o in c.get("options") or []: + form = (forms.get(c["id"]) or {}).get(o["id"]) + if form: + o["appjail"] = form + return data + diff --git a/dbuild/cli.py b/dbuild/cli.py index a5b9b07..92bdb84 100644 --- a/dbuild/cli.py +++ b/dbuild/cli.py @@ -613,8 +613,11 @@ def _dispatch_choices(cfg: Config, args: argparse.Namespace) -> int: """Print the choices as fjord's catalog carries them.""" import json + from dbuild import appjail_choices from dbuild import choices as choices_mod data = choices_mod.to_fjord(cfg.metadata.choices, cfg.compose_text, cfg.compose_data) + # Each option's AppJail form, so fjord can run a non-default pick there. + data = appjail_choices.merge_into(data, appjail_choices.option_forms(cfg)) text = json.dumps(data, indent=2) + "\n" if args.out == "-": sys.stdout.write(text) diff --git a/tests/test_appjail_choices.py b/tests/test_appjail_choices.py new file mode 100644 index 0000000..5b82b84 --- /dev/null +++ b/tests/test_appjail_choices.py @@ -0,0 +1,55 @@ +"""Each choice option's AppJail form: what fjord adds to the bundle's director +so a non-default pick runs on AppJail too.""" + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from dbuild import appjail_choices +from dbuild import config as dbuild_config +from tests.test_docs import ( + CHOICES_COMPOSE, + CHOICES_ENV, + CONTAINERFILE_J2, + STACK_COMPOSE, + STACK_ENV, + _chdir, +) + + +def _forms(compose: str, env: str, containerfile: str | None = None): + with tempfile.TemporaryDirectory() as d: + t = Path(d) + (t / "compose.yaml").write_text(compose) + (t / "example.env").write_text(env) + if containerfile: + (t / "Containerfile.j2").write_text(containerfile) + with _chdir(t): + return appjail_choices.option_forms(dbuild_config.load(t)) + + +class TestOptionForms(unittest.TestCase): + def test_a_database_option_brings_its_jail(self): + forms = _forms(CHOICES_COMPOSE, CHOICES_ENV, CONTAINERFILE_J2) + pg = forms["database"]["postgres"] + self.assertIn("from: ghcr.io/daemonless/postgres:17", pg["director"]) + self.assertIn("!ENV '${TODO_DB_PASSWORD}'", pg["director"], "values come from .env, as the tag") + self.assertNotIn("'!ENV", pg["director"], "the tag, not a quoted string") + self.assertIn("device: !ENV '${DATABASE_LOCATION}'", pg["director"]) + # The host variable names the jail by its director service. + (svc, var), = pg["hostnames"].items() + self.assertEqual(var, "TODO_DB_HOST") + self.assertIn(f" {svc}:\n", pg["director"]) + self.assertNotIn("sqlite", forms.get("database", {}), "nothing to add, no form") + + def test_a_part_option_brings_its_authored_jail(self): + forms = _forms(STACK_COMPOSE, STACK_ENV) + on = forms["public_proxy"]["on"] + self.assertIn("proxy", on["director"]) + self.assertNotIn("ml:", on["director"], "only what the option adds, not the default jails") + + +if __name__ == "__main__": + unittest.main()