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
2 changes: 1 addition & 1 deletion .github/workflows/daemonless-build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion dbuild/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""dbuild: FreeBSD OCI container image build tool.
"""

VERSION = "1.9.16"
VERSION = "1.9.17"
40 changes: 40 additions & 0 deletions dbuild/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
Expand Down Expand Up @@ -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,
Expand All @@ -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"

22 changes: 21 additions & 1 deletion dbuild/docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import argparse
import os
import re
from datetime import date
from pathlib import Path
from typing import Any
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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": [],
Expand All @@ -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()}>"
Expand Down
27 changes: 24 additions & 3 deletions dbuild/templates/README.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

Expand All @@ -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

Expand All @@ -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

Expand All @@ -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
Expand Down
34 changes: 32 additions & 2 deletions dbuild/templates/README.mkdocs.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/dbuild.1
Original file line number Diff line number Diff line change
@@ -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
Expand Down
57 changes: 57 additions & 0 deletions tests/test_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading