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
116 changes: 116 additions & 0 deletions dbuild/appjail_choices.py
Original file line number Diff line number Diff line change
@@ -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

3 changes: 3 additions & 0 deletions dbuild/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
55 changes: 55 additions & 0 deletions tests/test_appjail_choices.py
Original file line number Diff line number Diff line change
@@ -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()
Loading