From ef794b41cfbc284583e6bc5fb2e385ddcb418ad9 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Tue, 29 Sep 2026 17:52:30 -0400 Subject: [PATCH] docs: show the real compose for images shipped with their database; release v1.9.17 --- .github/workflows/daemonless-build.yaml | 2 +- dbuild/__init__.py | 2 +- dbuild/config.py | 40 +++++++++++++++++ dbuild/docs.py | 22 +++++++++- dbuild/templates/README.j2 | 27 ++++++++++-- dbuild/templates/README.mkdocs.j2 | 34 ++++++++++++++- docs/dbuild.1 | 2 +- tests/test_docs.py | 57 +++++++++++++++++++++++++ 8 files changed, 177 insertions(+), 9 deletions(-) diff --git a/.github/workflows/daemonless-build.yaml b/.github/workflows/daemonless-build.yaml index 4eaa48a..5b3e73a 100644 --- a/.github/workflows/daemonless-build.yaml +++ b/.github/workflows/daemonless-build.yaml @@ -50,7 +50,7 @@ on: env: REGISTRY: ghcr.io - DBUILD_REF: v1.9.16 + DBUILD_REF: v1.9.17 jobs: # Detect which variants exist and build dynamic matrix diff --git a/dbuild/__init__.py b/dbuild/__init__.py index e4d6cf9..849ab40 100644 --- a/dbuild/__init__.py +++ b/dbuild/__init__.py @@ -1,4 +1,4 @@ """dbuild: FreeBSD OCI container image build tool. """ -VERSION = "1.9.16" +VERSION = "1.9.17" diff --git a/dbuild/config.py b/dbuild/config.py index 9325566..751c8d9 100644 --- a/dbuild/config.py +++ b/dbuild/config.py @@ -413,6 +413,14 @@ class Config: volumes: list[dict[str, Any]] = field(default_factory=list) ports: list[dict[str, Any]] = field(default_factory=list) + # A compose with more than one service (an app and its database) is shown + # in the docs as the file itself, with example.env as the .env to save: the + # generated one-service snippet dropped the sidecar (librenms' MariaDB, + # paperless-ngx's redis) and listed .env-only variables as the app's + # environment. Empty for a single service, where the snippet is right. + compose_text: str = "" + example_env: str = "" + @property def full_image(self) -> str: """Return the fully-qualified image reference (registry/image).""" @@ -946,6 +954,15 @@ def load(base: Path | None = None) -> Config: # Service data (Env, Volumes, Ports) env, volumes, ports = _parse_service_data(local_data, compose_data) + compose_text = "" + example_env = "" + services = compose_data.get("services") + if isinstance(services, dict) and len(services) > 1: + compose_text = _without_top_level_key(compose_path.read_text(), "x-daemonless") + example_env_path = base / "example.env" + if example_env_path.is_file(): + example_env = example_env_path.read_text() + return Config( image=image_name, registry=registry, @@ -957,4 +974,27 @@ def load(base: Path | None = None) -> Config: env=env, volumes=volumes, ports=ports, + compose_text=compose_text, + example_env=example_env, ) + + +def _without_top_level_key(text: str, key: str) -> str: + """Return compose text without one top-level key and its block. + + Line-based, so the rest keeps its comments and layout -- a YAML round trip + would drop them, and they are the explanation a reader needs. The block + ends at the next line that starts in column 0 and is not a comment. + """ + out: list[str] = [] + skipping = False + for line in text.splitlines(): + if line.startswith(f"{key}:"): + skipping = True + continue + if skipping and line and not line[0].isspace() and not line.startswith("#"): + skipping = False + if not skipping: + out.append(line) + return "\n".join(out).strip() + "\n" + diff --git a/dbuild/docs.py b/dbuild/docs.py index 35bcb2d..e13f169 100644 --- a/dbuild/docs.py +++ b/dbuild/docs.py @@ -5,6 +5,7 @@ import argparse import os +import re from datetime import date from pathlib import Path from typing import Any @@ -336,7 +337,6 @@ def _enrich_metadata(cfg: Config, community_override: str | None = None) -> dict base_version = "15.1" # fallback default cf_path = Path.cwd() / "Containerfile.j2" if cf_path.exists(): - import re match = re.search(r"ARG BASE_VERSION=(.*)", cf_path.read_text()) if match: base_version = _normalize_base_version(match.group(1)) @@ -393,6 +393,11 @@ def _enrich_metadata(cfg: Config, community_override: str | None = None) -> dict "screenshots": _collect_screenshots(Path.cwd()), "cit_mode": cfg.test.mode if cfg.test else "none", "image_class": meta.image_class, + "compose_text": cfg.compose_text, + "example_env": cfg.example_env, + # The AppJail bundle carries the sidecar (x-daemonless.appjail.depends_on), + # so its Director section runs the whole app, not the app alone. + "appjail_sidecar": bool((getattr(meta, "appjail", None) or {}).get("depends_on")), "env": [], "volumes": [], "ports": [], @@ -410,10 +415,25 @@ def _enrich_metadata(cfg: Config, community_override: str | None = None) -> dict else: cfg.env.append({"name": k, "default": ""}) + # 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 + # raw reference (a Default column reading "${DB_PASSWORD}"). + env_file: dict[str, str] = {} + for line in cfg.example_env.splitlines(): + k, sep, v = line.partition("=") + if sep and k.strip() and not k.lstrip().startswith("#"): + env_file[k.strip()] = v.strip() + for e in cfg.env: name = e["name"] val = e["default"] + if cfg.compose_text: + ref = re.fullmatch(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}", str(val or "")) + if ref: + val = env_file.get(ref.group(1), ref.group(2) or "") + elif not val: + val = env_file.get(name, "") display_val = val if val and val not in ['""', "''"] else "" if not display_val and any(x in name.upper() for x in ["PASS", "KEY", "SECRET", "TOKEN"]): display_val = f"<{name.upper()}>" diff --git a/dbuild/templates/README.j2 b/dbuild/templates/README.j2 index f71ee63..8a3070c 100644 --- a/dbuild/templates/README.j2 +++ b/dbuild/templates/README.j2 @@ -447,6 +447,24 @@ Before deploying, ensure your host environment is ready. See the [Quick Start Gu ### Podman Compose +{%- if compose_text %} +{%- if example_env %} + +**1.** Save as `.env` and fill in what is empty: + +```env { data-zip-bundle="{{ name }}-podman" data-zip-filename=".env" } +{{ example_env | trim }} +``` + +**2.** Save as `compose.yaml`: +{%- endif %} + +```yaml{% if example_env %} { data-zip-bundle="{{ name }}-podman" data-zip-filename="compose.yaml" }{% endif %} +{{ compose_text | trim }} +``` + +Then run `podman-compose up -d`. +{%- else %} {%- if env and image_class == "stack" %} **1.** Save as `.env`: @@ -469,7 +487,8 @@ Then run `podman-compose up -d`. Save as `compose.yaml`, then run `podman-compose up -d`. {%- endif %} -{%- if appjail_enabled %} +{%- endif %} +{%- if appjail_enabled and (not compose_text or appjail_sidecar) %} ### AppJail Director @@ -485,7 +504,9 @@ Save the files above, then run `appjail-director up`. {%- endif %} {%- endif %} -{%- if image_class in ["service", "cli"] %} +{#- One-container recipes cannot start a sidecar: a compose with several + services (an app and its database) is deployed with compose only. #} +{%- if image_class in ["service", "cli"] and not compose_text %} ### Podman CLI @@ -495,7 +516,7 @@ Save the files above, then run `appjail-director up`. Save as `run.sh`, then run `sh run.sh`. {%- endif %} -{%- if image_class == "service" %} +{%- if image_class == "service" and not compose_text %} {%- if appjail_enabled %} ### AppJail diff --git a/dbuild/templates/README.mkdocs.j2 b/dbuild/templates/README.mkdocs.j2 index 751871d..1e4f805 100644 --- a/dbuild/templates/README.mkdocs.j2 +++ b/dbuild/templates/README.mkdocs.j2 @@ -83,7 +83,35 @@ Before deploying, ensure your host environment is ready. See the [Quick Start Gu {% block deployment_tabs %} {%- set port_var = "@" ~ name.upper().replace("-", "_") ~ "_PORT@" %} {%- set bastille_tab = bastille_enabled and image_class == "service" %} -{%- if appjail_enabled or bastille_tab %} +{%- if compose_text %} +{#- An app shipped with its database: the real compose and the .env it reads. + The one-service snippet dropped the database; the one-container recipes + (CLI, Ansible, Bastille) would start the app without it. #} +=== ":material-docker: Podman" +{% if example_env %} + **1.** Save as `.env` and fill in what is empty: + + ```env { data-zip-bundle="{{ name }}-podman" data-zip-filename=".env" } + {{ example_env | trim | indent(4) }} + ``` + + **2.** Save as `compose.yaml`: +{% endif %} + ```yaml { data-zip-bundle="{{ name }}-podman" data-zip-filename="compose.yaml" } + {{ compose_text | trim | indent(4) }} + ``` + + Then run `podman-compose up -d`. +{% if appjail_enabled and appjail_sidecar %} +=== ":appjail-appjail: AppJail" + + === ":material-file-document-outline: Director" + + {{ appjail_director_snippet(port_var) | indent(8) }} + + Save the files above, then run `appjail-director up`. +{% endif %} +{%- elif appjail_enabled or bastille_tab %} {#- More than one runtime: nest formats under a per-runtime outer tab. #} === ":material-docker: Podman" @@ -189,7 +217,9 @@ Before deploying, ensure your host environment is ready. See the [Quick Start Gu {% endblock %} {% block interactive_config %} -{%- if image_class == "service" %} +{#- The settings panel fills in the one-service snippet; an app shipped with + its database shows its real compose instead, which it cannot edit. #} +{%- if image_class == "service" and not compose_text %} ### Interactive Configuration diff --git a/docs/dbuild.1 b/docs/dbuild.1 index bc570e2..90072ea 100644 --- a/docs/dbuild.1 +++ b/docs/dbuild.1 @@ -1,4 +1,4 @@ -.TH DBUILD 1 "2026-09-12" "dbuild 1.9.16" "User Commands" +.TH DBUILD 1 "2026-09-29" "dbuild 1.9.17" "User Commands" .SH NAME dbuild \- FreeBSD OCI container image build tool .SH SYNOPSIS diff --git a/tests/test_docs.py b/tests/test_docs.py index d7c2a69..cdd6172 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -188,6 +188,63 @@ def test_cli_image_renders_podman_cli_usage(self): self.assertIn("ghcr.io/daemonless/", readme) +class TestReadmeWithSidecar(unittest.TestCase): + """An app shipped with its database is documented as its real compose. + + The one-service snippet dropped the sidecar (librenms' MariaDB, + paperless-ngx's redis) and listed .env-only variables as the app's + environment; the one-container recipes started the app without it. + """ + + COMPOSE = ( + "name: app\n" + "x-daemonless:\n" + ' title: "App"\n' + ' description: "An app and its database."\n' + ' category: "Monitoring"\n' + " docs:\n" + " env:\n" + ' CONFIG_LOCATION: "Host folder for app data"\n' + "services:\n" + " app:\n" + " image: ghcr.io/daemonless/app:latest\n" + " environment:\n" + " # found over localhost on host networking\n" + " - DB_HOST=127.0.0.1\n" + " - DB_PASSWORD=${DB_PASSWORD}\n" + " volumes:\n" + " - ${CONFIG_LOCATION}:/config\n" + " app-mariadb:\n" + " image: ghcr.io/daemonless/mariadb:latest\n" + " environment:\n" + " - MYSQL_PASSWORD=${DB_PASSWORD}\n" + ) + + def test_real_compose_and_env_shown(self): + with tempfile.TemporaryDirectory() as d: + repo = Path(d) + (repo / "compose.yaml").write_text(self.COMPOSE) + (repo / "example.env").write_text("CONFIG_LOCATION=/containers/app\nDB_PASSWORD=\n") + (repo / "Containerfile.j2").write_text(CONTAINERFILE_J2) + with _chdir(repo): + cfg = dbuild_config.load(repo) + self.assertEqual(docs.run(cfg, argparse.Namespace(community=None)), 0) + readme = (repo / "README.md").read_text() + compose = readme.split("### Podman Compose", 1)[1] + self.assertIn("app-mariadb:", compose) # the sidecar is there + self.assertIn("# found over localhost on host networking", compose) # comments kept + self.assertIn("DB_PASSWORD=\n", compose) # example.env as the .env + self.assertNotIn("x-daemonless", compose) + self.assertNotIn("CONFIG_LOCATION= #", compose) # no .env-only var as app env + for section in ("### Podman CLI", "### Bastille", "### Ansible", "### AppJail Director"): + self.assertNotIn(section, readme) + + def test_single_service_unchanged(self): + with tempfile.TemporaryDirectory() as d: + repo = _make_repo(Path(d)) + self.assertEqual(dbuild_config.load(repo).compose_text, "") + + @contextlib.contextmanager def _set_env(key: str, value: str): prev = os.environ.get(key)